sawbuck 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,11 +42,33 @@ sawbuck: gamed recorded on test.js (the win came from editing the test, no
42
42
  | `sawbuck detach [--claude \| --user]` | remove it |
43
43
  | `sawbuck check [--base R --head R]` | verify a commit; record a regression or a gamed win |
44
44
  | `sawbuck context` · `sawbuck rank` | the ranked relevant history for the current change |
45
+ | `sawbuck debt` | the whole-repo live suppression debt: what is still silenced in HEAD now, disabled tests first, heaviest files ranked |
45
46
  | `sawbuck run --attempt '<agent cmd>'` | thin harness: load context, run one attempt through the keep-if-verified gate |
46
47
  | `sawbuck trace` · `sawbuck doctor` · `detach --verify` | show the footprint; check the attachment resolves; prove a clean removal |
47
48
 
48
49
  Each repo needs a `promptwheel.config.json` saying what to measure (your tests, lint, coverage, or an eval).
49
50
 
51
+ ## Gate debt on pull requests
52
+
53
+ `sawbuck debt --since <base>` shows only the suppression debt a branch adds. Fail the build when a PR adds the kind you care about:
54
+
55
+ ```yaml
56
+ # .github/workflows/debt.yml
57
+ name: debt
58
+ on: pull_request
59
+ jobs:
60
+ debt:
61
+ runs-on: ubuntu-latest
62
+ steps:
63
+ - uses: actions/checkout@v4
64
+ with: { fetch-depth: 0 } # the base ref must be present to diff against
65
+ - run: npx -y sawbuck debt --since "origin/${{ github.base_ref }}" --fail-on undocumented
66
+ ```
67
+
68
+ `--fail-on` takes `undocumented` (a bare `# type: ignore` / `# noqa` with no code), `test` (a newly disabled test), `any`, or a number of new suppressions. Exit 1 fails the check; add `--json` for the machine-readable delta. This repo is also a composite action: `uses: sawbuck-ai/sawbuck@v0` with `base` and `fail-on` inputs.
69
+
70
+ Debt also rises on its own: once attached, the per-turn `context` hook surfaces the live suppression debt on the files your agent is touching when it is the suspect kind (undocumented, a disabled test, or on code that keeps breaking), so you do not have to run anything.
71
+
50
72
  ## The engines
51
73
 
52
74
  Sawbuck is thin glue over two tools it depends on:
package/debt.mjs ADDED
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ // debt.mjs, Sawbuck's live-suppression-debt ledger: what is STILL silenced in HEAD right now,
3
+ // split by severity (disabled/weakened tests vs lint/type/error suppressions), risk-ranked by how
4
+ // fragile each file is (reverts/fix-bursts), and split by whether each suppression is DOCUMENTED (a
5
+ // rule code or reason) or BARE (just the directive, nobody wrote why). The whole-repo view;
6
+ // `sawbuck context` is the task-scoped one. The live state (git grep of HEAD, gated) is the
7
+ // complete lens for CURRENT debt; the historical diff scan under-counts it.
8
+ // Zero-dep. Usage: node debt.mjs [--repo P] [--top N] [--since REF] [--json]
9
+
10
+ import { execFileSync } from 'node:child_process';
11
+ import { createRequire } from 'node:module';
12
+ import { basename, join, dirname, resolve } from 'node:path';
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+
15
+ const HERE = dirname(fileURLToPath(import.meta.url));
16
+ const require = createRequire(import.meta.url);
17
+ const engineBin = (envVar, pkgSubpath, devPath) => {
18
+ if (process.env[envVar]) return process.env[envVar];
19
+ const bare = pkgSubpath.replace(/\/bin\/[^/]*$/, '');
20
+ for (const spec of [pkgSubpath, bare]) { try { return require.resolve(spec); } catch {} }
21
+ return devPath;
22
+ };
23
+ const LB = engineBin('LB_BIN', '@promptwheel/logbook/bin/logbook.mjs', join(HERE, '..', 'logbook', 'bin', 'logbook.mjs'));
24
+
25
+ const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
26
+ const repo = resolve(flag('--repo', process.cwd()));
27
+ const top = Math.max(1, parseInt(flag('--top', '12'), 10) || 12);
28
+ const since = flag('--since', null);
29
+ const failRaw = flag('--fail-on', null); // undocumented | test | any | <N>; bare --fail-on = undocumented
30
+ const failMode = failRaw === true ? 'undocumented' : (failRaw ? String(failRaw).toLowerCase() : null);
31
+ const asJson = process.argv.includes('--json');
32
+
33
+ // A disabled/skipped/weakened TEST is a hidden-failure risk; a lint/type/error suppression is
34
+ // usually mundane. Same split sawbuck's rank uses, so the two faces agree.
35
+ const isTestSkip = (m) => /\bit\.skip\b|\btest\.skip\b|xit\(|xdescribe\(|\bdescribe\.skip\b|pytest\.mark\.skip|unittest\.skip|\bt\.Skip\b|@Disabled\b|@Ignore\b|^\[Ignore\b|^Skip\s*=|^#\[ignore|markTestSkipped|markTestIncomplete/.test(String(m).trim());
36
+ // DOCUMENTED = a rule code or reason follows the directive (declared intent); BARE = just the
37
+ // directive. Heuristic, a lead not a verdict, but bare is the suspect kind worth scrutinizing.
38
+ const isDocumented = (c) => {
39
+ c = String(c || '');
40
+ if (/eslint-disable/.test(c)) return /eslint-disable(?:-next-line|-line)?\s+\S/.test(c); // a rule name follows
41
+ if (/@ts-ignore/.test(c)) return false; // inherently undocumented
42
+ if (/@ts-expect-error/.test(c)) return /@ts-expect-error\s+\S/.test(c);
43
+ if (/noqa/i.test(c)) return /noqa:\s*\w/i.test(c); // # noqa: E501
44
+ if (/type:\s*ignore/i.test(c)) return /ignore\s*\[/.test(c); // # type: ignore[code]
45
+ return /\(\s*["']/.test(c) || /=\s*["']/.test(c); // annotation reason/code: @Disabled("..."), Skip="...", #[ignore = "..."]
46
+ };
47
+
48
+ // events give fragility + fall-back dates. Best-effort.
49
+ let events = [];
50
+ try { events = execFileSync(process.execPath, [LB, 'query', '--json'], { cwd: repo, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }).split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch {}
51
+
52
+ let auditHead, SUPPRESS_PAT;
53
+ try { ({ auditHead, SUPPRESS_PAT } = await import(pathToFileURL(LB).href)); } catch (e) { console.error(`sawbuck debt: cannot load logbook (${String(e.message).split('\n')[0]})`); process.exit(1); }
54
+
55
+ let live;
56
+ try { live = auditHead(repo, events); } catch (e) { console.error(`sawbuck debt: could not read the suppression ledger (${String(e.message).split('\n')[0]})`); process.exit(1); }
57
+
58
+ // enrich each gated live suppression with its line content, so we can tell BARE from DOCUMENTED.
59
+ // One extra HEAD grep; we only read content for lines auditHead already gated, so no gating drift.
60
+ const grepAt = (ref) => { try { return execFileSync('git', ['-C', repo, 'grep', '-nP', SUPPRESS_PAT.source, ref], { encoding: 'utf8', maxBuffer: 512 * 1024 * 1024 }); } catch (e) { return (e.stdout || '').toString(); } };
61
+ const parseGrep = (g, ref) => { const re = new RegExp('^' + ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ':(.+?):(\\d+):(.*)$'); const m = []; for (const row of g.split('\n')) { const x = re.exec(row); if (x) m.push({ file: x[1], line: +x[2], content: x[3] }); } return m; };
62
+ const headRows = SUPPRESS_PAT ? parseGrep(grepAt('HEAD'), 'HEAD') : [];
63
+ const contentAt = new Map(headRows.map((r) => [r.file + ':' + r.line, r.content]));
64
+ for (const it of live) { it.content = contentAt.get(it.file + ':' + it.line) || ''; it.documented = it.content ? isDocumented(it.content) : false; }
65
+ const bare = live.filter((it) => !it.documented && it.content).length;
66
+
67
+ // --since REF: the DELTA. Suppressions in HEAD but not at REF = debt this branch/PR adds.
68
+ if (since && typeof since === 'string') {
69
+ const norm = (s) => String(s).trim().replace(/\s+/g, ' ');
70
+ const base = new Set(parseGrep(grepAt(since), since).map((r) => r.file + '\u0000' + norm(r.content)));
71
+ const added = live.filter((it) => it.content && !base.has(it.file + '\u0000' + norm(it.content)));
72
+ const skips = added.filter((it) => isTestSkip(it.kind)).length, undoc = added.filter((it) => !it.documented).length;
73
+ // the gate: fail (exit 1) when the branch adds the kind of debt --fail-on names.
74
+ const fail = !failMode ? false
75
+ : /^\d+$/.test(failMode) ? added.length >= +failMode
76
+ : failMode === 'any' ? added.length > 0
77
+ : (failMode === 'test' || failMode === 'tests' || failMode === 'disabled') ? skips > 0
78
+ : undoc > 0; // default: undocumented
79
+ if (asJson) { console.log(JSON.stringify({ since, added: added.length, testSkips: skips, undocumented: undoc, failOn: failMode, fail, items: added.map((it) => ({ file: it.file, line: it.line, kind: String(it.kind).trim(), documented: it.documented })) }, null, 2)); process.exit(fail ? 1 : 0); }
80
+ if (!added.length) { console.log(`🐴 sawbuck: no new suppression debt since ${since} (clean).`); process.exit(0); }
81
+ const o = [`🐴 sawbuck: ${added.length} new suppression${added.length === 1 ? '' : 's'} since ${since} · ${skips} disabled test${skips === 1 ? '' : 's'} · ${undoc} undocumented (no code/reason)`];
82
+ for (const it of added.slice(0, Math.max(top, 20))) o.push(` ${isTestSkip(it.kind) ? '🔴' : (it.documented ? ' ' : '✎ ')} ${it.file}:${it.line} ${String(it.kind).trim()}${it.documented ? '' : ' (bare - no code/reason)'}`);
83
+ o.push(`\n ✎ = bare/undocumented (the suspect kind); declare intent (a rule code or reason) or remove it.`);
84
+ if (failMode) o.push(fail ? `\n ❌ debt gate FAILED (--fail-on ${failMode}): this branch adds debt that must be declared or removed.` : `\n ✓ debt gate passed (--fail-on ${failMode}).`);
85
+ console.log(o.join('\n'));
86
+ process.exit(fail ? 1 : 0);
87
+ }
88
+
89
+ if (!live.length) { console.log(asJson ? '{"total":0,"testSkips":0,"codeSuppressions":0,"undocumented":0,"files":[]}' : '🐴 sawbuck: no live suppression debt in HEAD (nothing silenced in src/test/config). A clean tree.'); process.exit(0); }
90
+
91
+ // Fragility: debt on code that keeps breaking (reverts, fix-bursts) is the debt that matters; debt
92
+ // on stable code is mostly noise. One pass over the events builds per-file history; a file's debt
93
+ // severity is multiplied by its fragility. Same signals rank.mjs uses.
94
+ const DAY = 86400000;
95
+ const resolveRename = (f) => f.replace(/\{[^}]*=>\s*([^}]*)\}/g, '$1').replace(/\/{2,}/g, '/');
96
+ const histMap = new Map();
97
+ for (const e of events) {
98
+ const rv = !!e.revert, fx = !!e.fix;
99
+ if (!rv && !fx) continue;
100
+ for (const raw of (e.files || [])) {
101
+ const f = resolveRename(raw);
102
+ const h = histMap.get(f) || { reverts: 0, fixes: 0, fixDates: [] };
103
+ if (rv) h.reverts++;
104
+ if (fx) { h.fixes++; if (e.date) h.fixDates.push(Date.parse(e.date)); }
105
+ histMap.set(f, h);
106
+ }
107
+ }
108
+ const fragilityOf = (file) => {
109
+ const h = histMap.get(file) || { reverts: 0, fixes: 0, fixDates: [] };
110
+ const fd = [...h.fixDates].sort((a, b) => a - b);
111
+ let burst = 0;
112
+ for (let i = 0; i + 2 < fd.length; i++) if (fd[i + 2] - fd[i] <= 30 * DAY) { burst = fd.filter((x) => x >= fd[i] && x <= fd[i] + 30 * DAY).length; break; }
113
+ const hot = burst >= 3;
114
+ const mult = 1 + Math.min(3, h.reverts * 0.75 + (hot ? 1 : 0) + h.fixes * 0.1);
115
+ return { reverts: h.reverts, fixes: h.fixes, hot, mult };
116
+ };
117
+
118
+ const byFile = new Map();
119
+ let testSkips = 0, codeSup = 0;
120
+ for (const it of live) {
121
+ const t = isTestSkip(it.kind); t ? testSkips++ : codeSup++;
122
+ const f = byFile.get(it.file) || { file: it.file, skips: 0, bareSup: 0, docSup: 0, kinds: new Set(), oldest: null };
123
+ if (t) f.skips++; else (it.documented ? f.docSup++ : f.bareSup++);
124
+ f.kinds.add(String(it.kind).trim());
125
+ if (it.date && (!f.oldest || it.date < f.oldest)) f.oldest = it.date;
126
+ byFile.set(it.file, f);
127
+ }
128
+ // RISK = severity (disabled test 3, bare suppression 1.5, documented 1) x file fragility.
129
+ const files = [...byFile.values()].map((f) => {
130
+ const sup = f.bareSup + f.docSup;
131
+ const sev = f.skips * 3 + f.bareSup * 1.5 + f.docSup * 1;
132
+ const fr = fragilityOf(f.file);
133
+ return { file: f.file, n: f.skips + sup, skips: f.skips, sup, bareSup: f.bareSup, oldest: f.oldest, reverts: fr.reverts, fixes: fr.fixes, hot: fr.hot, risk: +(sev * fr.mult).toFixed(1) };
134
+ }).sort((a, b) => b.risk - a.risk || b.n - a.n);
135
+ const onFragile = files.filter((f) => f.reverts > 0 || f.hot).length;
136
+
137
+ if (asJson) {
138
+ console.log(JSON.stringify({ total: live.length, testSkips, codeSuppressions: codeSup, undocumented: bare, filesOnFragileCode: onFragile, files: files.map((f) => ({ file: f.file, live: f.n, testSkips: f.skips, suppressions: f.sup, undocumented: f.bareSup, reverts: f.reverts, hot: f.hot, risk: f.risk, oldest: f.oldest })) }, null, 2));
139
+ process.exit(0);
140
+ }
141
+
142
+ const out = [];
143
+ out.push(`🐴 sawbuck: live suppression debt in ${basename(repo)}`);
144
+ out.push(` ${live.length} still silenced in HEAD · ${testSkips} disabled/weakened test${testSkips === 1 ? '' : 's'} (hidden-failure risk) · ${codeSup} lint/type suppression${codeSup === 1 ? '' : 's'} · ${bare} undocumented (no code/reason)`);
145
+ if (onFragile) out.push(` ${onFragile} of ${files.length} debt-carrying files sit on fragile code (reverts/hotspots); look there first`);
146
+ out.push(`\n highest-risk debt (severity x how fragile the file is):`);
147
+ for (const f of files.slice(0, top)) {
148
+ const bits = [f.skips ? `${f.skips} test` : '', f.sup ? `${f.sup} lint/type${f.bareSup ? `, ${f.bareSup} bare` : ''}` : ''].filter(Boolean).join(' + ');
149
+ const frag = [f.reverts ? `${f.reverts} revert${f.reverts === 1 ? '' : 's'}` : '', f.hot ? 'fix-hotspot' : '', (!f.hot && f.fixes >= 3) ? `${f.fixes} fixes` : ''].filter(Boolean).join(', ');
150
+ out.push(` ${f.reverts || f.hot ? '🔥 ' : ' '}${f.file} · ${f.n} (${bits})${frag ? ` · ${frag}` : ''} · risk ${f.risk}`);
151
+ }
152
+ const dated = live.filter((it) => it.date).sort((a, b) => String(a.date).localeCompare(String(b.date)));
153
+ if (dated.length) {
154
+ out.push(`\n oldest still-silenced (most stale):`);
155
+ for (const it of dated.slice(0, 5)) out.push(` ${it.file}:${it.line} ${String(it.kind).trim()} (since ${it.date})`);
156
+ }
157
+ out.push(`\n bare = no code/reason, the suspect kind. leads, not verdicts; full dated ledger: logbook audit`);
158
+ console.log(out.join('\n'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Sign your agent to a contract so it does what you want: a deterministic, zero-LLM check that re-proves every win from the agent's source edits alone, so a green that came from editing the test is caught and reverted. A saddle for your coding harness.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "dispatch.mjs",
26
26
  "sawbuck-hook.mjs",
27
27
  "rank.mjs",
28
+ "debt.mjs",
28
29
  "run.mjs",
29
30
  "README.md",
30
31
  "GLOSSARY.md",
package/rank.mjs CHANGED
@@ -90,22 +90,57 @@ const normMult = (p, kind) => {
90
90
  return Math.max(0.4, Math.min(1, REF / b));
91
91
  };
92
92
 
93
+ // Classify a live suppression MARKER (disabled test = hidden-failure risk) and its line as
94
+ // DOCUMENTED (a rule code/reason follows) vs BARE (just the directive, the suspect kind). Same
95
+ // splits `sawbuck debt` uses, so the ambient surface and the ledger agree.
96
+ const isTestSkip = (m) => /\bit\.skip\b|\btest\.skip\b|xit\(|xdescribe\(|\bdescribe\.skip\b|pytest\.mark\.skip|unittest\.skip|\bt\.Skip\b|@Disabled\b|@Ignore\b|^\[Ignore\b|^Skip\s*=|^#\[ignore|markTestSkipped|markTestIncomplete/.test(String(m).trim());
97
+ const isDocumented = (c) => {
98
+ c = String(c || '');
99
+ if (/eslint-disable/.test(c)) return /eslint-disable(?:-next-line|-line)?\s+\S/.test(c);
100
+ if (/@ts-ignore/.test(c)) return false;
101
+ if (/@ts-expect-error/.test(c)) return /@ts-expect-error\s+\S/.test(c);
102
+ if (/noqa/i.test(c)) return /noqa:\s*\w/i.test(c);
103
+ if (/type:\s*ignore/i.test(c)) return /ignore\s*\[/.test(c);
104
+ return /\(\s*["']/.test(c) || /=\s*["']/.test(c);
105
+ };
106
+ // The "slider": debt rises on its own, but gently. A disabled test or an undocumented suppression
107
+ // on a touched file always surfaces; a file that is churning (hot / reverted) surfaces any debt;
108
+ // a stable file's routine documented suppressions stay quiet until they pile past DEBT_MIN. Tune
109
+ // with SAWBUCK_DEBT_MIN. This is how debt rises by itself in the per-turn context, no command run.
110
+ const DEBT_MIN = Math.max(1, +(process.env.SAWBUCK_DEBT_MIN) || 6);
111
+
93
112
  // Still-live filter (lead #4). A suppression on a path that is now CLEAN of suppressions is churn,
94
113
  // not current risk (the study: AutoMapper carries 4 live `[Ignore]` vs ~158/repo across C# history).
95
114
  // logbook's own SUPPRESS_PAT, git-grepped at HEAD but SCOPED to just the touched paths so the
96
115
  // per-turn hook stays cheap, tells us which still carry one. Imported so the pattern never drifts;
97
116
  // degrades to "treat all as live" if logbook cannot be imported. --raw turns it off.
98
117
  let liveSet = null;
118
+ const liveDebt = new Map(); // path -> { n, skips, bare }: live suppressions in HEAD, for ambient surfacing
99
119
  if (!raw && paths.length) {
100
120
  try {
101
- const { SUPPRESS_PAT } = await import(pathToFileURL(LB).href);
121
+ const { SUPPRESS_PAT, kindAllowedInFile, isMention } = await import(pathToFileURL(LB).href);
102
122
  if (SUPPRESS_PAT && SUPPRESS_PAT.source) {
123
+ // -nP (content), still scoped to the touched paths, so we can both weight AND surface the debt.
103
124
  let grep = '';
104
- try { grep = execFileSync('git', ['-C', repo, 'grep', '-lP', SUPPRESS_PAT.source, 'HEAD', '--', ...paths], { encoding: 'utf8' }); }
125
+ try { grep = execFileSync('git', ['-C', repo, 'grep', '-nP', SUPPRESS_PAT.source, 'HEAD', '--', ...paths], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); }
105
126
  catch (e) { grep = (e.stdout || '').toString(); } // git grep exits 1 when nothing matches
106
- liveSet = new Set(grep.split('\n').filter(Boolean).map((l) => l.replace(/^HEAD:/, '')));
127
+ liveSet = new Set();
128
+ for (const row of grep.split('\n')) {
129
+ const m = /^HEAD:(.+?):\d+:(.*)$/.exec(row); if (!m) continue;
130
+ const file = m[1], content = m[2];
131
+ const re = new RegExp(SUPPRESS_PAT.source, 'g'); let mm;
132
+ while ((mm = re.exec(content))) {
133
+ if (kindAllowedInFile && !kindAllowedInFile(mm[0], file)) continue; // gate exactly like audit
134
+ if (isMention && isMention(content, mm.index, mm[0])) continue; // a comment ABOUT it is not live
135
+ liveSet.add(file);
136
+ const d = liveDebt.get(file) || { n: 0, skips: 0, bare: 0 };
137
+ d.n++;
138
+ if (isTestSkip(mm[0])) d.skips++; else if (!isDocumented(content)) d.bare++;
139
+ liveDebt.set(file, d);
140
+ }
141
+ }
107
142
  }
108
- } catch { liveSet = null; } // logbook unavailable -> skip the filter, do not crash the read face
143
+ } catch { liveSet = null; liveDebt.clear(); } // logbook unavailable -> skip, do not crash the read face
109
144
  }
110
145
  // a suppression on a still-silenced path keeps its weight; on a now-clean path it is near-zero.
111
146
  const stillLive = (p) => (liveSet === null || liveSet.has(p) ? 1 : 0.25);
@@ -140,6 +175,20 @@ const ranked = paths.map((p) => {
140
175
  let burst = 0;
141
176
  for (let i = 0; i + 2 < fixDates.length; i++) if (fixDates[i + 2] - fixDates[i] <= 30 * DAY) { burst = fixDates.filter((x) => x >= fixDates[i] && x <= fixDates[i] + 30 * DAY).length; break; }
142
177
  if (burst >= 3) { score += 4; hits.unshift({ date: new Date(fixDates[fixDates.length - 1]).toISOString().slice(0, 10), kind: `HOT: ${burst} fixes in 30 days`, subject: 'this file was actively on fire, verify carefully', w: 99 }); }
178
+ // ambient debt: the live suppression debt on this file rises on its own when it is the suspect kind
179
+ // (a disabled test or an undocumented suppression), when the file is fragile (hot/reverted), or when
180
+ // it piles past the slider. Routine documented suppressions on stable files stay quiet. The score
181
+ // bump lets a file with ONLY debt (no history) surface at all; `sawbuck debt` is the full ledger.
182
+ const dbt = liveDebt.get(p);
183
+ if (dbt) {
184
+ const fragile = burst >= 3 || hits.some((h) => /revert/.test(h.kind || ''));
185
+ if (dbt.skips || dbt.bare || (fragile && dbt.n) || dbt.n >= DEBT_MIN) {
186
+ const extra = [dbt.skips ? `${dbt.skips} disabled test${dbt.skips === 1 ? '' : 's'}` : '', dbt.bare ? `${dbt.bare} undocumented` : ''].filter(Boolean).join(', ');
187
+ const w = dbt.skips ? 2.6 : (dbt.bare ? 2.2 : 1.4);
188
+ score += w;
189
+ hits.push({ kind: 'live debt here', subject: `${dbt.n} suppression${dbt.n === 1 ? '' : 's'} still silenced${extra ? ` (${extra})` : ''}; sawbuck debt for the ledger`, w });
190
+ }
191
+ }
143
192
  hits.sort((a, b) => b.w - a.w);
144
193
  return { path: p, score: +score.toFixed(2), top: hits.slice(0, 3), hot: burst >= 3 };
145
194
  }).filter((r) => r.score > 0).sort((a, b) => b.score - a.score).slice(0, top);
package/sawbuck.mjs CHANGED
@@ -85,6 +85,10 @@ Usage:
85
85
  sawbuck rank [--base R --head R] [--file P ...]
86
86
  Show the relevant past findings for the paths a change touches
87
87
  sawbuck context [repo] Ranked relevant history for your current working changes (the read face)
88
+ sawbuck debt [repo] [--since REF] [--fail-on undocumented|test|any|N]
89
+ The whole-repo live suppression debt: what is still silenced in HEAD now,
90
+ risk-ranked (severity x file fragility), undocumented flagged. --since REF
91
+ shows only the debt a branch adds; --fail-on exits nonzero for a CI gate
88
92
  sawbuck run --attempt '<cmd>' [--loops N]
89
93
  Thin harness: load context, run one attempt through the keep-if-verified gate
90
94
  sawbuck --help | --version
@@ -151,6 +155,12 @@ switch (cmd) {
151
155
  catch (e) { process.exit(e.status || 1); }
152
156
  break;
153
157
  }
158
+ case 'debt': {
159
+ // the whole-repo debt ledger: live suppressions still in HEAD, severity-split, worst-first.
160
+ try { execFileSync(process.execPath, [join(HERE, 'debt.mjs'), ...rest], { stdio: 'inherit' }); }
161
+ catch (e) { process.exit(e.status || 1); }
162
+ break;
163
+ }
154
164
  case 'context': {
155
165
  // the read face: ranked relevant history for what you're working on right now.
156
166
  const repo = repoArg();