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 +22 -0
- package/debt.mjs +158 -0
- package/package.json +2 -1
- package/rank.mjs +53 -4
- package/sawbuck.mjs +10 -0
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.
|
|
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', '-
|
|
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(
|
|
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
|
|
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();
|