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 +21 -0
- package/debt.mjs +12 -3
- package/package.json +1 -1
- package/rank.mjs +53 -4
- package/sawbuck.mjs +2 -2
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.
|
|
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', '-
|
|
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,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
|
|
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
|