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"