From 201eb8d9d54243b79374a264e35de548fda9c9aa Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:38:59 +0100 Subject: [PATCH 1/4] docs: write spc-11 and mark itd-96 additive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minted spc-11 stub carried no design record, so `abcd intent ready itd-96` failed its spec_body check. Write the spec body, resolving every one of itd-96's open questions: a dedicated naming document is naming's primary source with the glossary as an explicitly-qualified fallback, the preference order for architecture docs, the package-layout scan on top of spc-10's WalkFiles, and the thresholds separating the confidence levels. Both new sections ceiling at partial, matching what the mapping row already predicts for the conventions tier, so the strength of the signal is carried by confidence rather than by inflating the status. That keeps mapping.go — the brief-to-lifeboat contract — unedited. impact: additive records the change class on the intent rather than in CHANGELOG.md. Assisted-by: Claude:claude-opus-4-8 --- ...o-s-naming-and-internals-conventions-in.md | 1 + ...o-s-naming-and-internals-conventions-in.md | 123 +++++++++++++++++- 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/.abcd/development/intents/planned/itd-96-disembark-reads-a-repo-s-naming-and-internals-conventions-in.md b/.abcd/development/intents/planned/itd-96-disembark-reads-a-repo-s-naming-and-internals-conventions-in.md index 6f68068..b12c611 100644 --- a/.abcd/development/intents/planned/itd-96-disembark-reads-a-repo-s-naming-and-internals-conventions-in.md +++ b/.abcd/development/intents/planned/itd-96-disembark-reads-a-repo-s-naming-and-internals-conventions-in.md @@ -7,6 +7,7 @@ suggested_kind: null reclassification_history: [] builds_on: [] severity: minor +impact: additive --- # Disembark Reads A Repo's Naming And Internals Conventions Into Its Lifeboat diff --git a/.abcd/development/specs/open/spc-11-disembark-reads-a-repo-s-naming-and-internals-conventions-in.md b/.abcd/development/specs/open/spc-11-disembark-reads-a-repo-s-naming-and-internals-conventions-in.md index 6bfbdff..391c83a 100644 --- a/.abcd/development/specs/open/spc-11-disembark-reads-a-repo-s-naming-and-internals-conventions-in.md +++ b/.abcd/development/specs/open/spc-11-disembark-reads-a-repo-s-naming-and-internals-conventions-in.md @@ -7,4 +7,125 @@ intent: itd-96 ## Summary -_Draft: describe what spc-11 delivers for itd-96 — scope, approach, and how it satisfies the intent's Acceptance Criteria. This spec is the design record the fidelity review audits against._ +spc-11 delivers itd-96: two conventions-tier `Source` adapters that ground +`constraints/naming` and `internals` on the files a team naturally writes — a +`NAMING.md` or naming page under `docs/` for naming, an `ARCHITECTURE.md` or +architecture/explanation tree plus the package layout for internals. A +repository with conventional docs but no `.abcd/` record now carries both +sections in its lifeboat instead of two blanks. `glossary` is untouched: +`convGlossarySource` already owns it, and this spec adds no second adapter for +it. + +## Scope + +- **Adapters** (`internal/core/lifeboat/sources_conventions.go`): + `convNamingSource` (`constraints/naming`) and `convInternalsSource` + (`internals`), both `TierConventions`, both siblings of `convGlossarySource`. +- **Registration** (`conventionSources()` in `probe.go`): one line each. With + spc-10's `convOpenQuestionsSource` this brings the conventions tier to + fourteen adapters. +- **Tier gate** (`hasConventions` in `probe.go`): the new adapters' own name + lists are appended to the candidate union, per the comment that already + forbids narrowing the gate below what the adapters read — otherwise a repo + carrying *only* a `NAMING.md` or `ARCHITECTURE.md` would have the whole Tier-1 + set skipped and blank falsely. +- **Depends on spc-10** for `(*SourceContext).WalkFiles`, which + `convInternalsSource` uses for its layout scan. spc-11 stacks on spc-10; it + does not re-land the primitive. +- **No mapping change, no new dependency.** + +## Approach + +### `convNamingSource` — `constraints/naming` + +Preference order, first match wins: + +1. A dedicated naming document — `ctx.FindFirst("NAMING.md", "NAMING", + "naming.md")`, then the first `docs/` entry whose lower-cased name starts + with `naming` (the same `ListDir`-prefix idiom `convGlossarySource` uses for + `docs/glossary*`). Cited alone, `ConfidenceMedium`. +2. **Fallback: the glossary document.** A project that never wrote a naming + registry usually encodes its reserved vocabulary in its glossary, so the + glossary is real evidence for naming — but weaker, because a glossary + defines terms rather than ruling on what may be renamed. Cited as + `" (glossary fallback — no dedicated naming document)"`, + `ConfidenceLow`. +3. Neither → a blank naming both search sets in `Searched` and asking the human + question. + +**Status is `StatusPartial` in both non-blank cases** — the ceiling the mapping +row predicts for this section, and the honest one: neither a naming page nor a +glossary enumerates a project's full reserved vocabulary. The strength of the +signal is carried by `Confidence` (medium for a dedicated document, low for the +glossary fallback), not by inflating the status. + +**Distinctness from `glossary` is structural, not incidental.** The two +adapters declare different `Section()` values, so the orchestrator indexes them +into different coverage rows and neither can displace the other. On a +glossary-only repository the report shows `glossary` partial/medium cited to +`GLOSSARY.md`, and `constraints/naming` partial/low cited to the same file +*with the fallback qualifier* — visibly a weaker, derived reading rather than a +duplicate row. A test asserts the two rows differ in confidence and in cited +string on that fixture. + +### `convInternalsSource` — `internals` + +Two independent signals, combined: + +- **Architecture prose.** `ctx.FindFirst("ARCHITECTURE.md", "ARCHITECTURE", + "architecture.md", "docs/architecture.md")`, then the directories + `docs/architecture`, `docs/design`, `docs/explanation` (Diátaxis), in that + preference order. A file is read and measured with the existing + `convProseBytes` / `convGroundedProseBytes` threshold — the same measure + `convContextSource` uses for a README, reused rather than re-invented. A + directory counts as prose evidence when it holds at least one Markdown file. +- **Package layout.** `WalkFiles` from `.`, keeping the top-two path segments + of every file under a recognised source root (`internal`, `pkg`, `src`, + `lib`, `cmd`, `app`) — the packages a rescuer must navigate. Cited as + `"//"` entries, capped at `maxLayoutCitations` (50) with the + overflow reported as a count. `WalkFiles`'s `truncated` flag, when set, is + reported in the citation so a capped scan never reads as a complete one. + +Outcomes: + +| Signals present | Status | Confidence | +|---|---|---| +| Architecture prose above the threshold (± layout) | `StatusPartial` | `ConfidenceHigh` | +| Architecture doc present but thin, or a doc directory with no prose measured | `StatusPartial` | `ConfidenceMedium` | +| Layout only, no architecture doc | `StatusPartial` | `ConfidenceLow` | +| Neither | `StatusBlank` with `Searched` + `Question` | — | + +As with naming, `StatusPartial` is the ceiling: an `ARCHITECTURE.md` plus a +package listing describes the shape of a system, not its internals chapters. +This matches the mapping row's conventions prediction, which stays unedited. + +### Resolved open questions (itd-96 § Open Questions) + +| Question | Decision | +|---|---| +| Naming vs glossary — what is naming's distinct source? | A **dedicated** naming document (`NAMING.md`, `docs/naming*`) is naming's primary source; the glossary is an explicitly-qualified **fallback** at lower confidence. The two sections are distinct rows with distinct evidence strings; no second `glossary` adapter is added. | +| Which paths map to `internals`? | `ARCHITECTURE.md` (and spellings) → `docs/architecture.md` → `docs/architecture/` → `docs/design/` → `docs/explanation/`, in that preference order, plus the package layout as an independent second signal. | +| What counts as "package layout", and the missing primitive? | Top-two path segments under `internal`, `pkg`, `src`, `lib`, `cmd`, `app`, gathered with spc-10's `WalkFiles`. A bounded recursive walk is needed (a top-level `ListDir` cannot see `internal/core/lifeboat`), and it is the shared primitive both intents asked for — landed once, in spc-10. | +| Extraction heuristics and status thresholds | The existing `convProseBytes` ≥ `convGroundedProseBytes` threshold separates real architecture prose from a stub. Status ceilings at `StatusPartial` for both sections (the mapping contract's conventions prediction); the distinction lives in `Confidence`. | +| Confidence levels | Naming: medium (dedicated doc) / low (glossary fallback). Internals: high (real prose) / medium (thin doc) / low (layout only). | +| Reserved-vocabulary spellings | `NAMING.md`, `NAMING`, `naming.md`, and any `docs/` entry whose name starts with `naming` (case-insensitive). Heading-shape sniffing inside a glossary is *not* attempted — it would guess at a convention that has no standard. | + +## Acceptance-criteria satisfaction + +- **Naming docs → non-blank, cites the file** — a fixture with `NAMING.md` + asserts `constraints/naming` partial, `TierConventions`, citing `NAMING.md`. +- **ARCHITECTURE.md + layout → non-blank, cites both** — a fixture with + `ARCHITECTURE.md` and an `internal//` tree asserts `internals` non-blank + citing the doc and the layout entries. +- **Neither → honest blanks** — a bare fixture asserts both sections blank with + populated `Searched` and a non-empty `Question`. +- **Read-only** — the byte-for-byte tree-invariance test from spc-10 covers the + whole probe, both new adapters included. +- **No duplicate `glossary` adapter** — a glossary-only fixture asserts + `glossary` is still grounded by `convGlossarySource`, that + `constraints/naming` is partial with the fallback qualifier and lower + confidence, and that `conventionSources()` contains exactly one adapter whose + `Section()` is `glossary`. +- **Both tiers present → one deterministic result** — the unchanged + `beats`/`tierRank` reduction; the native adapters keep winning where a record + exists, and the report names the winning tier. From e58e3f240443add2329bd3ce6e4a690b76650ba4 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:26:28 +0100 Subject: [PATCH 2/4] feat: ground naming and internals from a repo's conventional docs constraints/naming and internals were each grounded by exactly one adapter, both native, so a repository with a NAMING.md and an ARCHITECTURE.md sitting in plain sight still reported both sections blank for want of an .abcd/ record. convNamingSource reads a dedicated naming document first and falls back to the glossary, cited with an explicit qualifier at lower confidence: a glossary defines terms rather than ruling on what may be renamed, so it is real but weaker evidence. The glossary section keeps its own adapter and its own row; naming is visibly a derived reading, never a duplicate. convInternalsSource combines architecture prose, measured with the README prose threshold, with a package-layout scan over WalkFiles. Both sections ceiling at partial, matching what the mapping row already predicts for this tier, so the strength of the signal lives in the confidence rather than in an inflated status. Also harden the walk itself, which the security review showed the tier gate now makes reachable from a source tree carrying no other conventional file. Its cap counted regular files only, so a tree of directories holding nothing regular never reached it; and every directory is resolved from the containment root one component at a time, so a chain of directories costs the square of its depth. A depth-1500 tree took over two minutes and now takes 0.4s. Directories are counted against the same cap and descent is capped at maxWalkDepth, both reported through the existing truncated flag. A layout scan cut short by a cap no longer reports as a complete one: the truncation note is no longer conditional on having found a package, and a blank drawn from a partial walk says the walk was partial. Captured, not fixed: iss-113 (the marker adapter's truncation wording names only the file cap), iss-114 (os.Root path re-resolution is the quadratic underneath the depth cap). Assisted-by: Claude:claude-opus-4-8 --- ...ce-s-truncation-evidence-names-only-the.md | 12 + ...y-directory-through-os-root-fs-which-re.md | 12 + internal/core/lifeboat/probe.go | 67 ++- internal/core/lifeboat/probe_test.go | 82 +++- internal/core/lifeboat/sources_conventions.go | 243 +++++++++- .../core/lifeboat/sources_conventions_test.go | 448 ++++++++++++++++++ 6 files changed, 836 insertions(+), 28 deletions(-) create mode 100644 .abcd/work/issues/open/iss-113-convopenquestionssource-s-truncation-evidence-names-only-the.md create mode 100644 .abcd/work/issues/open/iss-114-walkfiles-opens-every-directory-through-os-root-fs-which-re.md diff --git a/.abcd/work/issues/open/iss-113-convopenquestionssource-s-truncation-evidence-names-only-the.md b/.abcd/work/issues/open/iss-113-convopenquestionssource-s-truncation-evidence-names-only-the.md new file mode 100644 index 0000000..6d064f9 --- /dev/null +++ b/.abcd/work/issues/open/iss-113-convopenquestionssource-s-truncation-evidence-names-only-the.md @@ -0,0 +1,12 @@ +--- +schema_version: 1 +id: "iss-113" +slug: "convopenquestionssource-s-truncation-evidence-names-only-the" +severity: "nitpick" +category: "tech-debt" +source: "impl-review" +found_during: "itd-96 P1 review" +found_at: "internal/core/lifeboat/sources_conventions.go" +--- + +convOpenQuestionsSource's truncation evidence names only the "%d-file walk cap", but WalkFiles now also truncates on its directory cap and its depth cap, so a walk stopped by either reports a cause it did not hit. The three affected strings are the searched note, the blank's qualified question, and the non-blank scan note. convInternalsSource words the same note as "walk cap (N entries, M levels deep)"; the two should say the same thing. Found by an independent security review of the itd-96 diff. \ No newline at end of file diff --git a/.abcd/work/issues/open/iss-114-walkfiles-opens-every-directory-through-os-root-fs-which-re.md b/.abcd/work/issues/open/iss-114-walkfiles-opens-every-directory-through-os-root-fs-which-re.md new file mode 100644 index 0000000..f8e356f --- /dev/null +++ b/.abcd/work/issues/open/iss-114-walkfiles-opens-every-directory-through-os-root-fs-which-re.md @@ -0,0 +1,12 @@ +--- +schema_version: 1 +id: "iss-114" +slug: "walkfiles-opens-every-directory-through-os-root-fs-which-re" +severity: "minor" +category: "tech-debt" +source: "impl-review" +found_during: "itd-96 P1 review" +found_at: "internal/core/lifeboat/probe.go" +--- + +WalkFiles opens every directory through os.Root.FS(), which re-resolves the whole path from the containment root one component at a time, so a walk costs O(entries x depth) component opens. Measured post-fix: a 60000-directory tree at depth 30 takes 10.5s for one probe (two adapters walk it concurrently). The depth and directory caps bound this, but the constant is high. os.Root.OpenRoot would let the walk hold a sub-root per directory and open each child in O(1). Found while fixing the unbounded-walk finding in the itd-96 review. \ No newline at end of file diff --git a/internal/core/lifeboat/probe.go b/internal/core/lifeboat/probe.go index c72918b..70aa4ec 100644 --- a/internal/core/lifeboat/probe.go +++ b/internal/core/lifeboat/probe.go @@ -35,12 +35,23 @@ const maxGitOutputBytes = 16 << 20 // 16 MiB // it. const maxDirEntries = 50000 -// maxWalkFiles caps how many regular files WalkFiles returns from one walk, -// mirroring maxDirEntries: a whole-tree walk of a vast monorepo must terminate -// and stay bounded in memory. Reaching it is reported, never silent, so an +// maxWalkFiles caps how many regular files WalkFiles returns from one walk, and +// equally how many directories it descends into, mirroring maxDirEntries: a +// whole-tree walk of a vast monorepo must terminate and stay bounded in memory. +// Both halves are load-bearing — a tree of directories holding no regular file +// never reaches a file cap. Reaching either is reported, never silent, so an // adapter can say in its evidence that it saw only part of the tree. const maxWalkFiles = maxDirEntries +// maxWalkDepth caps how many levels below its start WalkFiles descends. Every +// directory is opened by resolving its path from the containment root one +// component at a time, so a chain of directories costs the square of its depth +// to walk: a few thousand nested directories are trivial to create and take +// minutes to traverse. Real trees are shallow — the deepest path in this +// repository is six levels — so the cap prunes only the pathological ones, and +// says so when it does. +const maxWalkDepth = 32 + // walkSkipDirs are the directory names WalkFiles never descends into, matched by // name at any depth: VCS internals, dependency trees, and generated code. None // of them holds a team's own material, and together they are the dominant cost @@ -283,10 +294,11 @@ func (c *SourceContext) ListDir(rel string) []string { } // WalkFiles returns the repo-relative POSIX paths of every regular file beneath -// a repo-relative directory, sorted, and reports whether the walk stopped at its -// file cap. It is the recursive counterpart of ListDir, for adapters whose -// evidence is the shape of the tree rather than a known filename. Content is -// still read through ReadFile, so the walk adds no second read path. +// a repo-relative directory, sorted, and reports whether the walk stopped at any +// of its bounds — the file cap, the directory cap, or the depth cap. It is the +// recursive counterpart of ListDir, for adapters whose evidence is the shape of +// the tree rather than a known filename. Content is still read through ReadFile, +// so the walk adds no second read path. // // It is contained by the same os.Root as every other read, skips the // walkSkipDirs trees, and skips non-regular files (FIFOs, devices, sockets) so @@ -300,10 +312,10 @@ func (c *SourceContext) WalkFiles(rel string) (paths []string, truncated bool) { return c.walkFilesLimited(rel, maxWalkFiles) } -// walkFilesLimited is WalkFiles with the file cap injected, so the truncation -// branch is exercisable by a test at an affordable scale. The shipped cap stays -// a const: adapters run concurrently, and a mutable package-level cap would be -// shared state between them. +// walkFilesLimited is WalkFiles with the file and directory cap injected, so the +// truncation branches are exercisable by a test at an affordable scale. The +// shipped cap stays a const: adapters run concurrently, and a mutable +// package-level cap would be shared state between them. func (c *SourceContext) walkFilesLimited(rel string, limit int) (paths []string, truncated bool) { if c.root == nil { return nil, false @@ -312,6 +324,8 @@ func (c *SourceContext) walkFilesLimited(rel string, limit int) (paths []string, if !fs.ValidPath(start) { return nil, false } + startDepth := pathDepth(start) + dirs := 0 err := fs.WalkDir(c.root.FS(), start, func(p string, d fs.DirEntry, err error) error { if err != nil { // An unreadable entry in a foreign tree is skipped, not fatal: the @@ -330,6 +344,20 @@ func (c *SourceContext) walkFilesLimited(rel string, limit int) (paths []string, return fs.SkipDir } } + // Directories are capped alongside files: a tree of directories + // holding nothing regular yields no path, so a file cap alone never + // fires and the walk runs to exhaustion over a foreign tree. + if dirs >= limit { + truncated = true + return fs.SkipAll + } + dirs++ + if pathDepth(p)-startDepth >= maxWalkDepth { + // Prune the chain, not the tree: everything above the cap is + // still walked, and the truncation is reported either way. + truncated = true + return fs.SkipDir + } return nil } if !d.Type().IsRegular() { @@ -349,6 +377,15 @@ func (c *SourceContext) walkFilesLimited(rel string, limit int) (paths []string, return paths, truncated } +// pathDepth counts the segments in a cleaned slash path, with "." the zero +// depth. It is how the walk measures how far below its start it has descended. +func pathDepth(p string) int { + if p == "." { + return 0 + } + return strings.Count(p, "/") + 1 +} + // firstRootSHA returns the canonical (first) root-commit SHA, or "". func firstRootSHA(repoRoot string) string { out, err := gitutil.Run(repoRoot, "rev-list", "--max-parents=0", "HEAD") @@ -533,7 +570,13 @@ func hasConventions(c *SourceContext) bool { candidates = append(candidates, convGlossaryDocNames...) // convGlossarySource candidates = append(candidates, convPlatformFiles...) // convPlatformSource (Dockerfile, Makefile, go.mod, package.json) candidates = append(candidates, convADRDirs...) // convADRsSource - for _, ml := range convManifestLocks { // convDependenciesSource + candidates = append(candidates, convNamingDocNames...) // convNamingSource + // convInternalsSource: an architecture document, an architecture tree, or the + // package layout on its own is grounding evidence for internals. + candidates = append(candidates, convArchitectureDocNames...) + candidates = append(candidates, convArchitectureDirs...) + candidates = append(candidates, convLayoutRoots...) + for _, ml := range convManifestLocks { // convDependenciesSource candidates = append(candidates, ml.manifest) } return c.FindFirst(candidates...) != "" diff --git a/internal/core/lifeboat/probe_test.go b/internal/core/lifeboat/probe_test.go index 54e4a14..07a391b 100644 --- a/internal/core/lifeboat/probe_test.go +++ b/internal/core/lifeboat/probe_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "path" "path/filepath" "strings" "testing" @@ -180,19 +181,88 @@ func TestWalkFilesStopsAtTheFileCap(t *testing.T) { } } +// TestWalkFilesStopsAtTheDirectoryCap holds the bound a file cap alone cannot: +// a tree of directories holding no regular file at all never reaches the file +// cap, so the walk would run to exhaustion over a foreign tree that costs two +// syscalls per directory and yields nothing. Directories are counted against the +// same cap, and reaching it is reported like any other truncation. +func TestWalkFilesStopsAtTheDirectoryCap(t *testing.T) { + dir := t.TempDir() + for i := 0; i < 10; i++ { + if err := os.MkdirAll(filepath.Join(dir, fmt.Sprintf("d%02d", i)), 0o755); err != nil { + t.Fatal(err) + } + } + ctx, err := newSourceContext(dir) + if err != nil { + t.Fatal(err) + } + defer ctx.Close() + + paths, truncated := ctx.walkFilesLimited(".", 3) + if !truncated { + t.Errorf("walk of 10 empty directories under a 3-entry cap reported no truncation (paths %v)", paths) + } +} + +// TestWalkFilesStopsAtTheDepthCap holds the other unbounded dimension: every +// directory the walk opens is resolved from the containment root one component +// at a time, so the cost of a chain of directories grows with the square of its +// depth — a few thousand nested directories, cheap to create, cost minutes to +// walk. The walk descends maxWalkDepth levels and says it stopped there. +func TestWalkFilesStopsAtTheDepthCap(t *testing.T) { + dir := t.TempDir() + deep := make([]string, 0, maxWalkDepth+2) + for i := 0; i < maxWalkDepth+2; i++ { + deep = append(deep, "d") + } + buried := path.Join(append(deep, "buried.txt")...) + writeTree(t, dir, map[string]string{ + "shallow.txt": "x\n", + buried: "x\n", + }) + ctx, err := newSourceContext(dir) + if err != nil { + t.Fatal(err) + } + defer ctx.Close() + + paths, truncated := ctx.WalkFiles(".") + if !truncated { + t.Errorf("walk of a %d-deep chain under a depth cap of %d reported no truncation", len(deep), maxWalkDepth) + } + for _, p := range paths { + if p == buried { + t.Errorf("walk returned %q, %d levels below its depth cap of %d", p, len(deep)-maxWalkDepth, maxWalkDepth) + } + } + // Pruning a chain is not abandoning the tree: what sits above the cap is + // still walked and still returned. + if len(paths) == 0 || paths[len(paths)-1] != "shallow.txt" { + t.Errorf("walk = %v, want the shallow file still returned", paths) + } +} + // TestProbeLeavesEveryFileByteIdentical is the read-only invariance property at // full strength: a probe of a marker-bearing tree must leave every file's // contents unchanged and add or remove nothing. The marker adapter reads every // file in the tree, so byte-level proof — not a path/size fingerprint — is what -// makes "point it at an archived project, touch nothing" true. +// makes "point it at an archived project, touch nothing" true. The fixture +// carries every signal the whole-tree adapters consult — work markers, a naming +// document, an architecture document, and a package tree — so one fixture proves +// the property for all of them. func TestProbeLeavesEveryFileByteIdentical(t *testing.T) { dir := t.TempDir() writeTree(t, dir, map[string]string{ - "README.md": "# demo\n\nA demo project kept for the probe.\n", - "go.mod": "module example.com/demo\n\ngo 1.22\n", - "src/a.go": "package a\n\n// TODO: handle the retry case\n", - "src/b.go": "package a\n\n// FIXME: this leaks a connection\n", - "docs/notes.md": "Notes about the demo.\n", + "README.md": "# demo\n\nA demo project kept for the probe.\n", + "go.mod": "module example.com/demo\n\ngo 1.22\n", + "src/a.go": "package a\n\n// TODO: handle the retry case\n", + "src/b.go": "package a\n\n// FIXME: this leaks a connection\n", + "docs/notes.md": "Notes about the demo.\n", + "NAMING.md": "# Naming\n\nA voyage is never called a run.\n", + "ARCHITECTURE.md": "# Architecture\n\nA core, and a shell that formats it.\n", + "internal/core/core.go": "package core\n", + "internal/store/store.go": "package store\n", }) before := fileHashes(t, dir) if _, err := Probe(dir); err != nil { diff --git a/internal/core/lifeboat/sources_conventions.go b/internal/core/lifeboat/sources_conventions.go index b429334..649a296 100644 --- a/internal/core/lifeboat/sources_conventions.go +++ b/internal/core/lifeboat/sources_conventions.go @@ -25,6 +25,8 @@ func conventionSources() []Source { convSurfacesSource{}, convOutOfScopeSource{}, convGlossarySource{}, + convNamingSource{}, + convInternalsSource{}, convOpenQuestionsSource{}, convIssuesSource{}, convWhatWorkedSource{}, @@ -441,6 +443,30 @@ func (convOutOfScopeSource) Probe(ctx *SourceContext) Evidence { // convGlossaryDocNames are the glossary spellings checked outside docs/. var convGlossaryDocNames = []string{"GLOSSARY.md", "GLOSSARY", "glossary.md"} +// convDocsEntryWithPrefix returns the repo-relative path of the first entry +// directly under docs/ whose lower-cased name starts with prefix, or "". It is +// the docs/ convention several adapters share: a project that keeps its glossary +// or its naming rules under docs/ names the page after the thing it documents. +func convDocsEntryWithPrefix(ctx *SourceContext, prefix string) string { + for _, name := range ctx.ListDir("docs") { + if strings.HasPrefix(strings.ToLower(name), prefix) { + return "docs/" + name + } + } + return "" +} + +// convGlossaryDoc returns the glossary document a repository carries — a +// conventional spelling at the root, else a docs/glossary* page — or "". It is +// the single definition of "where the glossary lives", read both by the glossary +// adapter that owns the section and by the naming adapter that falls back to it. +func convGlossaryDoc(ctx *SourceContext) string { + if p := ctx.FindFirst(convGlossaryDocNames...); p != "" { + return p + } + return convDocsEntryWithPrefix(ctx, "glossary") +} + // convGlossarySource partially grounds "glossary" from a glossary document under // docs/ or at the repo root. Blank when none exists. type convGlossarySource struct{} @@ -449,28 +475,225 @@ func (convGlossarySource) Section() Section { return "glossary" } func (convGlossarySource) Tier() Tier { return TierConventions } func (convGlossarySource) Probe(ctx *SourceContext) Evidence { - if p := ctx.FindFirst(convGlossaryDocNames...); p != "" { + if p := convGlossaryDoc(ctx); p != "" { return Evidence{ Status: StatusPartial, Confidence: ConfidenceMedium, Sources: []string{p}, } } - for _, name := range ctx.ListDir("docs") { - if strings.HasPrefix(strings.ToLower(name), "glossary") { - return Evidence{ - Status: StatusPartial, - Confidence: ConfidenceMedium, - Sources: []string{"docs/" + name}, - } - } - } return blank( []string{"GLOSSARY.md", "docs/glossary*"}, "What terms does this project define? No glossary document found.", ) } +// convNamingDocNames are the dedicated naming-document spellings checked outside +// docs/. The list is short because there is no strong standard: a project that +// rules on names usually writes one file and calls it NAMING. +var convNamingDocNames = []string{"NAMING.md", "NAMING", "naming.md"} + +// convNamingSource partially grounds "constraints/naming" from a dedicated +// naming document, falling back to the glossary. A project that never wrote a +// naming registry usually encodes its reserved vocabulary in its glossary, so +// the glossary is real evidence for naming — weaker, because a glossary defines +// terms rather than ruling on what may be renamed, and cited with that +// qualifier so the section is never mistaken for a copy of the glossary row. +// +// The ceiling is StatusPartial in both cases: neither a naming page nor a +// glossary enumerates a project's full reserved vocabulary. The strength of the +// signal is carried by Confidence, not by inflating the status. +type convNamingSource struct{} + +func (convNamingSource) Section() Section { return "constraints/naming" } +func (convNamingSource) Tier() Tier { return TierConventions } + +func (convNamingSource) Probe(ctx *SourceContext) Evidence { + p := ctx.FindFirst(convNamingDocNames...) + if p == "" { + p = convDocsEntryWithPrefix(ctx, "naming") + } + if p != "" { + return Evidence{ + Status: StatusPartial, + Confidence: ConfidenceMedium, + Sources: []string{p}, + } + } + if g := convGlossaryDoc(ctx); g != "" { + return Evidence{ + Status: StatusPartial, + Confidence: ConfidenceLow, + Sources: []string{g + " (glossary fallback — no dedicated naming document)"}, + } + } + return blank( + []string{ + "naming document (" + strings.Join(convNamingDocNames, ", ") + ")", + "docs/naming*", + "GLOSSARY.md", "docs/glossary*", + }, + "What names and reserved vocabulary are fixed? No naming document and no glossary found.", + ) +} + +// convArchitectureDocNames are the architecture-document spellings checked as a +// single file, in preference order. +var convArchitectureDocNames = []string{ + "ARCHITECTURE.md", "ARCHITECTURE", "architecture.md", "docs/architecture.md", +} + +// convArchitectureDirs are the conventional homes for architecture prose spread +// across several files, in preference order. The last is the Diátaxis +// explanation quadrant, which is where a repo following that scheme puts it. +var convArchitectureDirs = []string{ + "docs/architecture", "docs/design", "docs/explanation", +} + +// convLayoutRoots are the directory names that hold a project's own packages — +// the second, independent internals signal, and the one every codebase has +// whether or not it wrote its architecture down. +var convLayoutRoots = []string{"internal", "pkg", "src", "lib", "cmd", "app"} + +// maxLayoutCitations caps how many package entries the layout scan cites. Beyond +// it the scan keeps counting — the headline stays truthful — but stops citing, +// so a monorepo with thousands of packages cannot dump them all into a brief +// section. +const maxLayoutCitations = 50 + +// convDirHasMarkdown reports whether a repo-relative directory holds at least one +// Markdown file — the test that separates a real documentation tree from an +// empty or placeholder one. +func convDirHasMarkdown(ctx *SourceContext, dir string) bool { + if !ctx.IsDir(dir) { + return false + } + for _, name := range ctx.ListDir(dir) { + low := strings.ToLower(name) + if strings.HasSuffix(low, ".md") || strings.HasSuffix(low, ".markdown") { + return true + } + } + return false +} + +// convLayoutPackages returns the "//" entries implied by walked file +// paths: the top two segments of every file beneath a recognised source root, +// unique and sorted. A file sitting directly in a root names no package, so it +// contributes nothing. +func convLayoutPackages(paths []string) []string { + var out []string + for _, p := range paths { + seg := strings.Split(p, "/") + if len(seg) < 3 { + continue + } + for _, root := range convLayoutRoots { + if seg[0] == root { + out = append(out, seg[0]+"/"+seg[1]+"/") + break + } + } + } + return dedupeSorted(out) +} + +// convInternalsSource partially grounds "internals" from what a team wrote about +// its own architecture and from the package layout a rescuer must navigate. The +// two signals are independent: either alone is evidence, and together they are +// the strongest reading available without a record. +// +// The ceiling is StatusPartial by construction: an architecture document plus a +// package listing describes the shape of a system, not its internals chapters. +// Which signals were found moves the confidence instead. +type convInternalsSource struct{} + +func (convInternalsSource) Section() Section { return "internals" } +func (convInternalsSource) Tier() Tier { return TierConventions } + +func (s convInternalsSource) Probe(ctx *SourceContext) Evidence { + return s.probeLimited(ctx, maxWalkFiles) +} + +// probeLimited is Probe with the walk cap injected, so the truncation branch is +// exercisable by a test at an affordable scale. The shipped cap stays a const: +// adapters run concurrently, and a mutable package-level cap would be shared +// state between them. +func (convInternalsSource) probeLimited(ctx *SourceContext, walkLimit int) Evidence { + docPath := ctx.FindFirst(convArchitectureDocNames...) + docDir := "" + if docPath == "" { + for _, dir := range convArchitectureDirs { + if convDirHasMarkdown(ctx, dir) { + docDir = dir + break + } + } + } + paths, truncated := ctx.walkFilesLimited(".", walkLimit) + pkgs := convLayoutPackages(paths) + + // Loud staging: a walk the bounds cut short saw only part of the tree, so a + // rescuer never mistakes a partial layout for the whole one — and the note is + // owed whether or not the truncated walk happened to reach a package first. + truncatedNote := "" + if truncated { + truncatedNote = fmt.Sprintf("walk cap (%d entries, %d levels deep) cut the layout scan short", walkLimit, maxWalkDepth) + } + + if docPath == "" && docDir == "" && len(pkgs) == 0 { + searched := []string{ + "architecture document (" + strings.Join(convArchitectureDocNames, ", ") + ")", + "architecture trees (" + strings.Join(convArchitectureDirs, ", ") + ")", + "package layout under " + strings.Join(convLayoutRoots, ", "), + } + // A blank is a first-class result only while it is trustworthy (adr-35), + // so a scan that never reached the source roots says so rather than + // claiming there are none. + question := "How is this system built internally? No architecture document and no recognisable package layout." + if truncated { + searched = append(searched, truncatedNote+"; the rest of the tree was not walked") + question = "How is this system built internally? No architecture document, and no package layout in the part of the tree the scan reached — it did not reach all of it." + } + return blank(searched, question) + } + + var sources []string + // Layout alone is the weakest reading: it says where the code is, never why. + confidence := ConfidenceLow + switch { + case docPath != "": + // The same prose measure convContextSource applies to a README, so a + // scaffolded ARCHITECTURE.md is not mistaken for a written one. + if data, ok := ctx.ReadFile(docPath); ok && convProseBytes(data) >= convGroundedProseBytes { + sources = append(sources, docPath) + confidence = ConfidenceHigh + } else { + sources = append(sources, docPath+" (near-empty)") + confidence = ConfidenceMedium + } + case docDir != "": + sources = append(sources, docDir+"/") + confidence = ConfidenceMedium + } + + if truncated { + sources = append(sources, truncatedNote+"; packages beyond it were not seen") + } + if len(pkgs) > 0 { + if len(pkgs) > maxLayoutCitations { + sources = append(sources, fmt.Sprintf("%d further package(s) counted but not cited (citation cap %d)", len(pkgs)-maxLayoutCitations, maxLayoutCitations)) + pkgs = pkgs[:maxLayoutCitations] + } + sources = append(sources, pkgs...) + } + return Evidence{ + Status: StatusPartial, + Confidence: confidence, + Sources: dedupeSorted(sources), + } +} + // convMarkerNames are the in-code work markers recognised as open questions, // uppercase only. NOTE and OPTIMIZE are deliberately absent: NOTE marks // explanation rather than unfinished work, and OPTIMIZE is rare enough that its diff --git a/internal/core/lifeboat/sources_conventions_test.go b/internal/core/lifeboat/sources_conventions_test.go index fb66cb3..47e2a86 100644 --- a/internal/core/lifeboat/sources_conventions_test.go +++ b/internal/core/lifeboat/sources_conventions_test.go @@ -592,6 +592,454 @@ func TestConvOpenQuestionsBlankAdmitsAPartialScan(t *testing.T) { } } +// convArchitectureProse is an ARCHITECTURE.md body comfortably above the +// convGroundedProseBytes threshold — real architecture prose rather than a stub. +const convArchitectureProse = "# Architecture\n\n" + + "The wreck is split into a transport-agnostic core and a thin CLI shell. " + + "The core owns every decision; the shell only formats what the core returns, " + + "so a second front door costs nothing but its formatter.\n" + +// TestConvNamingPartialFromNamingDoc is the intent's headline naming behaviour: a +// record-less repository carrying a dedicated NAMING.md no longer blanks +// "constraints/naming" — the section comes back non-blank at the conventions +// tier, citing the file it read. +func TestConvNamingPartialFromNamingDoc(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "go.mod": "module example.com/wreck\n\ngo 1.22\n", + "NAMING.md": "# Naming\n\nA voyage is never called a run.\n", + }) + cov, err := Probe(dir) + if err != nil { + t.Fatal(err) + } + sc := findSection(t, cov, "constraints/naming") + if sc.Status != StatusPartial { + t.Fatalf("constraints/naming status = %s, want partial", sc.Status) + } + if sc.Tier != TierConventions { + t.Errorf("constraints/naming tier = %s, want %s", sc.Tier, TierConventions) + } + if !containsSource(sc.Evidence, "NAMING.md") { + t.Errorf("evidence = %v, want NAMING.md cited", sc.Evidence) + } + if sc.Confidence != ConfidenceMedium { + t.Errorf("confidence = %s for a dedicated naming document, want %s", sc.Confidence, ConfidenceMedium) + } +} + +// TestConvNamingPartialFromDocsNamingPage confirms the docs/ prefix idiom: a +// naming page under docs/ grounds the section when no root NAMING.md exists. +func TestConvNamingPartialFromDocsNamingPage(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "go.mod": "module example.com/wreck\n\ngo 1.22\n", + "docs/naming-conventions.md": "# Naming conventions\n\nPackages are singular nouns.\n", + "docs/unrelated-appendix.md": "# Appendix\n\nNothing about naming.\n", + }) + ctx, err := newSourceContext(dir) + if err != nil { + t.Fatal(err) + } + defer ctx.Close() + + ev := convSourceForSection(t, "constraints/naming").Probe(ctx) + if ev.Status != StatusPartial { + t.Fatalf("constraints/naming status = %s, want partial", ev.Status) + } + if !containsSource(ev.Sources, "docs/naming-conventions.md") { + t.Errorf("evidence = %v, want the docs/ naming page cited", ev.Sources) + } + if ev.Confidence != ConfidenceMedium { + t.Errorf("confidence = %s for a docs/ naming page, want %s", ev.Confidence, ConfidenceMedium) + } +} + +// TestConvNamingFallsBackToGlossaryWithoutDisplacingIt holds the distinctness +// contract on the fixture that could collapse the two sections into one: a +// repository whose only vocabulary signal is a GLOSSARY.md. The glossary section +// stays exactly what convGlossarySource already made it, and naming is a visibly +// weaker derived reading — lower confidence, and a citation qualified as the +// fallback it is — never a duplicate row. +func TestConvNamingFallsBackToGlossaryWithoutDisplacingIt(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "GLOSSARY.md": "# Glossary\n\n**Voyage** — one packed lifeboat.\n", + }) + cov, err := Probe(dir) + if err != nil { + t.Fatal(err) + } + + naming := findSection(t, cov, "constraints/naming") + if naming.Status != StatusPartial { + t.Fatalf("constraints/naming status = %s, want partial", naming.Status) + } + if naming.Confidence != ConfidenceLow { + t.Errorf("naming confidence = %s on a glossary fallback, want %s", naming.Confidence, ConfidenceLow) + } + if len(naming.Evidence) != 1 || !strings.Contains(naming.Evidence[0], "GLOSSARY.md") { + t.Fatalf("naming evidence = %v, want the glossary cited", naming.Evidence) + } + if !strings.Contains(naming.Evidence[0], "glossary fallback") { + t.Errorf("naming cites %q without the glossary-fallback qualifier", naming.Evidence[0]) + } + + glossary := findSection(t, cov, "glossary") + if glossary.Status != StatusPartial || glossary.Confidence != ConfidenceMedium { + t.Fatalf("glossary = %s/%s, want partial/medium (convGlossarySource unchanged)", + glossary.Status, glossary.Confidence) + } + if !containsSource(glossary.Evidence, "GLOSSARY.md") { + t.Errorf("glossary evidence = %v, want GLOSSARY.md cited bare", glossary.Evidence) + } + + // The two rows must differ in both dimensions: same file, different reading. + if naming.Confidence == glossary.Confidence { + t.Errorf("naming and glossary share confidence %s; naming must be the weaker reading", naming.Confidence) + } + if naming.Evidence[0] == glossary.Evidence[0] { + t.Errorf("naming and glossary cite the identical string %q; naming must be visibly derived", naming.Evidence[0]) + } +} + +// TestConventionSourcesHaveOneAdapterPerSection guards the registry against a +// duplicate adapter: two adapters for one section race for the same coverage row +// and the loser's evidence silently vanishes. It pins the sections this intent +// touches — including glossary, which keeps its single pre-existing adapter. +func TestConventionSourcesHaveOneAdapterPerSection(t *testing.T) { + count := map[Section]int{} + for _, s := range conventionSources() { + count[s.Section()]++ + } + for _, section := range []Section{ + "glossary", "constraints/naming", "internals", "evidence/open-questions", + } { + if count[section] != 1 { + t.Errorf("conventionSources has %d adapters for %s, want exactly 1", count[section], section) + } + } + for section, n := range count { + if n > 1 { + t.Errorf("conventionSources has %d adapters for %s, want at most 1", n, section) + } + } +} + +// TestConvInternalsCitesArchitectureAndLayout is the intent's headline internals +// behaviour: a record-less repository with an ARCHITECTURE.md and a package tree +// grounds "internals" from both signals at once, citing the document and the +// packages a rescuer must navigate. +func TestConvInternalsCitesArchitectureAndLayout(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "go.mod": "module example.com/wreck\n\ngo 1.22\n", + "ARCHITECTURE.md": convArchitectureProse, + "internal/core/voyage.go": "package core\n", + "internal/surface/cli/main.go": "package cli\n", + "cmd/wreck/main.go": "package main\n", + }) + cov, err := Probe(dir) + if err != nil { + t.Fatal(err) + } + sc := findSection(t, cov, "internals") + if sc.Status != StatusPartial { + t.Fatalf("internals status = %s, want partial", sc.Status) + } + if sc.Tier != TierConventions { + t.Errorf("internals tier = %s, want %s", sc.Tier, TierConventions) + } + if sc.Confidence != ConfidenceHigh { + t.Errorf("internals confidence = %s with real architecture prose, want %s", sc.Confidence, ConfidenceHigh) + } + if !containsSource(sc.Evidence, "ARCHITECTURE.md") { + t.Errorf("evidence = %v, want ARCHITECTURE.md cited", sc.Evidence) + } + for _, want := range []string{"internal/core/", "internal/surface/", "cmd/wreck/"} { + if !containsSource(sc.Evidence, want) { + t.Errorf("evidence = %v, want the layout entry %s cited", sc.Evidence, want) + } + } +} + +// TestConvInternalsLayoutOnlyIsLowConfidence holds the weakest internals signal: +// a package tree with no architecture document still says something about the +// shape of the system, at low confidence and citing the layout alone. The fixture +// carries no other conventional sentinel, so it also proves the tier gate admits +// a repository whose only Tier-1 signal is its layout. +func TestConvInternalsLayoutOnlyIsLowConfidence(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "internal/store/store.go": "package store\n", + "pkg/api/api.go": "package api\n", + }) + cov, err := Probe(dir) + if err != nil { + t.Fatal(err) + } + sc := findSection(t, cov, "internals") + if sc.Status != StatusPartial { + t.Fatalf("internals status = %s, want partial (evidence %v)", sc.Status, sc.Evidence) + } + if sc.Confidence != ConfidenceLow { + t.Errorf("internals confidence = %s with layout only, want %s", sc.Confidence, ConfidenceLow) + } + for _, want := range []string{"internal/store/", "pkg/api/"} { + if !containsSource(sc.Evidence, want) { + t.Errorf("evidence = %v, want the layout entry %s cited", sc.Evidence, want) + } + } + for _, got := range sc.Evidence { + if strings.Contains(strings.ToLower(got), "architecture") { + t.Errorf("layout-only internals cites %q; no architecture document exists", got) + } + } +} + +// TestConvInternalsThinArchitectureIsMedium separates a written architecture +// chapter from a placeholder: a document below the prose threshold is still +// evidence, but only at medium confidence. +func TestConvInternalsThinArchitectureIsMedium(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "go.mod": "module example.com/wreck\n\ngo 1.22\n", + "ARCHITECTURE.md": "# Architecture\n\nTBD.\n", + }) + ctx, err := newSourceContext(dir) + if err != nil { + t.Fatal(err) + } + defer ctx.Close() + + ev := convSourceForSection(t, "internals").Probe(ctx) + if ev.Status != StatusPartial { + t.Fatalf("internals status = %s, want partial", ev.Status) + } + if ev.Confidence != ConfidenceMedium { + t.Errorf("internals confidence = %s for a thin architecture doc, want %s", ev.Confidence, ConfidenceMedium) + } + if len(ev.Sources) == 0 { + t.Fatal("partial internals cites no evidence") + } +} + +// TestConvInternalsMediumFromArchitectureDirectory confirms the Diataxis +// fallback: a docs/explanation tree holding Markdown is architecture prose spread +// across files, and counts at medium confidence. +func TestConvInternalsMediumFromArchitectureDirectory(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "go.mod": "module example.com/wreck\n\ngo 1.22\n", + "docs/explanation/model.md": "# The model\n\nHow the pieces fit.\n", + "docs/explanation/notes.txt": "not markdown\n", + }) + ctx, err := newSourceContext(dir) + if err != nil { + t.Fatal(err) + } + defer ctx.Close() + + ev := convSourceForSection(t, "internals").Probe(ctx) + if ev.Status != StatusPartial { + t.Fatalf("internals status = %s, want partial", ev.Status) + } + if ev.Confidence != ConfidenceMedium { + t.Errorf("internals confidence = %s for a doc directory, want %s", ev.Confidence, ConfidenceMedium) + } + if !containsSource(ev.Sources, "docs/explanation/") { + t.Errorf("evidence = %v, want the docs/explanation/ tree cited", ev.Sources) + } +} + +// TestConvInternalsBoundsItsLayoutCitations holds both layout bounds at once: the +// citation cap keeps a vast monorepo from dumping every package into a section +// while the count stays truthful, and a walk cut short by the file cap says so in +// its own evidence rather than reading as a complete survey. +func TestConvInternalsBoundsItsLayoutCitations(t *testing.T) { + dir := t.TempDir() + files := map[string]string{"go.mod": "module example.com/vast\n\ngo 1.22\n"} + for i := 0; i < maxLayoutCitations+5; i++ { + files[fmt.Sprintf("internal/p%03d/p.go", i)] = "package p\n" + } + writeTree(t, dir, files) + ctx, err := newSourceContext(dir) + if err != nil { + t.Fatal(err) + } + defer ctx.Close() + + src := convSourceForSection(t, "internals").(convInternalsSource) + full := src.probeLimited(ctx, maxWalkFiles) + cited := 0 + for _, s := range full.Sources { + if strings.HasPrefix(s, "internal/p") { + cited++ + } + } + if cited != maxLayoutCitations { + t.Errorf("cited %d layout entries, want the cap of %d", cited, maxLayoutCitations) + } + if !containsSource(full.Sources, fmt.Sprintf("5 further package(s) counted but not cited (citation cap %d)", maxLayoutCitations)) { + t.Errorf("evidence = %v, want the 5 uncited packages reported as a count", full.Sources) + } + + // A walk cut short must be visible in the evidence, exactly as the marker + // scan's truncation is. + capped := src.probeLimited(ctx, 3) + said := false + for _, s := range capped.Sources { + if strings.Contains(s, "walk cap") { + said = true + } + } + if !said { + t.Errorf("truncated layout scan does not report it in its evidence: %v", capped.Sources) + } +} + +// TestConvInternalsStagesAScanThatReachedNoPackage holds the loud staging where +// it is easiest to lose: a walk the cap cut short before it ever reached a +// source root found no packages at all, so both the blank and the +// architecture-only reading would otherwise describe a tree the scan never +// finished reading. A blank is a first-class result only while it is trustworthy +// (adr-35), so it must say the layout scan stopped early — and so must the +// non-blank reading beside it. +func TestConvInternalsStagesAScanThatReachedNoPackage(t *testing.T) { + // assets/ sorts before every source root, so a cap of ten files is spent + // before the walk reaches src/ — exactly the shape of a repository whose + // code sits behind a large data or frontend tree. + bulk := func(extra map[string]string) map[string]string { + files := map[string]string{"src/pkg/code.go": "package pkg\n"} + for i := 0; i < 40; i++ { + files[fmt.Sprintf("assets/a%02d.bin", i)] = "x\n" + } + for rel, content := range extra { + files[rel] = content + } + return files + } + + t.Run("blank", func(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, bulk(nil)) + ctx, err := newSourceContext(dir) + if err != nil { + t.Fatal(err) + } + defer ctx.Close() + + ev := convSourceForSection(t, "internals").(convInternalsSource).probeLimited(ctx, 10) + if ev.Status != StatusBlank { + t.Fatalf("internals status = %s, want blank (evidence %v)", ev.Status, ev.Sources) + } + said := false + for _, s := range ev.Searched { + if strings.Contains(s, "walk cap") { + said = true + } + } + if !said { + t.Errorf("blank internals searched %v without admitting the walk cap cut the layout scan short", ev.Searched) + } + if !strings.Contains(ev.Question, "reached") { + t.Errorf("blank internals asks %q, claiming no layout in a tree it did not finish reading", ev.Question) + } + }) + + t.Run("architecture only", func(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, bulk(map[string]string{"ARCHITECTURE.md": convArchitectureProse})) + ctx, err := newSourceContext(dir) + if err != nil { + t.Fatal(err) + } + defer ctx.Close() + + ev := convSourceForSection(t, "internals").(convInternalsSource).probeLimited(ctx, 10) + if ev.Status != StatusPartial { + t.Fatalf("internals status = %s, want partial", ev.Status) + } + said := false + for _, s := range ev.Sources { + if strings.Contains(s, "walk cap") { + said = true + } + } + if !said { + t.Errorf("evidence = %v, want the truncated layout scan reported even though it found no package", ev.Sources) + } + }) +} + +// TestConvNamingAndInternalsBlankWithoutSignals holds the "a blank is a result" +// contract for both new adapters: a repository with neither naming nor +// architecture documentation and no recognisable layout returns honest blanks +// naming what was searched and the question a human must answer — never a +// fabricated section. +func TestConvNamingAndInternalsBlankWithoutSignals(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "README.md": "# Bare\n\nA project that documented nothing else.\n", + "main.go": "package main\n\nfunc main() {}\n", + }) + ctx, err := newSourceContext(dir) + if err != nil { + t.Fatal(err) + } + defer ctx.Close() + + for _, section := range []Section{"constraints/naming", "internals"} { + ev := convSourceForSection(t, section).Probe(ctx) + if ev.Status != StatusBlank { + t.Fatalf("%s status = %s, want blank (evidence %v)", section, ev.Status, ev.Sources) + } + if len(ev.Searched) == 0 { + t.Errorf("blank %s names nothing it searched", section) + } + if ev.Question == "" { + t.Errorf("blank %s carries no question for a human", section) + } + } +} + +// TestHasConventionsCoversNamingAndArchitecture is the tier-gate regression the +// new adapters open up: the gate skips every adapter of an absent tier, so a +// repository whose only conventional signal is a NAMING.md or an ARCHITECTURE.md +// would have the whole Tier-1 set skipped and its section blanked falsely. +func TestHasConventionsCoversNamingAndArchitecture(t *testing.T) { + for _, tc := range []struct { + file string + content string + section Section + }{ + {"NAMING.md", "# Naming\n\nA voyage is never called a run.\n", "constraints/naming"}, + {"ARCHITECTURE.md", convArchitectureProse, "internals"}, + } { + t.Run(tc.file, func(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{tc.file: tc.content}) + cov, err := Probe(dir) + if err != nil { + t.Fatal(err) + } + found := false + for _, tr := range cov.TiersPresent { + if tr == TierConventions { + found = true + } + } + if !found { + t.Fatalf("tiers_present = %v omits TierConventions for a %s-only repo", cov.TiersPresent, tc.file) + } + sc := findSection(t, cov, tc.section) + if sc.Status == StatusBlank { + t.Errorf("%s blanked in a %s-only repo; the tier gate skipped its adapter", tc.section, tc.file) + } + }) + } +} + // containsSource reports whether want appears in sources. func containsSource(sources []string, want string) bool { for _, s := range sources { From c9e1f7deaf211a7bfcabea3599c0b3faa4a82020 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:31:06 +0100 Subject: [PATCH 3/4] chore: resolve iss-113, fixed with the walk-bounding change The marker adapter's truncation evidence named only the file cap; it now names all three bounds the walk enforces, fixed on the itd-95 branch alongside the change that made the wording imprecise. Assisted-by: Claude:claude-opus-4-8 --- ...nvopenquestionssource-s-truncation-evidence-names-only-the.md | 1 + 1 file changed, 1 insertion(+) rename .abcd/work/issues/{open => resolved}/iss-113-convopenquestionssource-s-truncation-evidence-names-only-the.md (71%) diff --git a/.abcd/work/issues/open/iss-113-convopenquestionssource-s-truncation-evidence-names-only-the.md b/.abcd/work/issues/resolved/iss-113-convopenquestionssource-s-truncation-evidence-names-only-the.md similarity index 71% rename from .abcd/work/issues/open/iss-113-convopenquestionssource-s-truncation-evidence-names-only-the.md rename to .abcd/work/issues/resolved/iss-113-convopenquestionssource-s-truncation-evidence-names-only-the.md index 6d064f9..8e1ef44 100644 --- a/.abcd/work/issues/open/iss-113-convopenquestionssource-s-truncation-evidence-names-only-the.md +++ b/.abcd/work/issues/resolved/iss-113-convopenquestionssource-s-truncation-evidence-names-only-the.md @@ -7,6 +7,7 @@ category: "tech-debt" source: "impl-review" found_during: "itd-96 P1 review" found_at: "internal/core/lifeboat/sources_conventions.go" +resolution: "Fixed on the itd-95 branch alongside the walk-bounding change that caused it: both truncation strings in convOpenQuestionsSource now name the walk cap as '(N entries, M levels deep)' rather than as a file cap alone, so the evidence matches the three dimensions the walk actually bounds." --- convOpenQuestionsSource's truncation evidence names only the "%d-file walk cap", but WalkFiles now also truncates on its directory cap and its depth cap, so a walk stopped by either reports a cause it did not hit. The three affected strings are the searched note, the blank's qualified question, and the non-blank scan note. convInternalsSource words the same note as "walk cap (N entries, M levels deep)"; the two should say the same thing. Found by an independent security review of the itd-96 diff. \ No newline at end of file From 30628d1cfe136d899cf02ec777a617c746a9f985 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:35:36 +0100 Subject: [PATCH 4/4] docs: record the tier-ceiling and walk-bounds decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two decisions from the itd-95/itd-96 work that a future session would otherwise re-litigate: that the mapping table's per-tier columns are a ceiling adapters honour rather than a loose prediction, and that a walk over a foreign tree must name which of entries, depth, and aggregate bytes bounds it — a per-item cap and a count cap multiply, and their product is not a bound. Assisted-by: Claude:claude-opus-4-8 --- .abcd/work/DECISIONS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 5a4aace..47c45cb 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -623,3 +623,27 @@ parallel-agent merge contention bites. `/abcd:intent`'s interview script: `plan` is never run without the human's explicit in-session confirmation). Escalation path if violations appear: an `ac_confirmed_by:` field is lint-legal today and slots in as a fifth check. + +- 2026-07-21 — The mapping table's per-tier status columns are a **ceiling**, + not merely a prediction. Every conventions adapter already honours its row + (`convGlossarySource` returns partial where the row says partial; + `convPlatformSource` returns grounded where it says grounded), so itd-95's + and itd-96's three new adapters cap at `StatusPartial` — the value all three + rows predict — and carry signal strength in `Confidence` instead. Rejected: + returning `StatusGrounded` for a dedicated `NAMING.md` or a prose-bearing + `ARCHITECTURE.md`, which would have made the rendered brief table wrong and + required editing `mapping.go`, the brief-to-lifeboat contract both intents + put out of scope. Every acceptance bar asks only for "non-blank", so the + ceiling satisfies them. Revisit only by amending the mapping row first. + +- 2026-07-21 — A probe walk of a foreign tree must be bounded in **three** + dimensions, not one. itd-95 shipped `WalkFiles` with a regular-file cap; an + independent security review of the itd-96 branch showed both remaining + dimensions were exploitable — a tree of directories holding no regular file + never reaches a file cap, and `os.Root` re-resolves each directory from the + containment root one component at a time, making a directory chain quadratic + in its depth (depth-1500 did not finish in two minutes). Directories are now + counted against the same cap and descent is capped at `maxWalkDepth`. The + general rule: any new whole-tree traversal states which of {entries, depth, + aggregate bytes} bounds it, because a per-item cap and a count cap multiply + and their product is not a bound.