LearnNewsExamplesServices
Frontmatter
id12247
titleLint learn/tree.json against the learn/ folder structure
stateClosed
labels
enhancementaitestingbuild
assigneesneo-opus-ada
createdAtMay 31, 2026, 7:57 AM
updatedAtJun 7, 2026, 7:17 PM
githubUrlhttps://github.com/neomjs/neo/issues/12247
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 31, 2026, 1:23 PM

Lint learn/tree.json against the learn/ folder structure

Closed v13.0.0/archive-v13-0-0-chunk-15 enhancementaitestingbuild
neo-opus-ada
neo-opus-ada commented on May 31, 2026, 7:57 AM

Context

learn/tree.json is the docs-portal navigation tree and also feeds buildScripts/docs/seo/generate.mjs (content URLs + sitemap). It is hand-edited JSON with no structural enforcement — a leaf id can point at a missing .md, a parentId can reference a non-existent group, or a phantom nav group can be introduced that has no backing folder. The two existing linters (ai/scripts/lint/lint-agents.mjs, lint-skill-manifest.mjs) only cover agent substrate; neither touches tree.json.

This drift surfaced twice in a single session during the identity rollout (Epic #12225) — caught by humans late in review, not by tooling:

  • #12238 — a phantom BenefitsBrain nav group was added over files that were flat in learn/benefits/; flagged by the operator AND a cross-family reviewer.
  • #12240 — a parent group label outgrew its contents; flagged in cross-family review.

A lint moves the catch from late human review to authoring/CI time. This is friction→gold from the #12225 rollout: tooling-hardening the exact failure mode that cost two review-cycle catches.

The Problem

tree.json is a flat parentId adjacency list{"data": [ {name, parentId, id, isLeaf?, collapsed?} ... ]} — not a nested tree. Hierarchy is encoded via parentId; a leaf's id is its learn/-relative path (benefits/Introductionlearn/benefits/Introduction.md); group nodes carry isLeaf:false and a PascalCase id referenced by children's parentId.

Failure modes with no current guard: dangling leaf (id with no file), orphan node (parentId with no matching group), leaf pointing at a non-existent folder, and the phantom-group case (a nav group that splits one real folder into two nav buckets, implying a subfolder that does not exist — the #12238 signature).

The Architectural Reality

  • Validator: new ai/scripts/lint/lint-tree-json.mjs, sibling to lint-agents.mjs + lint-skill-manifest.mjs (structural-pre-flight: sibling-pattern match, no novel directory).
  • CI wiring: .github/workflows/skill-manifest-lint.yml is the existing lint workflow; add a learn/tree.json + learn/** path trigger and a run step (or a focused tree-json-lint.yml if it reads cleaner separate).
  • npm script: ai:lint-tree-json, mirroring ai:lint-agents.
  • Folder ↔ group relationship: a leaf benefits/X implies the learn/benefits/ folder; a group's leaf-children share that prefix. The phantom-group signature is two distinct groups parenting leaves of the same folder-prefix.

The Fix

Add ai/scripts/lint/lint-tree-json.mjs enforcing these mechanical invariants (exit non-zero + actionable message on violation):

  1. Leaf-id → file exists — every leaf node's id resolves to an existing learn/<id>.md.
  2. parentId integrity — every non-null parentId references an existing node whose isLeaf is false.
  3. Folder-prefix exists — every leaf id's directory part resolves to a real folder under learn/.
  4. One folder ↔ one nav group (phantom-group guard) — no two distinct group nodes may parent leaves sharing the same folder-prefix.

Wire into CI (path-triggered) + add the ai:lint-tree-json npm script.

Contract Ledger

This ticket ships a surface consumed by agents + CI (npm script, CLI, violation-code/output contract, workflow), so per the Contract Completeness Gate:

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
ai:lint-tree-json npm script package.json scripts (sibling ai:lint-agents) runs node ./ai/scripts/lint/lint-tree-json.mjs (no args); exit 0 = clean, exit 1 = violations n/a — additive script, no prior consumer package.json npm run ai:lint-tree-jsonOK … (190 nodes), exit 0
CLI node ai/scripts/lint/lint-tree-json.mjs this ticket no-arg full-file validation of learn/tree.json; --help/-h prints usage + exits 0; exits 1 with a per-violation report otherwise n/a script @summary + --help output lintTreeJson.spec.mjs CLI tests (--help exits 0; real tree passes)
Violation-code / output contract this ticket (lintTree return shape) each violation is {code, message}; stable codes LEAF_FILE, PARENT_NOT_GROUP, ORPHAN, GROUP_SPANS_FOLDERS, PHANTOM_GROUP, plus input-guard codes DUP_ID / MISSING_ID / STRUCTURE / PARSE n/a script @summary 12 unit tests assert each code in isolation
CI trigger (.github/workflows/tree-json-lint.yml) .github/workflows/ convention runs on pull_request/pushdev touching learn/**, the lint script, or the workflow; node 24; full-file (no diff-base) n/a — new workflow runs post-merge (GitHub does not run a newly-added workflow on the PR that adds it) workflow file Post-Merge Validation in the PR
Unit-test coverage contract this ticket test/playwright/unit/ai/scripts/lint/lintTreeJson.spec.mjs covers every invariant + CLI + folderPrefix/isGroup under playwright.config.unit.mjs n/a test file npm run test-unit -- …/lintTreeJson.spec.mjs → 12 passed

Row-level Surface-Anchor V-B-A: every row names a surface created by this ticket's own PR (#12248) and verified working (lint run + 12 passing tests + green CI), so the anchors are confirmed-by-construction, not asserted.

As-built note: the shipped invariant 3 was reframed from the original "folder-prefix exists" (subsumed by Leaf-id→file) to group-cohesion (GROUP_SPANS_FOLDERS: a group's direct leaves share one folder-prefix) — the non-redundant complement to invariant 4. See the PR's Deltas.

Acceptance Criteria

  • ai/scripts/lint/lint-tree-json.mjs exists and enforces invariants 1–4.
  • Running it against the current (clean) learn/tree.json exits 0.
  • A self-test / negative fixture demonstrates each invariant fails as designed (notably: a phantom-group construction is rejected — the #12238 reproducer).
  • ai:lint-tree-json npm script added.
  • CI runs the lint on PRs touching learn/tree.json or learn/**.
  • Unit coverage for the 4 invariants (canonical Neo playwright-unit location, or an in-script self-test if a runtime harness is overkill).

Out of Scope

  • Semantic checks needing human judgment: nav ordering priority and parent-label↔contents naming fit (the #12240 case). These are review-judgment, not mechanical. Ordering MAY later be added as an advisory (warn, not fail) check — flagged here, not enforced now.
  • Rewriting / normalizing tree.json content — this ticket only adds validation.
  • Any change to the docs-portal renderer or generate.mjs logic.

Avoided Traps

  • Not a JSON-schema-only validator: json.load already passes today (the SEO generator parses it); the drift is structural-vs-folder, which a schema can't express.
  • Not modeling tree.json as a nested children tree — it is a flat parentId adjacency list; a naive nested walk returns 0 nodes (verified empirically before filing).

Decision Record impact

none — net-new tooling; no ADR governs tree.json structure.

Related

  • Empirical anchors: #12238 (phantom BenefitsBrain group), #12240 (parent-label mismatch), both under Epic #12225 (identity rollout).
  • Sibling tooling: ai/scripts/lint/lint-agents.mjs, lint-skill-manifest.mjs; CI .github/workflows/skill-manifest-lint.yml.

Origin Session ID: d2fbe3b2-44c4-4aab-b8c7-62621babc790 Retrieval Hint: "tree.json folder-structure lint phantom group"; lands as ai/scripts/lint/lint-tree-json.mjs.

tobiu referenced in commit 3d5f07c - "feat(lint): validate learn/tree.json mirrors the learn/ folder structure (#12247) (#12248) on May 31, 2026, 1:23 PM
tobiu closed this issue on May 31, 2026, 1:23 PM