From 097806adc4fd356bbeeb7e855538e5960e68d2b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 03:11:59 +0000 Subject: [PATCH 1/2] feat(cli): resolve each package's docs directory from the registered packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `os build` derived a package's docs directory from a fixed one level under `src/`, so the ADR-0130 D4 reference layout (`src/packages//docs/`) was dropped silently — exit 0, nothing printed, not even the `docs/uncollected-directory` warning, because the sweep never looked there. The sweep now finds each package's directory by the names the artifact's `packages[]` answers to, at no fixed depth: the recursion exists only to find a registered package, stops at one, and does not happen at all when there are none — so a single-package stack is still walked exactly one level. Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude --- .../utils/collect-docs.package-docs.test.ts | 148 ++++++++++++++ packages/cli/src/utils/collect-docs.ts | 191 ++++++++++++++---- 2 files changed, 303 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/utils/collect-docs.package-docs.test.ts b/packages/cli/src/utils/collect-docs.package-docs.test.ts index 12b0e424c73..e97dd6c8e18 100644 --- a/packages/cli/src/utils/collect-docs.package-docs.test.ts +++ b/packages/cli/src/utils/collect-docs.package-docs.test.ts @@ -258,6 +258,154 @@ describe('the #18170 warning stays for a directory neither convention reads', () }); }); +// ── #18965: the docs directory resolves PER REGISTERED PACKAGE, at no depth ─ +/** + * The maintainer's ruling (batch #204 item 5, letter B): `os build` derives + * each package's docs directory from the packages the artifact REGISTERS, not + * from a fixed depth under `src/`. A (declare `src//docs/` the convention + * and rename the reference fixture) and C (scan two levels) were rejected. + * + * ## Why the two-level layout is built HERE and not in the reference fixture + * + * The ruling names "one test per layout … and `examples/app-multi-package`'s". + * Measured at `32b5831c4e`, that fixture holds exactly 7 files and no `docs/` + * directory anywhere, and `git log --diff-filter=AD` over + * `examples/app-multi-package/**\/docs/**` returns nothing — it never had one. + * ⭐ Lit control on the same probe: `examples/` holds 256 files, 11 of them + * under a `/docs/` path, so the probe can see docs directories there. + * + * So "one test per layout" needed a choice, and the layout — not the fixture + * file — is what this collector resolves against: + * + * - ⛔ NOT by adding a docs directory to `examples/app-multi-package`. It is + * the ADR-0130 D4 reference fixture and several measurements quote its + * contents exactly (`artifact-packages.ts` sizes the per-package + * de-duplication residue on it; `build-json-advisory-parity.e2e.test.ts` + * reads its artifact), so giving it docs changes what all of them read to + * buy what the cases below already prove. + * - ⛔ NOR by pinning that fixture's on-disk SHAPE from here. Such a pin + * fails the day someone flattens the fixture to `src//` — a change + * that, after this card, is harmless in both directions. Pinning a + * property this fix deliberately stops being load-bearing would be a pin + * that only ever produces false red. + * + * ⇒ the layout is reproduced here, where a marker string proves the pedigree + * of every collected doc, and the reading above is the record that it is the + * reference fixture's layout being reproduced. + */ +describe('the docs directory is derived from the packages the artifact registers', () => { + it('collects the ADR-0130 D4 two-level layout and attributes it to the right package', () => { + const marker = writePackageDoc(path.join('packages', 'orders'), 'sales_playbook', 'MARKER-two-level'); + const { docs, issues, packageDocs } = collectDocsFromSrc(configPath, stack().packages); + + expect(issues).toEqual([]); + expect(docs).toEqual([]); // ⛔ still not the top level — the #18431 ruling's clause 1 + expect(packageDocs).toHaveLength(1); + expect(packageDocs[0].index).toBe(1); + expect(packageDocs[0].id).toBe('com.example.multi.orders'); + expect(packageDocs[0].namespace).toBe('sales'); + expect(packageDocs[0].dir).toBe('src/packages/orders/docs'); + expect(packageDocs[0].docs.map((d) => d.name)).toEqual(['sales_playbook']); + expect(packageDocs[0].docs[0].content).toContain(marker); + }); + + it('⭐ lit control: the flat layout is collected exactly as before, beside a two-level one', () => { + const flat = writePackageDoc('core', 'crm_core_guide', 'MARKER-flat-control'); + const nested = writePackageDoc(path.join('packages', 'orders'), 'sales_playbook', 'MARKER-nested'); + const { packageDocs, issues } = collectDocsFromSrc(configPath, stack().packages); + + expect(issues).toEqual([]); + expect(packageDocs.map((set) => [set.id, set.dir, set.docs.map((d) => d.name)])).toEqual([ + ['com.example.multi.core', 'src/core/docs', ['crm_core_guide']], + ['com.example.multi.orders', 'src/packages/orders/docs', ['sales_playbook']], + ]); + // ⚠️ Pedigree, not count: without these the pair above is satisfied by a + // collector that read one directory twice. + expect(packageDocs[0].docs[0].content).toContain(flat); + expect(packageDocs[1].docs[0].content).toContain(nested); + }); + + it('lints each two-level package against its OWN namespace, end to end', () => { + writePackageDoc(path.join('packages', 'core'), 'crm_core_guide', 'MARKER-core'); + writePackageDoc(path.join('packages', 'orders'), 'sales_playbook', 'MARKER-orders'); + const { issues, packageDocs } = collectAndLintDocs(configPath, stack()); + + expect(issues).toEqual([]); + expect(packageDocs.map((set) => [set.index, set.dir])).toEqual([ + [0, 'src/packages/core/docs'], + [1, 'src/packages/orders/docs'], + ]); + }); + + it('⛔ stops at the package root — a `docs/` deeper inside a resolved package is not a second one', () => { + writePackageDoc(path.join('packages', 'orders'), 'sales_playbook', 'MARKER-root'); + const inside = path.join(tmp, 'src', 'packages', 'orders', 'components', 'docs'); + fs.mkdirSync(inside, { recursive: true }); + fs.writeFileSync(path.join(inside, 'sales_widget.md'), '# widget'); + + const { packageDocs, issues } = collectDocsFromSrc(configPath, stack().packages); + expect(packageDocs).toHaveLength(1); + expect(packageDocs[0].dir).toBe('src/packages/orders/docs'); + expect(packageDocs[0].docs.map((d) => d.name)).toEqual(['sales_playbook']); + // A package's subtree is that package's SOURCE, not more packages — the + // walk stops at a package root, so this is unchanged from before #18965 + // (the one-level sweep never reached it either). + expect(issues).toEqual([]); + }); + + it('a directory naming NO package keeps its own answer at the new depth', () => { + writePackageDoc(path.join('packages', 'billing'), 'crm_index', 'MARKER-unmatched-deep'); + const { packageDocs, issues } = collectDocsFromSrc(configPath, stack().packages); + + expect(packageDocs).toEqual([]); + expect(issues).toHaveLength(1); + expect(issues[0].rule).toBe('docs/uncollected-directory'); + expect(issues[0].path).toBe('src/packages/billing/docs'); + expect(issues[0].message).toContain('"billing" names none of this artifact\'s packages'); + }); + + it('an AMBIGUOUS directory name keeps its own answer at the new depth', () => { + writePackageDoc(path.join('packages', 'core'), 'crm_index', 'MARKER-ambiguous-deep'); + const twins = [pkg({ ...CORE }), pkg({ ...CORE, id: 'com.other.core', name: 'Other Core' })]; + + const { packageDocs, issues } = collectDocsFromSrc(configPath, twins); + expect(packageDocs).toEqual([]); + expect(issues).toHaveLength(1); + expect(issues[0].path).toBe('src/packages/core/docs'); + expect(issues[0].message).toContain('names 2 of this artifact\'s packages'); + }); + + it('⛔ ONE package answering to TWO docs directories is refused — never merged, never silently dropped', () => { + writePackageDoc('orders', 'sales_flat', 'MARKER-loc-flat'); + writePackageDoc(path.join('packages', 'orders'), 'sales_nested', 'MARKER-loc-nested'); + + const { packageDocs, issues } = collectDocsFromSrc(configPath, stack().packages); + // ⛔ Neither is collected: `attachPackageDocs` keys its sets by package + // index through a Map, so collecting both would drop one without a word. + expect(packageDocs).toEqual([]); + expect(issues.map((i) => [i.rule, i.path])).toEqual([ + ['docs/uncollected-directory', 'src/orders/docs'], + ['docs/uncollected-directory', 'src/packages/orders/docs'], + ]); + // Each side names the OTHER, so the author can see both halves from either. + expect(issues[0].message).toContain('answers to 2 docs directories'); + expect(issues[0].message).toContain('src/packages/orders/docs/'); + expect(issues[1].message).toContain('src/orders/docs/'); + }); + + it('⛔ single-package regression: a stack with no packages[] is walked ONE level and reports nothing deeper', () => { + writePackageDoc(path.join('packages', 'orders'), 'sales_playbook', 'MARKER-invisible'); + const { docs, packageDocs, issues } = collectDocsFromSrc(configPath); + + expect(docs).toEqual([]); + expect(packageDocs).toEqual([]); + // With no registered package there is nothing to search FOR, so there is + // no recursion at all: `src/packages/docs` does not exist, so the sweep + // finds nothing and says nothing — byte-for-byte the pre-#18965 answer. + expect(issues).toEqual([]); + }); +}); + // ── Clause 2: one prefix rule per package ────────────────────────────────── describe('the doc lint reads the OWNING package namespace', () => { it('accepts a package doc carrying its OWN package prefix, not the artifact manifest one', () => { diff --git a/packages/cli/src/utils/collect-docs.ts b/packages/cli/src/utils/collect-docs.ts index 18e9d6f462c..5bc3350dc2e 100644 --- a/packages/cli/src/utils/collect-docs.ts +++ b/packages/cli/src/utils/collect-docs.ts @@ -15,11 +15,17 @@ * build error — flatness is the contract that keeps cross-references * stable (a link is `[text](./.md)`; resolution is a basename * lookup with zero path arithmetic). - * - **Absence**: a `src//docs/` directory one level down is NEVER - * collected (ADR-0046 anchors at `src/docs`), and under an ADR-0130 - * multi-package layout that is where a moved docs directory lands — so it - * is REPORTED rather than passed over, because a build that keeps none of - * the author's docs and says nothing is the defect (#18170). + * - **Per-package collection** (ADR-0130 D4): an artifact that declares + * `packages[]` also gets each package's OWN `docs/` directory, read out of + * whatever directory that package answers to under `src/` and attached to + * that package's body — ⛔ never to the top level. The directory is found + * from the packages the artifact REGISTERS, at no fixed depth, so + * `src//docs/` and the ADR-0130 D4 reference fixture's + * `src/packages//docs/` are one case and not two (#18965). + * - **Absence**: Markdown under a `/docs/` that no registered package + * claims is still not collected — so it is REPORTED rather than passed + * over, because a build that keeps none of the author's docs and says + * nothing is the defect (#18170). * - **Lint**: namespace-prefix naming (`docs/namespace-prefix`, * `docs/namespace-required`), duplicate names (`docs/duplicate-name`), the * v1 syntax bans (no MDX, no images), and same-package link resolution. @@ -305,6 +311,19 @@ function markdownFilesIn(dir: string): string[] { * than one (where the message NAMES the candidates, because "I read this * convention and could not attribute the result" is a different fact from "I do * not read this convention"). + * + * ## What #18965 changed here, which is NOTHING — and that is a decision + * + * Letter B made the per-package directory resolve at no fixed depth, so the + * sentence below ("read from src/docs/ only") is false for every stack B newly + * reaches. ⛔ It was still not touched, because it is not reachable from one: + * the branch that emits it runs only when `refs.length === 0` — a stack that + * declares no `packages[]` — and for that stack B resolves no package + * directory at all, so `src/docs/` really is the only place its docs are read + * from. The paragraph above already argued exactly this and it survived the + * ruling intact. The message a stack WITH packages gets is the pair below it, + * which names the packages it was matched against and never claims a fixed + * path. */ function uncollectedDocsMessage(rel: string, files: readonly string[]): string { return `${rel}/ holds ${files.length} Markdown file(s) that were NOT collected: package docs are read from src/docs/ only (ADR-0046 §3.2), so these are absent from the artifact's \`docs[]\` and from every book that includes them. Move them into src/docs/ (doc names carry the package namespace prefix, so packages do not collide there), declare them inline as \`defineStack({ docs })\`, or delete them if they are not package docs. Found: ${files.join(', ')}`; @@ -381,45 +400,144 @@ export function docsPackageRefs(packages: unknown): DocsPackageRef[] { } /** - * Walk every `src//docs/` once and split the result two ways: collected - * into the package that owns it, or reported as unread (#18170's warning, kept - * by the #18431 ruling's clause 4). + * Where one package's docs directory IS — derived from the packages the + * artifact REGISTERS rather than from a fixed depth under `src/` (#18965, + * maintainer ruling batch #204 item 5, letter B). + * + * ## Why the search is by NAME, and why it carries no depth + * + * A registered package carries no source path. `ArtifactPackageSchema` is a + * `strictObject` whose single key is `manifest`, and that body is + * `AssembledPackageBodySchema` — `ManifestSchema` plus the collection keys. + * Neither declares the directory the package was authored in, so the only + * thing that can locate a package on disk is its NAME, and the two spellings + * {@link docsPackageRefs} derives are exactly that name. + * + * ⛔ The depth is therefore not a parameter of this walk and no number appears + * in it. Reading `src//docs/` and reading `src/packages//docs/` are + * the SAME act — find the directory this package answers to, then read the + * `docs/` inside it — which is why the ruling refused to pin a second depth + * (letter C): a layout the convention never enumerated is dropped in silence, + * the defect one level down. + * + * ## Two properties make it safe, and they are the whole design + * + * 1. ⛔ The recursion exists ONLY to find a registered package. A directory + * that names one IS a package root, so the walk stops there — its subtree + * is that package's own source, not more packages, which is what keeps a + * `src//components/docs/*.md` out of this. And with NO registered + * packages there is nothing to search for, so there is no recursion at + * all: a single-package stack is walked exactly one level, as it always + * was. That is the ruling's "unchanged by construction" — ⛔ it is the + * absence of a search, not a special case guarding one. + * 2. A directory whose name names no package is never RESOLVED at any depth, + * so this walk can only ever add directories a package claims. What it + * reports is unmoved: an unmatched directory is still a candidate exactly + * where it was a candidate before, because `node_modules` is skipped on + * the way DOWN and never as a candidate — a single-package stack's + * warning list is the same list, item for item, including that one. + */ +function packageDirectoryCandidates( + srcDir: string, + refs: readonly DocsPackageRef[], +): Array<{ name: string; dir: string; rel: string; owners: DocsPackageRef[] }> { + const found: Array<{ name: string; dir: string; rel: string; owners: DocsPackageRef[] }> = []; + const walk = (dir: string, rel: string): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + const children = entries.filter((e) => e.isDirectory() && e.name !== COLLECTED_DOCS_DIR); + children.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of children) { + const childDir = path.join(dir, entry.name); + const childRel = `${rel}/${entry.name}`; + const owners = refs.filter((ref) => ref.directoryNames.includes(entry.name)); + found.push({ name: entry.name, dir: childDir, rel: childRel, owners }); + // ⛔ Descend only in SEARCH of a package (property 1). An installed + // dependency's tree is not this artifact's source, so a package id + // matching a directory inside `node_modules` names someone else's copy. + if (owners.length === 0 && refs.length > 0 && entry.name !== 'node_modules') { + walk(childDir, childRel); + } + } + }; + walk(srcDir, 'src'); + return found; +} + +/** + * Walk the docs directories the artifact's packages resolve to, and split the + * result two ways: collected into the package that owns it, or reported as + * unread (#18170's warning, kept by the #18431 ruling's clause 4 and narrowed + * by no ruling since). * * ⭐ ONE traversal, and it is the traversal that was already here. The warning - * this function grew out of already read every `src//docs/` and already - * listed its Markdown files by name; collecting them costs the file reads and - * nothing else. That is the measurement the ruling asked for — see the PR that - * landed this — and it is why the directory convention is the one implemented - * first. + * this function grew out of already read every candidate `/docs/` and + * already listed its Markdown files by name; collecting them costs the file + * reads and nothing else. #18965 changed only where that traversal is allowed + * to go — see {@link packageDirectoryCandidates}. + * + * ## The one branch #18965 added, and why it is a REFUSAL + * + * Depth-free resolution makes a new ambiguity reachable: one package answering + * to TWO doc-bearing directories (`src/core/docs` and `src/packages/core/docs` + * in one tree). ⛔ It is not guessed and ⛔ it is not merged. + * {@link attachPackageDocs} keys its sets by package index through a `Map`, so + * a second set for one index would be dropped there without a word — the exact + * silence this card exists about, re-created one layer up. Both directories are + * reported and neither is collected, the same answer this collector already + * gives when one directory names two packages. */ function sweepPackageDocsDirectories( srcDir: string, refs: readonly DocsPackageRef[], ): { packageDocs: PackageDocSet[]; issues: DocIssue[] } { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(srcDir, { withFileTypes: true }); - } catch { - return { packageDocs: [], issues: [] }; - } const issues: DocIssue[] = []; const packageDocs: PackageDocSet[] = []; - const packageDirs = entries.filter((e) => e.isDirectory() && e.name !== COLLECTED_DOCS_DIR); - packageDirs.sort((a, b) => a.name.localeCompare(b.name)); - for (const entry of packageDirs) { - const dir = path.join(srcDir, entry.name, COLLECTED_DOCS_DIR); - const files = markdownFilesIn(dir); - if (files.length === 0) continue; - const rel = `src/${entry.name}/docs`; - - const owners = refs.filter((ref) => ref.directoryNames.includes(entry.name)); + + const candidates = packageDirectoryCandidates(srcDir, refs) + .map((candidate) => ({ + name: candidate.name, + owners: candidate.owners, + docsDir: path.join(candidate.dir, COLLECTED_DOCS_DIR), + rel: `${candidate.rel}/${COLLECTED_DOCS_DIR}`, + files: markdownFilesIn(path.join(candidate.dir, COLLECTED_DOCS_DIR)), + })) + .filter((candidate) => candidate.files.length > 0); + + // How many doc-bearing directories each package answers to — the operand of + // the multi-location refusal above. Counted over the whole tree before any + // of it is collected, so the refusal does not depend on walk order. + const locationsOf = new Map(); + for (const candidate of candidates) { + if (candidate.owners.length !== 1) continue; + const { index } = candidate.owners[0]; + locationsOf.set(index, [...(locationsOf.get(index) ?? []), candidate.rel]); + } + + for (const { name, owners, docsDir, rel, files } of candidates) { if (owners.length === 1) { - const compiled = compileDocsDirectory(dir, rel); + const owner = owners[0]; + const locations = locationsOf.get(owner.index) ?? []; + if (locations.length > 1) { + const elsewhere = locations.filter((location) => location !== rel).map((location) => `${location}/`).join(', '); + issues.push({ + severity: 'warning', + rule: 'docs/uncollected-directory', + message: `${rel}/ holds ${files.length} Markdown file(s) that were NOT collected: package "${owner.id}" answers to ${locations.length} docs directories in this tree (also ${elsewhere}), so which one carries its docs is ambiguous and ⛔ this collector will not guess (ADR-0130 D4). Keep one directory per package — merge the others into it, or declare the docs inline as \`defineStack({ docs })\` on the package that owns them. Found: ${files.join(', ')}`, + path: rel, + }); + continue; + } + const compiled = compileDocsDirectory(docsDir, rel); issues.push(...compiled.issues); packageDocs.push({ - index: owners[0].index, - id: owners[0].id, - ...(owners[0].namespace !== undefined ? { namespace: owners[0].namespace } : {}), + index: owner.index, + id: owner.id, + ...(owner.namespace !== undefined ? { namespace: owner.namespace } : {}), dir: rel, docs: compiled.docs, }); @@ -443,8 +561,8 @@ function sweepPackageDocsDirectories( severity: 'warning', rule: 'docs/uncollected-directory', message: owners.length === 0 - ? `${rel}/ holds ${files.length} Markdown file(s) that were NOT collected: "${entry.name}" names none of this artifact's packages, so there is no package body to attach them to (ADR-0130 D4). A per-package docs directory is matched against a package's \`id\` or the last dot-separated segment of that \`id\` — rename the directory to one of those two, declare the docs inline as \`defineStack({ docs })\` on the package that owns them, or move them into src/docs/. Declared packages: ${declared}. Found: ${files.join(', ')}` - : `${rel}/ holds ${files.length} Markdown file(s) that were NOT collected: "${entry.name}" names ${owners.length} of this artifact's packages (${owners.map((o) => o.id).join(', ')}), so which package body owns these docs is ambiguous and ⛔ this collector will not guess (ADR-0130 D4). Give those packages distinct \`id\` spellings — their last dot-separated segments must differ too — or declare the docs inline as \`defineStack({ docs })\` on the one that owns them. Found: ${files.join(', ')}`, + ? `${rel}/ holds ${files.length} Markdown file(s) that were NOT collected: "${name}" names none of this artifact's packages, so there is no package body to attach them to (ADR-0130 D4). A per-package docs directory is matched against a package's \`id\` or the last dot-separated segment of that \`id\` — rename the directory to one of those two, declare the docs inline as \`defineStack({ docs })\` on the package that owns them, or move them into src/docs/. Declared packages: ${declared}. Found: ${files.join(', ')}` + : `${rel}/ holds ${files.length} Markdown file(s) that were NOT collected: "${name}" names ${owners.length} of this artifact's packages (${owners.map((o) => o.id).join(', ')}), so which package body owns these docs is ambiguous and ⛔ this collector will not guess (ADR-0130 D4). Give those packages distinct \`id\` spellings — their last dot-separated segments must differ too — or declare the docs inline as \`defineStack({ docs })\` on the one that owns them. Found: ${files.join(', ')}`, path: rel, }); } @@ -574,8 +692,9 @@ function compileDocsDirectory(docsDir: string, relBase: string): { docs: DocItem /** * Read `src/docs/*.md` (flat) next to the given config file, and — when the - * caller hands over the artifact's `packages[]` — every `src//docs/` whose - * directory name resolves to one of those packages (#18431, ADR-0130 D4). + * caller hands over the artifact's `packages[]` — the `docs/` directory of + * every directory under `src/` that resolves to one of those packages (#18431, + * ADR-0130 D4; #18965 made that resolution depth-free). * * The two results stay SEPARATE and that separation is the ruling: the flat * directory's docs are the stack's own and keep attaching where they always From 4afa1e46697c2daf085ca54cc27cd024e5b253e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 03:46:24 +0000 Subject: [PATCH 2/2] chore(changeset): @objectstack/cli minor for the per-package docs directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-②: yes — `os build` accepts a source layout it previously read nothing from, so what an author may write and have collected widens. Claude-Session: https://claude.ai/code/session_01QCdUBjM47SxioST9z5Zwdf Co-authored-by: Claude --- ...ocs-dir-resolves-per-registered-package.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/18965-docs-dir-resolves-per-registered-package.md diff --git a/.changeset/18965-docs-dir-resolves-per-registered-package.md b/.changeset/18965-docs-dir-resolves-per-registered-package.md new file mode 100644 index 00000000000..ad2d16c3e05 --- /dev/null +++ b/.changeset/18965-docs-dir-resolves-per-registered-package.md @@ -0,0 +1,24 @@ +--- +"@objectstack/cli": minor +--- + +**Clause-②: yes** — `os build` accepts a source layout it previously read nothing from, so what an author may write and have collected widens. ⛔ Nothing narrows: every tree that built green still builds green, with the same `docs[]` and the same warnings. + +`os build` now derives **each package's docs directory from the packages the artifact registers**, not from a fixed depth under `src/` (maintainer ruling, decision batch #204 item 5, letter B). + +Before this, the sweep asked one question per direct child of `src/`: does `src/CHILD/docs/` hold Markdown? So a project whose packages sit one level deeper — the shape this repo's own ADR-0130 D4 reference fixture `examples/app-multi-package` has, `src/packages/PKG/` — was invisible to it. A doc at `src/packages/orders/docs/ord_guide.md` was dropped **silently**: no `docs[]` entry, exit 0, and not even the `docs/uncollected-directory` warning, because the sweep never looked there. That is the #18170 defect verbatim, one level down, and after #18431 it was out of reach of both the diagnostic and the collection. + +Both layouts are now one case rather than two: + +``` +src/orders/docs/sales_guide.md -> packages[].manifest.docs (unchanged) +src/packages/orders/docs/sales_guide.md -> packages[].manifest.docs (new) +``` + +**How the directory is found.** A registered package carries no source path — `ArtifactPackageSchema` is a `strictObject` whose only key is the assembled body — so the only thing that can locate one on disk is its NAME, and the two spellings a docs directory is matched against are unchanged: the package's `id`, and the last dot-separated segment of that `id`. ⛔ Never `name` (a display string, free to be re-worded) and ⛔ never `namespace` (ADR-0130 D1 exists so N packages may share one). + +**No second depth was pinned.** The walk descends only in SEARCH of a registered package and stops at the first directory that names one — so a package's own subtree stays its source, and a `docs/` inside it is not a second docs directory. With no `packages[]` there is nothing to search for, so there is no descent at all: a single-package stack is walked exactly one level, its `docs[]` and its warning text byte-for-byte what they were. That is the fence the ruling preserved from batch #147 item 4, held by construction rather than by a branch guarding it. + +**One new refusal.** Depth-free resolution makes one package able to answer to two doc-bearing directories (`src/core/docs` and `src/packages/core/docs` in one tree). Both are reported and neither is collected — the same answer this collector already gives when one directory names two packages. ⛔ It is not merged and ⛔ not silently halved: docs attach by package index, so collecting both would drop one without a word. + +A directory that matches **no** package and one that matches **more than one** keep their existing, distinct diagnostics, now at whatever depth they are found.