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/Introduction → learn/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):
- Leaf-id → file exists — every leaf node's
id resolves to an existing learn/<id>.md.
- parentId integrity — every non-null
parentId references an existing node whose isLeaf is false.
- Folder-prefix exists — every leaf
id's directory part resolves to a real folder under learn/.
- 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-json → OK … (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/push → dev 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
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.
Context
learn/tree.jsonis the docs-portal navigation tree and also feedsbuildScripts/docs/seo/generate.mjs(content URLs + sitemap). It is hand-edited JSON with no structural enforcement — a leafidcan point at a missing.md, aparentIdcan 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 touchestree.json.This drift surfaced twice in a single session during the identity rollout (Epic #12225) — caught by humans late in review, not by tooling:
BenefitsBrainnav group was added over files that were flat inlearn/benefits/; flagged by the operator AND a cross-family reviewer.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.jsonis a flatparentIdadjacency list —{"data": [ {name, parentId, id, isLeaf?, collapsed?} ... ]}— not a nested tree. Hierarchy is encoded viaparentId; a leaf'sidis itslearn/-relative path (benefits/Introduction→learn/benefits/Introduction.md); group nodes carryisLeaf:falseand a PascalCaseidreferenced by children'sparentId.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
ai/scripts/lint/lint-tree-json.mjs, sibling tolint-agents.mjs+lint-skill-manifest.mjs(structural-pre-flight: sibling-pattern match, no novel directory)..github/workflows/skill-manifest-lint.ymlis the existing lint workflow; add alearn/tree.json+learn/**path trigger and a run step (or a focusedtree-json-lint.ymlif it reads cleaner separate).ai:lint-tree-json, mirroringai:lint-agents.benefits/Ximplies thelearn/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.mjsenforcing these mechanical invariants (exit non-zero + actionable message on violation):idresolves to an existinglearn/<id>.md.parentIdreferences an existing node whoseisLeafisfalse.id's directory part resolves to a real folder underlearn/.Wire into CI (path-triggered) + add the
ai:lint-tree-jsonnpm 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:
ai:lint-tree-jsonnpm scriptpackage.jsonscripts (siblingai:lint-agents)node ./ai/scripts/lint/lint-tree-json.mjs(no args); exit 0 = clean, exit 1 = violationspackage.jsonnpm run ai:lint-tree-json→OK … (190 nodes), exit 0node ai/scripts/lint/lint-tree-json.mjslearn/tree.json;--help/-hprints usage + exits 0; exits 1 with a per-violation report otherwise@summary+--helpoutputlintTreeJson.spec.mjsCLI tests (--helpexits 0; real tree passes)lintTreereturn shape){code, message}; stable codesLEAF_FILE,PARENT_NOT_GROUP,ORPHAN,GROUP_SPANS_FOLDERS,PHANTOM_GROUP, plus input-guard codesDUP_ID/MISSING_ID/STRUCTURE/PARSE@summary.github/workflows/tree-json-lint.yml).github/workflows/conventionpull_request/push→devtouchinglearn/**, the lint script, or the workflow; node 24; full-file (no diff-base)test/playwright/unit/ai/scripts/lint/lintTreeJson.spec.mjscovers every invariant + CLI +folderPrefix/isGroupunderplaywright.config.unit.mjsnpm run test-unit -- …/lintTreeJson.spec.mjs→ 12 passedRow-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.mjsexists and enforces invariants 1–4.learn/tree.jsonexits 0.ai:lint-tree-jsonnpm script added.learn/tree.jsonorlearn/**.Out of Scope
tree.jsoncontent — this ticket only adds validation.generate.mjslogic.Avoided Traps
json.loadalready passes today (the SEO generator parses it); the drift is structural-vs-folder, which a schema can't express.tree.jsonas a nested children tree — it is a flatparentIdadjacency list; a naive nested walk returns 0 nodes (verified empirically before filing).Decision Record impact
none— net-new tooling; no ADR governstree.jsonstructure.Related
BenefitsBraingroup), #12240 (parent-label mismatch), both under Epic #12225 (identity rollout).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.