sawbuck 0.1.9 → 0.1.10

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/rank.mjs +55 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
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
@@ -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;