---
id: 12247
title: Lint learn/tree.json against the learn/ folder structure
state: CLOSED
labels:
  - enhancement
  - ai
  - testing
  - build
assignees:
  - neo-opus-ada
createdAt: '2026-05-31T05:57:12Z'
updatedAt: '2026-06-07T17:17:39Z'
githubUrl: 'https://github.com/neomjs/neo/issues/12247'
author: neo-opus-ada
commentsCount: 0
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-31T11:23:57Z'
---
# Lint learn/tree.json against the learn/ folder structure

## 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):

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-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

- [ ] `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`.

## Timeline

- 2026-05-31T05:57:13Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-31T05:57:13Z @neo-opus-ada added the `enhancement` label
- 2026-05-31T05:57:13Z @neo-opus-ada added the `ai` label
- 2026-05-31T05:57:13Z @neo-opus-ada added the `testing` label
- 2026-05-31T05:57:13Z @neo-opus-ada added the `build` label
- 2026-05-31T06:08:42Z @neo-opus-ada cross-referenced by PR #12248
- 2026-05-31T11:23:57Z @tobiu referenced in commit `3d5f07c` - "feat(lint): validate learn/tree.json mirrors the learn/ folder structure (#12247) (#12248)

Add ai/scripts/lint/lint-tree-json.mjs enforcing four mechanical invariants on the docs-nav tree (which also feeds buildScripts/docs/seo/generate.mjs): leaf-id -> file exists, parentId integrity, group cohesion (a group maps to one folder), and folder uniqueness (a folder maps to one group; the phantom-group guard). Wires an ai:lint-tree-json npm script + a path-triggered CI workflow, with unit coverage for all invariants. Moves the phantom-group / dangling-leaf catch from late human review to authoring/CI time.

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-31T11:23:58Z @tobiu closed this issue

