diff --git a/DESIGN.md b/DESIGN.md index 9087eec..57d78f7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -316,10 +316,15 @@ than smoothed over: - **Spec identifiers are not namespaced per project.** `AP-012` in one repo and `AP-012` in another are different invariants that share an id; receipts are per-repo today, so this only matters for a future cross-repo ledger. -- **"Unread" merges two causes.** A file can be unread because no harvester - understands its format (a capability gap) or because policy excludes it - (hidden-directory tooling). The per-file JSON makes the cause inspectable; - the text histogram does not yet distinguish them. +- **"Unread" had merged two causes — RESOLVED (schema 0.0.7).** A file can be + unread because no harvester understands its format (a capability gap) or + because policy excludes it (hidden-directory tooling). The two are now + separate disclosures: per-file `skip_reason` ("no-harvester" vs + "hidden-path"), an `unread_policy` summary count, and two distinct + histogram lines in every renderer. Found live on the first pre-push + dogfood install: a repo's tracked hidden documents rendered as "no + harvester for .md" when a markdown harvester exists — the cause was + policy, and the receipt now says so. - **Spec-id harvesting skips hidden directories.** Installed tooling under dot-directories (`.correctless/`, `.claude/`) carries the tooling's own identifiers; measured on a real sweep, all 75 remainder entries were tooling diff --git a/internal/harvest/harvest.go b/internal/harvest/harvest.go index 85af72f..8b9e4a6 100644 --- a/internal/harvest/harvest.go +++ b/internal/harvest/harvest.go @@ -105,7 +105,6 @@ func Run(repoDir string, files []string, harvesters ...Harvester) ([]schema.Clai cov := schema.Coverage{Files: make([]schema.FileCoverage, 0, len(files))} for _, f := range files { fc := schema.FileCoverage{File: f, ReadBy: readBy[f], Claims: claimCount[f]} - cov.Files = append(cov.Files, fc) switch { case fc.Claims > 0: cov.Claimed++ @@ -113,7 +112,19 @@ func Run(repoDir string, files []string, harvesters ...Harvester) ([]schema.Clai cov.Scanned++ default: cov.Unread++ + // An unread file has one of two different stories, and merging + // them misleads: a hidden-path file was skipped by POLICY (every + // harvester treats hidden directories as installed tooling — a + // harvester for its format may well exist), while any other + // unread file is a CAPABILITY gap. + if UnderDotDir(f) { + fc.SkipReason = "hidden-path" + cov.UnreadPolicy++ + } else { + fc.SkipReason = "no-harvester" + } } + cov.Files = append(cov.Files, fc) } return DetectPairs(out), cov, nil } diff --git a/internal/harvest/harvest_test.go b/internal/harvest/harvest_test.go index f253154..4f00f76 100644 --- a/internal/harvest/harvest_test.go +++ b/internal/harvest/harvest_test.go @@ -83,13 +83,21 @@ func TestINV009_CoverageThreeWaySplit(t *testing.T) { "plain.go": "package x\nfunc helper() {}\n", "notes.md": "prose about INV-901, which is not code\n", "data.bin": "\x00\x01binary payload no harvester reads\n", + // A TRACKED hidden-path document: policy skips it (installed + // tooling), which is a different unread story from data.bin's + // capability gap — a harvester for .md exists. + ".tooling/spec.md": "### INV-902: tooling doc\n", } for name, content := range files { - if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + abs := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { t.Fatal(err) } } - list := []string{"a_test.go", "b_test.go", "plain.go", "notes.md", "data.bin"} + list := []string{"a_test.go", "b_test.go", "plain.go", "notes.md", "data.bin", ".tooling/spec.md"} claims, cov, err := Run(dir, list, Default()...) if err != nil { t.Fatal(err) @@ -103,10 +111,13 @@ func TestINV009_CoverageThreeWaySplit(t *testing.T) { // notes.md counts as SCANNED, not unread: the rfc-must harvester opens // every candidate document to sniff for normative markers, and the sniff // is honestly a scan (it found none — the file yields zero claims). - if cov.Claimed != 2 || cov.Scanned != 2 || cov.Unread != 1 { - t.Fatalf("coverage split = claimed %d / scanned %d / unread %d, want 2/2/1", + if cov.Claimed != 2 || cov.Scanned != 2 || cov.Unread != 2 { + t.Fatalf("coverage split = claimed %d / scanned %d / unread %d, want 2/2/2", cov.Claimed, cov.Scanned, cov.Unread) } + if cov.UnreadPolicy != 1 { + t.Fatalf("unread_policy = %d, want 1 (the hidden-path doc)", cov.UnreadPolicy) + } byFile := map[string]schema.FileCoverage{} for _, f := range cov.Files { byFile[f.File] = f @@ -123,6 +134,16 @@ func TestINV009_CoverageThreeWaySplit(t *testing.T) { if len(byFile["data.bin"].ReadBy) != 0 { t.Errorf("data.bin read by %v, want unread", byFile["data.bin"].ReadBy) } + // The two unread causes are DIFFERENT disclosures and must not merge. + if got := byFile["data.bin"].SkipReason; got != "no-harvester" { + t.Errorf("data.bin skip_reason = %q, want no-harvester (capability gap)", got) + } + if got := byFile[".tooling/spec.md"].SkipReason; got != "hidden-path" { + t.Errorf(".tooling/spec.md skip_reason = %q, want hidden-path (policy skip)", got) + } + if byFile["plain.go"].SkipReason != "" { + t.Errorf("a read file carries a skip reason: %+v", byFile["plain.go"]) + } } // TestINV003_SpecIDNormalizesToCanonicalForm: harvested identifiers in any diff --git a/internal/receipt/markdown.go b/internal/receipt/markdown.go index cfdc359..cdc8158 100644 --- a/internal/receipt/markdown.go +++ b/internal/receipt/markdown.go @@ -82,9 +82,12 @@ func WriteMarkdown(w io.Writer, r schema.Receipt) { cov := r.Coverage fmt.Fprintf(w, "**Harvest coverage:** %d files — %d claimed · %d scanned · %d unread\n", len(cov.Files), cov.Claimed, cov.Scanned, cov.Unread) - if hist := unreadHistogram(cov); hist != "" { + if hist := unreadHistogram(cov, false); hist != "" { fmt.Fprintf(w, "unread (no harvester for): %s\n", hist) } + if hist := unreadHistogram(cov, true); hist != "" { + fmt.Fprintf(w, "unread (policy — hidden paths hold installed tooling): %s\n", hist) + } if cov.SuppressedMentions > 0 { fmt.Fprintf(w, "%s\n", mentionNote(cov.SuppressedMentions)) } @@ -97,12 +100,15 @@ func mdCell(s string) string { return strings.ReplaceAll(s, "\n", " ") } -// unreadHistogram renders the unread files grouped by extension, most common -// first — shared shape with the text renderer's disclosure. -func unreadHistogram(cov schema.Coverage) string { +// unreadHistogram renders the unread files of ONE cause grouped by +// extension, most common first — shared shape with the text renderer's +// disclosure. With policy set it selects the policy-skipped files +// (SkipReason "hidden-path"); otherwise it selects every other unread file, +// so a coverage record without the field still renders as a capability gap. +func unreadHistogram(cov schema.Coverage, policy bool) string { counts := map[string]int{} for _, f := range cov.Files { - if len(f.ReadBy) == 0 && f.Claims == 0 { + if len(f.ReadBy) == 0 && f.Claims == 0 && (f.SkipReason == "hidden-path") == policy { ext := path.Ext(f.File) if ext == "" { ext = "(none)" diff --git a/internal/receipt/receipt.go b/internal/receipt/receipt.go index bdb4252..56c76d7 100644 --- a/internal/receipt/receipt.go +++ b/internal/receipt/receipt.go @@ -387,9 +387,12 @@ func writeCoverage(w io.Writer, cov schema.Coverage) { // content no harvester could read, without 400 lines of file list. The // histogram is shared with the markdown renderer — one computation, no // drift between the two disclosures. - if hist := unreadHistogram(cov); hist != "" { + if hist := unreadHistogram(cov, false); hist != "" { fmt.Fprintf(w, " unread (no harvester for): %s\n", hist) } + if hist := unreadHistogram(cov, true); hist != "" { + fmt.Fprintf(w, " unread (policy — hidden paths hold installed tooling): %s\n", hist) + } if cov.SuppressedMentions > 0 { fmt.Fprintf(w, " %s\n", mentionNote(cov.SuppressedMentions)) } diff --git a/internal/receipt/receipt_test.go b/internal/receipt/receipt_test.go index fbeed00..b7c54a9 100644 --- a/internal/receipt/receipt_test.go +++ b/internal/receipt/receipt_test.go @@ -197,9 +197,9 @@ func TestCoverageDisclosesUnreadFiles(t *testing.T) { cov := schema.Coverage{ Files: []schema.FileCoverage{ {File: "formal/model.als", ReadBy: []string{"alloy"}, Claims: 17}, - {File: "src/core.c"}, - {File: "src/other.c"}, - {File: "docs/spec.md"}, + {File: "src/core.c", SkipReason: "no-harvester"}, + {File: "src/other.c", SkipReason: "no-harvester"}, + {File: "docs/spec.md"}, // no reason recorded: renders as a capability gap }, Claimed: 1, Unread: 3, } @@ -215,6 +215,41 @@ func TestCoverageDisclosesUnreadFiles(t *testing.T) { } } +// TestUnreadCausesRenderSeparately: "unread" merges two different stories — +// a capability gap (no harvester for the format) and a policy skip (hidden +// paths hold installed tooling). Both renderers must state them as separate +// lines; merging them misleads (measured live: a repo's tracked hidden docs +// rendered as "no harvester for .md" when a markdown harvester exists). +func TestUnreadCausesRenderSeparately(t *testing.T) { + cov := schema.Coverage{ + Files: []schema.FileCoverage{ + {File: "data.bin", SkipReason: "no-harvester"}, + {File: ".tooling/a.md", SkipReason: "hidden-path"}, + {File: ".tooling/b.md", SkipReason: "hidden-path"}, + }, + Unread: 3, UnreadPolicy: 2, + } + r := Assemble(gitdiff.Change{}, nil, nil, cov) + + for name, render := range map[string]func(*strings.Builder){ + "markdown": func(b *strings.Builder) { WriteMarkdown(b, r) }, + "text": func(b *strings.Builder) { WriteText(b, r) }, + } { + var b strings.Builder + render(&b) + out := b.String() + if !strings.Contains(out, "unread (no harvester for): .bin×1") { + t.Errorf("%s: capability line wrong:\n%s", name, out) + } + if !strings.Contains(out, "unread (policy — hidden paths hold installed tooling): .md×2") { + t.Errorf("%s: policy line wrong:\n%s", name, out) + } + if strings.Contains(out, "no harvester for): .md") { + t.Errorf("%s: policy-skipped files leaked into the capability line:\n%s", name, out) + } + } +} + // TestAnchoringSummaryAndMarkers: the receipt discloses the binding layer — // headline counts plus per-row markers for the two distrust states (orphan, // ambiguous). Resolved claims carry no marker; their upgraded text IS the diff --git a/schema/schema.go b/schema/schema.go index bb73a4a..362f6a4 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -294,6 +294,12 @@ type FileCoverage struct { // file whose claims merged into another file's claim still counts as // contributing. Claims int `json:"claims"` + // SkipReason states WHY an unread file was not read — the two causes are + // different disclosures: "hidden-path" means policy skipped it (the file + // lives under a hidden directory, which every harvester treats as + // installed tooling), while "no-harvester" means a capability gap (no + // harvester understands the format). Empty for read files. + SkipReason string `json:"skip_reason,omitempty"` } // Coverage is the receipt's disclosure of its own blind spots: which changed @@ -306,7 +312,11 @@ type Coverage struct { Files []FileCoverage `json:"files"` Claimed int `json:"claimed"` // files sourcing ≥1 claim Scanned int `json:"scanned"` // read by ≥1 harvester, 0 claims - Unread int `json:"unread"` // no harvester read the file + Unread int `json:"unread"` // no harvester read the file (total, both causes) + // UnreadPolicy counts the subset of Unread that policy skipped + // (SkipReason "hidden-path") rather than a capability gap. The receipt + // renders the two causes as separate disclosures. + UnreadPolicy int `json:"unread_policy,omitempty"` // SuppressedMentions counts spec-id sightings that were NOT minted as // claims because the repo defines no spec-id corpus at all: with no // definition anywhere, a reference has no possible referent — it is a @@ -332,4 +342,4 @@ type Receipt struct { } // SchemaVersion is the current version of the receipt schema (the payload). -const SchemaVersion = "0.0.6" +const SchemaVersion = "0.0.7"