sawbuck 0.1.9 → 0.1.11

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,6 +42,7 @@ 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
 
package/debt.mjs ADDED
@@ -0,0 +1,149 @@
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 asJson = process.argv.includes('--json');
30
+
31
+ // A disabled/skipped/weakened TEST is a hidden-failure risk; a lint/type/error suppression is
32
+ // usually mundane. Same split sawbuck's rank uses, so the two faces agree.
33
+ 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());
34
+ // DOCUMENTED = a rule code or reason follows the directive (declared intent); BARE = just the
35
+ // directive. Heuristic, a lead not a verdict, but bare is the suspect kind worth scrutinizing.
36
+ const isDocumented = (c) => {
37
+ c = String(c || '');
38
+ if (/eslint-disable/.test(c)) return /eslint-disable(?:-next-line|-line)?\s+\S/.test(c); // a rule name follows
39
+ if (/@ts-ignore/.test(c)) return false; // inherently undocumented
40
+ if (/@ts-expect-error/.test(c)) return /@ts-expect-error\s+\S/.test(c);
41
+ if (/noqa/i.test(c)) return /noqa:\s*\w/i.test(c); // # noqa: E501
42
+ if (/type:\s*ignore/i.test(c)) return /ignore\s*\[/.test(c); // # type: ignore[code]
43
+ return /\(\s*["']/.test(c) || /=\s*["']/.test(c); // annotation reason/code: @Disabled("..."), Skip="...", #[ignore = "..."]
44
+ };
45
+
46
+ // events give fragility + fall-back dates. Best-effort.
47
+ let events = [];
48
+ 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 {}
49
+
50
+ let auditHead, SUPPRESS_PAT;
51
+ 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); }
52
+
53
+ let live;
54
+ 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); }
55
+
56
+ // enrich each gated live suppression with its line content, so we can tell BARE from DOCUMENTED.
57
+ // One extra HEAD grep; we only read content for lines auditHead already gated, so no gating drift.
58
+ 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(); } };
59
+ 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; };
60
+ const headRows = SUPPRESS_PAT ? parseGrep(grepAt('HEAD'), 'HEAD') : [];
61
+ const contentAt = new Map(headRows.map((r) => [r.file + ':' + r.line, r.content]));
62
+ for (const it of live) { it.content = contentAt.get(it.file + ':' + it.line) || ''; it.documented = it.content ? isDocumented(it.content) : false; }
63
+ const bare = live.filter((it) => !it.documented && it.content).length;
64
+
65
+ // --since REF: the DELTA. Suppressions in HEAD but not at REF = debt this branch/PR adds.
66
+ if (since && typeof since === 'string') {
67
+ const norm = (s) => String(s).trim().replace(/\s+/g, ' ');
68
+ const base = new Set(parseGrep(grepAt(since), since).map((r) => r.file + '\u0000' + norm(r.content)));
69
+ const added = live.filter((it) => it.content && !base.has(it.file + '\u0000' + norm(it.content)));
70
+ if (asJson) { console.log(JSON.stringify({ since, added: added.length, testSkips: added.filter((it) => isTestSkip(it.kind)).length, undocumented: added.filter((it) => !it.documented).length, items: added.map((it) => ({ file: it.file, line: it.line, kind: String(it.kind).trim(), documented: it.documented })) }, null, 2)); process.exit(0); }
71
+ if (!added.length) { console.log(`🐴 sawbuck: no new suppression debt since ${since} (clean).`); process.exit(0); }
72
+ const skips = added.filter((it) => isTestSkip(it.kind)).length, undoc = added.filter((it) => !it.documented).length;
73
+ const o = [`🐴 sawbuck: ${added.length} new suppression${added.length === 1 ? '' : 's'} since ${since} · ${skips} disabled test${skips === 1 ? '' : 's'} · ${undoc} undocumented (no code/reason)`];
74
+ 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)'}`);
75
+ o.push(`\n ✎ = bare/undocumented (the suspect kind); declare intent (a rule code or reason) or remove it.`);
76
+ console.log(o.join('\n'));
77
+ process.exit(0);
78
+ }
79
+
80
+ 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); }
81
+
82
+ // Fragility: debt on code that keeps breaking (reverts, fix-bursts) is the debt that matters; debt
83
+ // on stable code is mostly noise. One pass over the events builds per-file history; a file's debt
84
+ // severity is multiplied by its fragility. Same signals rank.mjs uses.
85
+ const DAY = 86400000;
86
+ const resolveRename = (f) => f.replace(/\{[^}]*=>\s*([^}]*)\}/g, '$1').replace(/\/{2,}/g, '/');
87
+ const histMap = new Map();
88
+ for (const e of events) {
89
+ const rv = !!e.revert, fx = !!e.fix;
90
+ if (!rv && !fx) continue;
91
+ for (const raw of (e.files || [])) {
92
+ const f = resolveRename(raw);
93
+ const h = histMap.get(f) || { reverts: 0, fixes: 0, fixDates: [] };
94
+ if (rv) h.reverts++;
95
+ if (fx) { h.fixes++; if (e.date) h.fixDates.push(Date.parse(e.date)); }
96
+ histMap.set(f, h);
97
+ }
98
+ }
99
+ const fragilityOf = (file) => {
100
+ const h = histMap.get(file) || { reverts: 0, fixes: 0, fixDates: [] };
101
+ const fd = [...h.fixDates].sort((a, b) => a - b);
102
+ let burst = 0;
103
+ 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; }
104
+ const hot = burst >= 3;
105
+ const mult = 1 + Math.min(3, h.reverts * 0.75 + (hot ? 1 : 0) + h.fixes * 0.1);
106
+ return { reverts: h.reverts, fixes: h.fixes, hot, mult };
107
+ };
108
+
109
+ const byFile = new Map();
110
+ let testSkips = 0, codeSup = 0;
111
+ for (const it of live) {
112
+ const t = isTestSkip(it.kind); t ? testSkips++ : codeSup++;
113
+ const f = byFile.get(it.file) || { file: it.file, skips: 0, bareSup: 0, docSup: 0, kinds: new Set(), oldest: null };
114
+ if (t) f.skips++; else (it.documented ? f.docSup++ : f.bareSup++);
115
+ f.kinds.add(String(it.kind).trim());
116
+ if (it.date && (!f.oldest || it.date < f.oldest)) f.oldest = it.date;
117
+ byFile.set(it.file, f);
118
+ }
119
+ // RISK = severity (disabled test 3, bare suppression 1.5, documented 1) x file fragility.
120
+ const files = [...byFile.values()].map((f) => {
121
+ const sup = f.bareSup + f.docSup;
122
+ const sev = f.skips * 3 + f.bareSup * 1.5 + f.docSup * 1;
123
+ const fr = fragilityOf(f.file);
124
+ 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) };
125
+ }).sort((a, b) => b.risk - a.risk || b.n - a.n);
126
+ const onFragile = files.filter((f) => f.reverts > 0 || f.hot).length;
127
+
128
+ if (asJson) {
129
+ 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));
130
+ process.exit(0);
131
+ }
132
+
133
+ const out = [];
134
+ out.push(`🐴 sawbuck: live suppression debt in ${basename(repo)}`);
135
+ 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)`);
136
+ if (onFragile) out.push(` ${onFragile} of ${files.length} debt-carrying files sit on fragile code (reverts/hotspots); look there first`);
137
+ out.push(`\n highest-risk debt (severity x how fragile the file is):`);
138
+ for (const f of files.slice(0, top)) {
139
+ const bits = [f.skips ? `${f.skips} test` : '', f.sup ? `${f.sup} lint/type${f.bareSup ? `, ${f.bareSup} bare` : ''}` : ''].filter(Boolean).join(' + ');
140
+ 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(', ');
141
+ out.push(` ${f.reverts || f.hot ? '🔥 ' : ' '}${f.file} · ${f.n} (${bits})${frag ? ` · ${frag}` : ''} · risk ${f.risk}`);
142
+ }
143
+ const dated = live.filter((it) => it.date).sort((a, b) => String(a.date).localeCompare(String(b.date)));
144
+ if (dated.length) {
145
+ out.push(`\n oldest still-silenced (most stale):`);
146
+ for (const it of dated.slice(0, 5)) out.push(` ${it.file}:${it.line} ${String(it.kind).trim()} (since ${it.date})`);
147
+ }
148
+ out.push(`\n bare = no code/reason, the suspect kind. leads, not verdicts; full dated ledger: logbook audit`);
149
+ console.log(out.join('\n'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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
@@ -14,7 +14,7 @@
14
14
  import { execFileSync } from 'node:child_process';
15
15
  import { createRequire } from 'node:module';
16
16
  import { join, dirname } from 'node:path';
17
- import { fileURLToPath } from 'node:url';
17
+ import { fileURLToPath, pathToFileURL } from 'node:url';
18
18
 
19
19
  const HERE = dirname(fileURLToPath(import.meta.url));
20
20
  const require = createRequire(import.meta.url);
@@ -61,6 +61,55 @@ const codeish = (p) => /\.(m?[jt]sx?|cjs|py|go|rs|rb|java|kt|c|cc|cpp|h|hpp|php|
61
61
  // eslint-disable). The relevance lead: sharpen the signal, don't flatten it.
62
62
  const isTest = (p) => /(^|\/)(tests?|__tests__|specs?)\//i.test(p) || /[._-](test|spec)\.[a-z0-9]+$/i.test(p) || /(^|\/)test_[^/]+\.py$/i.test(p);
63
63
 
64
+ // Per-language suppression normalization. The 100-repo study (STUDY-100.md) found a suppression's
65
+ // raw count is an idiom artifact, not a risk level: a `# noqa` is background noise in Python (141
66
+ // markers/repo) but a `t.Skip` is notable in Go. So a hit is discounted by how ubiquitous its
67
+ // idiom is in the FILE's language. Discount-only [0.4, 1]: common idioms shrink, rare ones stay at
68
+ // full base weight, and the ordering floor holds (revert 3 > test-skip 2.5 > suppression 2).
69
+ // Reverts are idiom-independent and never normalized. --raw turns this off.
70
+ const raw = process.argv.includes('--raw');
71
+ const langOf = (p) => {
72
+ const e = (p.match(/\.([a-z0-9]+)$/i) || [, ''])[1].toLowerCase();
73
+ if (/^tsx?$/.test(e)) return 'ts';
74
+ if (/^(m?jsx?|cjs)$/.test(e)) return 'js';
75
+ if (['py', 'go', 'rs', 'php', 'phtml'].includes(e)) return e === 'phtml' ? 'php' : e;
76
+ if (['java', 'kt', 'kts', 'scala', 'groovy'].includes(e)) return 'java';
77
+ if (['cs', 'fs', 'vb'].includes(e)) return 'cs';
78
+ return 'other';
79
+ };
80
+ // baseline = markers/repo for that language+kind, from the study's per-language table.
81
+ const BASELINE = {
82
+ py: { code: 141, skip: 4.6 }, ts: { code: 89, skip: 19.1 }, js: { code: 37, skip: 10.5 },
83
+ go: { code: 1.6, skip: 21.6 }, java: { code: 0.5, skip: 13.9 }, rs: { code: 0.3, skip: 4.1 },
84
+ cs: { code: 0.5, skip: 158 }, php: { code: 0.5, skip: 15 }, other: { code: 20, skip: 10 },
85
+ };
86
+ const REF = 15; // a hit at or below ~15 markers/repo for its language+kind keeps full weight
87
+ const normMult = (p, kind) => {
88
+ if (raw) return 1;
89
+ const b = Math.max((BASELINE[langOf(p)] || BASELINE.other)[kind], 0.5);
90
+ return Math.max(0.4, Math.min(1, REF / b));
91
+ };
92
+
93
+ // Still-live filter (lead #4). A suppression on a path that is now CLEAN of suppressions is churn,
94
+ // not current risk (the study: AutoMapper carries 4 live `[Ignore]` vs ~158/repo across C# history).
95
+ // logbook's own SUPPRESS_PAT, git-grepped at HEAD but SCOPED to just the touched paths so the
96
+ // per-turn hook stays cheap, tells us which still carry one. Imported so the pattern never drifts;
97
+ // degrades to "treat all as live" if logbook cannot be imported. --raw turns it off.
98
+ let liveSet = null;
99
+ if (!raw && paths.length) {
100
+ try {
101
+ const { SUPPRESS_PAT } = await import(pathToFileURL(LB).href);
102
+ if (SUPPRESS_PAT && SUPPRESS_PAT.source) {
103
+ let grep = '';
104
+ try { grep = execFileSync('git', ['-C', repo, 'grep', '-lP', SUPPRESS_PAT.source, 'HEAD', '--', ...paths], { encoding: 'utf8' }); }
105
+ 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:/, '')));
107
+ }
108
+ } catch { liveSet = null; } // logbook unavailable -> skip the filter, do not crash the read face
109
+ }
110
+ // a suppression on a still-silenced path keeps its weight; on a now-clean path it is near-zero.
111
+ const stillLive = (p) => (liveSet === null || liveSet.has(p) ? 1 : 0.25);
112
+
64
113
  const DAY = 86400000;
