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. 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. diff --git a/.abcd/work/issues/resolved/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 new file mode 100644 index 0000000..8e1ef44 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-113-convopenquestionssource-s-truncation-evidence-names-only-the.md @@ -0,0 +1,13 @@ +--- +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" +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 diff --git a/internal/core/lifeboat/probe.go b/internal/core/lifeboat/probe.go index c77ff4f..70aa4ec 100644 --- a/internal/core/lifeboat/probe.go +++ b/internal/core/lifeboat/probe.go @@ -570,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 c178043..07a391b 100644 --- a/internal/core/lifeboat/probe_test.go +++ b/internal/core/lifeboat/probe_test.go @@ -247,15 +247,22 @@ func TestWalkFilesStopsAtTheDepthCap(t *testing.T) { // 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 406b935..cd33a5d 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 {