Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion internal/harvest/harvest.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,26 @@ 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++
case len(fc.ReadBy) > 0:
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
}
Expand Down
29 changes: 25 additions & 4 deletions internal/harvest/harvest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
16 changes: 11 additions & 5 deletions internal/receipt/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<sub>unread (no harvester for): %s</sub>\n", hist)
}
if hist := unreadHistogram(cov, true); hist != "" {
fmt.Fprintf(w, "<sub>unread (policy — hidden paths hold installed tooling): %s</sub>\n", hist)
}
if cov.SuppressedMentions > 0 {
fmt.Fprintf(w, "<sub>%s</sub>\n", mentionNote(cov.SuppressedMentions))
}
Expand All @@ -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)"
Expand Down
5 changes: 4 additions & 1 deletion internal/receipt/receipt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
41 changes: 38 additions & 3 deletions internal/receipt/receipt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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
Expand Down
14 changes: 12 additions & 2 deletions schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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"
Loading