65
114
  const ranked = paths.map((p) => {
66
115
  let score = 0; const hits = []; const fixDates = [];
@@ -72,8 +121,11 @@ const ranked = paths.map((p) => {
72
121
  // "weakened" only on a NET assertion drop (logbook's own rule: del > add + 2),
73
122
  // a rewrite that removes some asserts but adds more is not a weakening.
74
123
  else if (codeish(p) && ((e.suppressions && e.suppressions.length) || ((e.del_asserts || 0) > (e.add_asserts || 0) + 2) || e.downgrades > 0)) {
75
- if (isTest(p)) { w = 2.5; kind = 'test disabled / weakened'; }
76
- else { w = 2; kind = 'suppression in source'; }
124
+ const hasSupp = e.suppressions && e.suppressions.length;
125
+ const tst = isTest(p);
126
+ const m = hasSupp ? normMult(p, tst ? 'skip' : 'code') * stillLive(p) : 1; // normalize by language x still-live; leave a pure assertion-drop at full weight
127
+ if (tst) { w = 2.5 * m; kind = 'test disabled / weakened'; }
128
+ else { w = 2 * m; kind = 'suppression in source'; }
77
129
  }
78
130
  else if (e.fix) { w = 1.2; kind = 'bugfix here'; }
79
131
  else continue;
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]
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/PR adds vs REF
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();