sawbuck 0.1.11 โ†’ 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
@@ -48,6 +48,27 @@ sawbuck: gamed recorded on test.js (the win came from editing the test, no
48
48
 
49
49
  Each repo needs a `promptwheel.config.json` saying what to measure (your tests, lint, coverage, or an eval).
50
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
+
51
72
  ## The engines
52
73
 
53
74
  Sawbuck is thin glue over two tools it depends on:
package/debt.mjs CHANGED
@@ -26,6 +26,8 @@ const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (pro
26
26
  const repo = resolve(flag('--repo', process.cwd()));
27
27
  const top = Math.max(1, parseInt(flag('--top', '12'), 10) || 12);
28
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);
29
31
  const asJson = process.argv.includes('--json');
30
32
 
31
33
  // A disabled/skipped/weakened TEST is a hidden-failure risk; a lint/type/error suppression is
@@ -67,14 +69,21 @@ if (since && typeof since === 'string') {
67
69
  const norm = (s) => String(s).trim().replace(/\s+/g, ' ');
68
70
  const base = new Set(parseGrep(grepAt(since), since).map((r) => r.file + '\u0000' + norm(r.content)));
69
71
  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
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); }
73
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)`];
74
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)'}`);
75
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}).`);
76
85
  console.log(o.join('\n'));
77
- process.exit(0);
86
+ process.exit(fail ? 1 : 0);
78
87
  }
79
88
 
80
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); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.11",
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",
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,10 +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]
88
+ sawbuck debt [repo] [--since REF] [--fail-on undocumented|test|any|N]
89
89
  The whole-repo live suppression debt: what is still silenced in HEAD now,
90
90
  risk-ranked (severity x file fragility), undocumented flagged. --since REF
91
- shows only the debt a branch/PR adds vs REF
91
+ shows only the debt a branch adds; --fail-on exits nonzero for a CI gate
92
92
  sawbuck run --attempt '<cmd>' [--loops N]
93
93
  Thin harness: load context, run one attempt through the keep-if-verified gate
94
94
  sawbuck --help | --version