@ericrisco/rsc 4.0.3 → 4.0.4
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/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/lib/review-ledger.js +16 -0
- package/scripts/lib/risk.js +10 -4
- package/scripts/rsc.js +14 -3
- package/skills/asdd/SKILL.md +1 -1
- package/skills/implement/SKILL.md +1 -1
- package/skills/review/SKILL.md +1 -1
- package/skills/ship/SKILL.md +1 -0
- package/targets/asdd-gate.mjs +47 -2
- package/targets/sello.mjs +92 -1
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -122,6 +122,22 @@ export function round(root, task, { now = new Date().toISOString() } = {}) {
|
|
|
122
122
|
return { ok: true, round: n, message: `task "${task}": review round ${n} of ${MAX_ROUNDS}` };
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// Records that a review panel ran on a task: which lenses, distinct. Self-attested, like the sello's
|
|
126
|
+
// approve — it binds the claim to a task, it does not prove the review was good. The ship guard reads
|
|
127
|
+
// it: a tier-2 branch does not leave the machine without a panel of the required size on its spec.
|
|
128
|
+
export function panel(root, task, lenses, { now = new Date().toISOString() } = {}) {
|
|
129
|
+
if (!QUALIFIED_TASK.test(task)) {
|
|
130
|
+
return { ok: false, message: `rsc: qualify the task with its spec slug, e.g. \`review ledger panel <slug>/closing --lenses correctness,security,tests\`.` };
|
|
131
|
+
}
|
|
132
|
+
const list = [...new Set(String(lenses || '').split(',').map((l) => l.trim().toLowerCase()).filter(Boolean))];
|
|
133
|
+
if (!list.length) return { ok: false, message: 'rsc: name the lenses that ran: --lenses correctness,security,tests' };
|
|
134
|
+
// Bound to the commit reviewed: code committed after it is code no panel has seen.
|
|
135
|
+
const r = spawnSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', windowsHide: true });
|
|
136
|
+
const sha = r.status === 0 ? r.stdout.trim() : null;
|
|
137
|
+
append(root, [{ type: 'panel', task, lenses: list, sha, at: now }]);
|
|
138
|
+
return { ok: true, message: `task "${task}": panel of ${list.length} recorded (${list.join(', ')})${sha ? ` on ${sha.slice(0, 7)}` : ''}` };
|
|
139
|
+
}
|
|
140
|
+
|
|
125
141
|
export function stats(root) {
|
|
126
142
|
const ledger = readLedger(root);
|
|
127
143
|
const findings = ledger.filter((e) => e.type === 'finding');
|
package/scripts/lib/risk.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Spec: 02-DOCS/wiki/sdd/specs/asdd-v4.md.
|
|
7
7
|
|
|
8
8
|
import { spawnSync } from 'node:child_process';
|
|
9
|
-
import { NESTED_WORKTREES, SELLO_STATE_PATHS,
|
|
9
|
+
import { NESTED_WORKTREES, SELLO_STATE_PATHS, classifyChange, lensesRequired, readEffectiveConfig, trunkRef } from '../../targets/sello.mjs';
|
|
10
10
|
|
|
11
11
|
function git(root, args) {
|
|
12
12
|
const r = spawnSync('git', ['-C', root, '-c', 'core.quotePath=false', ...args], { encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
|
|
@@ -31,18 +31,22 @@ export function changedPaths(root, base = null) {
|
|
|
31
31
|
.filter((n) => !SELLO_STATE_PATHS.includes(n)));
|
|
32
32
|
let names = diff(mb);
|
|
33
33
|
let shown = ref;
|
|
34
|
+
let from = mb;
|
|
34
35
|
// Working straight on the trunk (FTD on an open main), the merge-base IS HEAD, and a change
|
|
35
36
|
// already committed would read as no change at all: tier 0, no reviewer, no escalation. With
|
|
36
37
|
// nothing uncommitted, the last commit is the change being asked about.
|
|
37
38
|
if (!base && mb === head.trim() && !names.size && git(root, ['rev-parse', '--verify', '--quiet', 'HEAD~1']) !== null) {
|
|
38
39
|
names = diff('HEAD~1');
|
|
39
40
|
shown = 'HEAD~1 (on the trunk: the last commit)';
|
|
41
|
+
from = 'HEAD~1';
|
|
40
42
|
}
|
|
41
|
-
return { ok: true, base: shown, paths: [...names].sort() };
|
|
43
|
+
return { ok: true, base: shown, from, paths: [...names].sort() };
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
// `texts` is what the change adds per path (sello.changeText): content signals raise a path that its
|
|
47
|
+
// name alone would leave at tier 1 — auth code in `src/app.js`.
|
|
48
|
+
export function assess(paths, cfg = null, texts = {}) {
|
|
49
|
+
const { tier, classes, configError, content } = classifyChange(paths, texts, cfg);
|
|
46
50
|
return {
|
|
47
51
|
tier,
|
|
48
52
|
classes: [...new Set(Object.values(classes))].sort(),
|
|
@@ -51,6 +55,7 @@ export function assess(paths, cfg = null) {
|
|
|
51
55
|
// Tier 2 is ASDD work, whatever lane it started in: the ship guard refuses to push it without a spec.
|
|
52
56
|
escalate: tier >= 2 ? 'asdd' : null,
|
|
53
57
|
paths: classes,
|
|
58
|
+
content,
|
|
54
59
|
...(configError ? { configError } : {}),
|
|
55
60
|
};
|
|
56
61
|
}
|
|
@@ -65,6 +70,7 @@ export function render(r) {
|
|
|
65
70
|
...(r.base ? [`base: ${r.base}`] : []),
|
|
66
71
|
...(r.escalate ? ['escalate: asdd — tier 2 needs a spec linked to this branch (`branch: <name>` in its frontmatter) that passes `npx @ericrisco/rsc spec gate`; push and PR are refused until it exists. Write it now, before more code.'] : []),
|
|
67
72
|
...(r.configError ? [`config ignored: ${r.configError}`] : []),
|
|
73
|
+
...(r.content || []).slice(0, 10).map((h) => ` content: ${h.class} in ${h.path} ("${h.match}")`),
|
|
68
74
|
...top.slice(0, 20).map(([p, c]) => ` ${c.padEnd(10)} ${p}`),
|
|
69
75
|
...(top.length > 20 ? [` … ${top.length - 20} more`] : []),
|
|
70
76
|
].join('\n');
|
package/scripts/rsc.js
CHANGED
|
@@ -1262,13 +1262,17 @@ async function main() {
|
|
|
1262
1262
|
.map((p) => rel(root, res(process.cwd(), p)).split('\\').join('/'));
|
|
1263
1263
|
let paths = given;
|
|
1264
1264
|
let against = null;
|
|
1265
|
+
let from = null;
|
|
1265
1266
|
if (!given.length) {
|
|
1266
1267
|
const c = K.changedPaths(root, base || null);
|
|
1267
1268
|
if (!c.ok) { say(c.message); process.exitCode = 1; return; }
|
|
1268
1269
|
paths = c.paths;
|
|
1269
1270
|
against = c.base;
|
|
1271
|
+
from = c.from;
|
|
1270
1272
|
}
|
|
1271
|
-
|
|
1273
|
+
// Given paths are read whole (a forecast or a single file); a diff contributes its added lines.
|
|
1274
|
+
const texts = S.changeText(root, from, paths);
|
|
1275
|
+
const r = { ...K.assess(paths, S.readEffectiveConfig(root), texts), ...(against ? { base: against } : {}) };
|
|
1272
1276
|
say(argv.includes('--json') ? JSON.stringify(r, null, 2) : K.render(r));
|
|
1273
1277
|
return;
|
|
1274
1278
|
}
|
|
@@ -1279,7 +1283,7 @@ async function main() {
|
|
|
1279
1283
|
const USE = [
|
|
1280
1284
|
'Use: npx @ericrisco/rsc review consolidate [report.md|findings.json ...] [--json] [--window <lines>]',
|
|
1281
1285
|
' Without files it reads stdin. Each lens report carries its findings in ```json findings blocks.',
|
|
1282
|
-
' npx @ericrisco/rsc review ledger record <findings.json> [--task <id>] | outcome <id> <fixed|false|intentional|irrelevant|parked> | round <slug>/<task> | stats [--json]',
|
|
1286
|
+
' npx @ericrisco/rsc review ledger record <findings.json> [--task <id>] | outcome <id> <fixed|false|intentional|irrelevant|parked> | round <slug>/<task> | panel <slug>/<task> --lenses a,b,c | stats [--json]',
|
|
1283
1287
|
' npx @ericrisco/rsc review escapes [--since <ref>] [--json]',
|
|
1284
1288
|
].join('\n');
|
|
1285
1289
|
if (argv[1] === 'ledger' || argv[1] === 'escapes') {
|
|
@@ -1328,6 +1332,13 @@ async function main() {
|
|
|
1328
1332
|
if (!r.ok) process.exitCode = 1;
|
|
1329
1333
|
return;
|
|
1330
1334
|
}
|
|
1335
|
+
if (sub === 'panel') {
|
|
1336
|
+
if (!a || a.startsWith('--')) return fail(USE);
|
|
1337
|
+
const r = L.panel(root, a, str('lenses'));
|
|
1338
|
+
if (!r.ok) return fail(r.message);
|
|
1339
|
+
say(r.message);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1331
1342
|
if (sub === 'stats') {
|
|
1332
1343
|
const s = L.stats(root);
|
|
1333
1344
|
say(json ? JSON.stringify(s, null, 2) : L.renderStats(s));
|
|
@@ -1532,7 +1543,7 @@ async function main() {
|
|
|
1532
1543
|
if (!candidate) { say(S.MESSAGES.noTrunk()); process.exitCode = 1; return; }
|
|
1533
1544
|
const paths2 = Object.keys(candidate.files);
|
|
1534
1545
|
if (!paths2.length) { say('sello: nothing to freeze — the working tree matches the trunk base.'); return; }
|
|
1535
|
-
const risk = S.
|
|
1546
|
+
const risk = S.classifyCandidate(root, candidate, cfg);
|
|
1536
1547
|
if (risk.configError) say(`⚠️ ${risk.configError}\n (using the default risk table until it is fixed)`);
|
|
1537
1548
|
const required = S.lensesRequired(risk.tier, cfg);
|
|
1538
1549
|
S.writeSello(root, { status: 'frozen', ...candidate, risk, required, lenses: [], createdAt: new Date().toISOString() });
|
package/skills/asdd/SKILL.md
CHANGED
|
@@ -51,7 +51,7 @@ Every spec answers four questions, and `spec:gate` refuses one dated 2026-11-03
|
|
|
51
51
|
|
|
52
52
|
### The risk decides, never the agent
|
|
53
53
|
|
|
54
|
-
`npx @ericrisco/rsc risk [--base <ref>] [paths…]` classifies a change by **what it touches** (the sello's path table; works with the sello off): tier 0 docs/copy → no reviewer; tier 1 ordinary code → one fresh-context reviewer; tier 2 auth, billing, CI workflows, migrations, secrets, harness config → the three-refuter panel **and human approval**. An agent's approval never counts as a human's.
|
|
54
|
+
`npx @ericrisco/rsc risk [--base <ref>] [paths…]` classifies a change by **what it touches and what it adds** (the sello's path table plus fixed content patterns — password hashing, session cookies, payments, schema changes, secrets — so auth code in `src/app.js` is still auth; works with the sello off): tier 0 docs/copy → no reviewer; tier 1 ordinary code → one fresh-context reviewer; tier 2 auth, billing, CI workflows, migrations, secrets, harness config → the three-refuter panel **and human approval**. An agent's approval never counts as a human's. **Enforced at push:** a tier-2 branch leaves the machine only with a spec linked to it that passes `rsc spec gate` **and** a panel recorded with `rsc review ledger panel <slug>/<task> --lenses correctness,security,tests`.
|
|
55
55
|
|
|
56
56
|
### The loop converges
|
|
57
57
|
|
|
@@ -115,7 +115,7 @@ review of the task, this first one included. Round 1 is the task's first review,
|
|
|
115
115
|
| --- | --- |
|
|
116
116
|
| 0 | none — the controls (e) still run |
|
|
117
117
|
| 1 | one fresh-context reviewer; frozen brief in `references/per-task-review.md` |
|
|
118
|
-
| 2 | the three refuters (`refuter-correctness` · `refuter-security` · `refuter-tests`) in parallel, as `../review/SKILL.md` runs them |
|
|
118
|
+
| 2 | the three refuters (`refuter-correctness` · `refuter-security` · `refuter-tests`) in parallel, as `../review/SKILL.md` runs them; then `review ledger panel <slug>/<task> --lenses correctness,security,tests` — the push gate checks it |
|
|
119
119
|
|
|
120
120
|
Every reviewer gets the **spec**, the **diff as a file** (`scripts/review-package <BASE> <HEAD>`, BASE
|
|
121
121
|
= the task's *recorded* base sha, never `HEAD~1`), the **surrounding code** at that sha (not just the
|
package/skills/review/SKILL.md
CHANGED
|
@@ -11,7 +11,7 @@ origin: risco
|
|
|
11
11
|
|
|
12
12
|
Tests prove the code works, not that it does what was asked. Review answers that other question: **is the diff correct, well-scoped and faithful to the spec — or did it pass for the wrong reasons?** In ASDD (`../asdd/SKILL.md`) it runs in two modes:
|
|
13
13
|
|
|
14
|
-
- **Per task** — called from `implement`'s loop after each task's self-check. Depth comes from `npx @ericrisco/rsc risk [--base <ref>] [paths…]` (same path table as the sello, works with the sello off): **tier 0** → no review; **tier 1** → one fresh-context reviewer (a subagent that never saw the implementation conversation); **tier 2** → the three-refuter panel below, and the branch will wait for human approval at ship.
|
|
14
|
+
- **Per task** — called from `implement`'s loop after each task's self-check. Depth comes from `npx @ericrisco/rsc risk [--base <ref>] [paths…]` (same path table as the sello, works with the sello off): **tier 0** → no review; **tier 1** → one fresh-context reviewer (a subagent that never saw the implementation conversation); **tier 2** → the three-refuter panel below, recorded after it runs with `npx @ericrisco/rsc review ledger panel <slug>/<task> --lenses correctness,security,tests` (the ship guard refuses to push a tier-2 branch without it — one reviewer is not a panel), and the branch will wait for human approval at ship. The tier reads the diff's content too: auth code in a generic file is tier 2.
|
|
15
15
|
- **Closing review** — once, before `ship`: the whole branch against the spec. Forty individually-correct tasks can still fail to be the feature together: a criterion nobody owned, two tasks that each assumed the other handled an edge, scope that crept in a slice at a time. Read the spec's acceptance criteria and "must respect" list against `git diff <base>...HEAD`, not the task reviews. Its record — tier, counts, verdict, each finding with its outcome — goes to `02-DOCS/wiki/sdd/reviews/<slug>.md`.
|
|
16
16
|
|
|
17
17
|
This skill owns **two roles that share one discipline**:
|
package/skills/ship/SKILL.md
CHANGED
|
@@ -246,6 +246,7 @@ In the `orient` voice: the explainer, the checklist result in one line, the thre
|
|
|
246
246
|
| "I'll add `Co-Authored-By: Claude` / a 'Generated with Claude Code' footer to be transparent" | It forges the record. The work is the responsible person's — no AI trailer, ever. Strip it. |
|
|
247
247
|
| "Review didn't formally approve but it's obviously fine" | No verdict = not ready. Ship runs on APPROVE only. Route back to review. |
|
|
248
248
|
| "Tier 2, but the three refuters approved — I'll self-merge" | An agent's approval never counts as a human's. Tier 2 waits for human approval. |
|
|
249
|
+
| "Tier 2, one reviewer was enough here" | The push gate wants the panel recorded (`review ledger panel … --lenses correctness,security,tests`). Run the three, record them, then push. |
|
|
249
250
|
| "The tree has a couple of stray edits, they're harmless" | A dirty tree means the merge is not the reviewed diff. Clean it or stash it first. |
|
|
250
251
|
| "`main` is protected but I'll just force-merge, I'm sure" | Protected means PR. Don't bypass the gate the repo deliberately set. |
|
|
251
252
|
| "No remote and the guard denies the merge — I'll merge another way" | The denial is the answer. Park the branch with the merge command for the human. |
|
package/targets/asdd-gate.mjs
CHANGED
|
@@ -53,6 +53,37 @@ export function linkedSpecs(root, branch, specCompleteness) {
|
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// A panel still covers HEAD when its commit is an ancestor of HEAD and this branch's own commits since
|
|
57
|
+
// changed no code. Not code, by the same rules the gate applies above: documentation, the harness's
|
|
58
|
+
// own state (.rsc/ journals, nested worktrees) and harness configuration. Commits that came from the
|
|
59
|
+
// trunk (a merge of main into the branch) are not this branch's work and are not counted.
|
|
60
|
+
function reviewedStill(root, sha, trunk, sello, cfg) {
|
|
61
|
+
if (!sha) return false;
|
|
62
|
+
if (spawnSync('git', ['-C', root, 'merge-base', '--is-ancestor', sha, 'HEAD'], { windowsHide: true }).status !== 0) return false;
|
|
63
|
+
const since = [...new Set((git(root, 'log', '--no-merges', '--no-renames', '--format=', '--name-only', `${sha}..HEAD`, `^${trunk}`) || '')
|
|
64
|
+
.split('\n').map((l) => l.trim()).filter(Boolean))]
|
|
65
|
+
.filter((n) => !sello.SELLO_STATE_PATHS.includes(n) && !sello.NESTED_WORKTREES.test(n));
|
|
66
|
+
const { classes } = sello.classifyRisk(since, cfg);
|
|
67
|
+
return Object.values(classes).every((c) => c === 'docs' || c === 'harness');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Largest panel recorded in the review ledger for a task of any of these specs (`<slug>/<task>`) that
|
|
71
|
+
// still covers HEAD.
|
|
72
|
+
function panelSize(root, slugs, stillValid = () => true) {
|
|
73
|
+
let text = '';
|
|
74
|
+
try { text = readFileSync(join(root, '.rsc', 'review-ledger.jsonl'), 'utf8'); } catch { return 0; }
|
|
75
|
+
let best = 0;
|
|
76
|
+
for (const line of text.split('\n')) {
|
|
77
|
+
let e;
|
|
78
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
79
|
+
if (e?.type !== 'panel' || typeof e.task !== 'string' || !Array.isArray(e.lenses)) continue;
|
|
80
|
+
if (!slugs.some((s) => e.task.toLowerCase().replace(/_/g, '-').startsWith(`${s.toLowerCase().replace(/_/g, '-')}/`))) continue;
|
|
81
|
+
if (!stillValid(e.sha)) continue;
|
|
82
|
+
best = Math.max(best, new Set(e.lenses.map((l) => String(l).toLowerCase())).size);
|
|
83
|
+
}
|
|
84
|
+
return best;
|
|
85
|
+
}
|
|
86
|
+
|
|
56
87
|
/**
|
|
57
88
|
* null when the command may go ahead; the denial text (with its way out, P6) when it may not.
|
|
58
89
|
* `lib` carries the sibling modules so tests and the hook load them the same way.
|
|
@@ -71,12 +102,26 @@ export function asddGate(root, command, { sello, specCompleteness }) {
|
|
|
71
102
|
...(git(root, 'diff', '--name-only', '--no-renames', '-z', base) || '').split('\0'),
|
|
72
103
|
...(git(root, 'ls-files', '--others', '--exclude-standard', '-z') || '').split('\0').filter((n) => !sello.NESTED_WORKTREES.test(n)),
|
|
73
104
|
].filter(Boolean))].filter((n) => !sello.SELLO_STATE_PATHS.includes(n));
|
|
74
|
-
const { tier, classes } = sello.
|
|
105
|
+
const { tier, classes } = sello.classifyChange(paths, sello.changeText(root, base, paths), cfg);
|
|
75
106
|
if (tier < 2) return null;
|
|
76
107
|
const tierTwo = [...new Set(Object.values(classes))].filter((c) => c !== 'default' && c !== 'docs');
|
|
77
108
|
if (tierTwo.length && tierTwo.every((c) => c === 'harness')) return null;
|
|
78
109
|
const specs = linkedSpecs(root, branch, specCompleteness);
|
|
79
|
-
|
|
110
|
+
const passing = specs.filter((s) => s.ok);
|
|
111
|
+
if (passing.length) {
|
|
112
|
+
// The spec exists; now the review it requires. A tier-2 change gets the full panel (three lenses
|
|
113
|
+
// by default), recorded against one of its linked specs in the review ledger.
|
|
114
|
+
const required = sello.lensesRequired(2, cfg);
|
|
115
|
+
const slugs = passing.map((s) => s.path.split('/').pop().slice(0, -3));
|
|
116
|
+
const best = panelSize(root, slugs, (sha) => reviewedStill(root, sha, trunk, sello, cfg));
|
|
117
|
+
if (best >= required) return null;
|
|
118
|
+
return `Branch "${branch}" is risk tier 2 and its spec passes, but no review panel of ${required} lens(es) is recorded for it` +
|
|
119
|
+
`${best ? ` (largest still valid: ${best})` : ''}. Tier 2 is reviewed by the full panel, not by one reviewer, and a panel covers ` +
|
|
120
|
+
'the commit it was recorded on: code committed after it needs the panel again (the record is self-attested, like the sello). ' +
|
|
121
|
+
`Recover: run the ${required} fresh-context refuters (correctness, security, tests) on the branch, then record it — ` +
|
|
122
|
+
`\`npx @ericrisco/rsc review ledger panel ${slugs[0]}/closing --lenses correctness,security,tests\` — and push again. ` +
|
|
123
|
+
'To turn this gate off for the project: create `.rsc/.no-asdd-gate`.';
|
|
124
|
+
}
|
|
80
125
|
const why = [...new Set(Object.values(classes).filter((c) => c !== 'default' && c !== 'docs'))].join(', ');
|
|
81
126
|
const found = specs.length
|
|
82
127
|
? `The linked spec does not pass the gate: ${specs.map((s) => `${s.path} (missing: ${[...s.missing, ...s.empty].join(', ') || '—'})`).join('; ')}. `
|
package/targets/sello.mjs
CHANGED
|
@@ -202,6 +202,97 @@ export function classifyRisk(paths, cfg = null) {
|
|
|
202
202
|
return { tier, classes, lowered, configError };
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// ---------------------------------------------------------------- content signals
|
|
206
|
+
// A path table only sees code that lives where its name says. A real 4.0.3 run put a whole login —
|
|
207
|
+
// password hashing, session cookies — into `src/app.js`: tier 1 by path, so no gate and no panel would
|
|
208
|
+
// have fired had the spec not happened to be called `login.md`. These rows read WHAT the change adds:
|
|
209
|
+
// code that hashes passwords, sets session cookies, moves money, alters a schema or carries a secret
|
|
210
|
+
// is high risk wherever it lives. Still deterministic (P1): fixed patterns over the added lines, never
|
|
211
|
+
// a judgement. Documentation is not scanned — a spec that talks about passwords is not auth code.
|
|
212
|
+
export const CONTENT_RISK = [
|
|
213
|
+
// Operations, not words: hashing or verifying a password, issuing a session or token. A login form's
|
|
214
|
+
// `type="password"` or an i18n string is not auth code (closing review, 4.0.4).
|
|
215
|
+
{ class: 'auth', scope: 'code', pattern: String.raw`\b(?:bcrypt(?:js)?|argon2(?:id|i|d)?)\.(?:hash|compare|verify)(?:Sync)?\s*\(|\b(?:scrypt|scryptSync|pbkdf2|pbkdf2Sync|timingSafeEqual|createHmac)\s*\(|\bjwt\.(?:sign|verify)\s*\(|\b(?:password_hash|password_verify)\s*\(|\bpassport\.(?:use|authenticate)\s*\(|\b(?:getServerSession|verifyIdToken|signInWith[A-Z]\w*)\s*\(|\bres\.cookie\s*\(\s*(?:['"\x60](?:sid|session|token|auth)|[A-Z_]*(?:SESSION|SID|TOKEN|AUTH))|\bpromisify\s*\(\s*(?:crypto\.)?(?:scrypt|pbkdf2)\b|\b(?:jwtVerify|SignJWT)\b|\breq\.session\.(?:\w+\s*=[^=]|regenerate\s*\()|\bcreateSession\s*\(|set-cookie['"\x60]?\s*[,:]\s*['"\x60]?\s*\x60?(?:sid|session|token|auth)\w*=` },
|
|
216
|
+
{ class: 'billing', scope: 'code', pattern: String.raw`\b(?:paymentIntents|charges|refunds|subscriptions|checkout\.sessions|invoices)\.(?:create|update|confirm|capture)\s*\(|\bprocess\.env\.(?:STRIPE|PAYPAL|BRAINTREE|ADYEN)_\w+` },
|
|
217
|
+
{ class: 'migrations', scope: 'code', pattern: String.raw`\b(?:ALTER\s+TABLE|DROP\s+(?:TABLE|COLUMN)|CREATE\s+TABLE|ADD\s+COLUMN|RENAME\s+COLUMN)\b` },
|
|
218
|
+
// A secret is a leak wherever it is added — tests, config and docs included.
|
|
219
|
+
{ class: 'secrets', scope: 'all', pattern: String.raw`-----BEGIN [A-Z ]*PRIVATE KEY-----|\bAKIA[0-9A-Z]{16}\b|\b[sr]k_live_[0-9a-zA-Z]{16,}|\bgh[pousr]_[0-9A-Za-z]{30,}` },
|
|
220
|
+
];
|
|
221
|
+
// What `code` rows never read: tests and fixtures, lockfiles, minified bundles, docs, and comment lines.
|
|
222
|
+
const NOT_CODE = /(?:^|\/)(?:tests?|__tests__|spec|fixtures?|__mocks__)\/|\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock|Gemfile\.lock|composer\.lock|go\.sum)$|\.min\.(?:js|css)$/;
|
|
223
|
+
const COMMENT_LINE = /^\s*(?:\/\/|#|\*|\/\*|<!--|--\s)/;
|
|
224
|
+
const CONTENT_MAX_BYTES = 1024 * 1024;
|
|
225
|
+
|
|
226
|
+
// The text a change ADDS, per path: added lines of the diff against `base` for tracked files, the
|
|
227
|
+
// whole file for untracked ones (or for every path when there is no base — an explicit `rsc risk
|
|
228
|
+
// <paths>`). One whole-tree diff, filtered after: a pathspec per file overflows the argument list on a
|
|
229
|
+
// large change, and a large change must not get LESS scrutiny than a small one. Unreadable or huge
|
|
230
|
+
// files are skipped: the path row still scores them.
|
|
231
|
+
export function changeText(rawRoot, base, paths) {
|
|
232
|
+
const root = resolveRoot(rawRoot);
|
|
233
|
+
const out = {};
|
|
234
|
+
const wanted = new Set(paths);
|
|
235
|
+
const readWhole = (p) => {
|
|
236
|
+
try {
|
|
237
|
+
const st = lstatSync(join(root, p));
|
|
238
|
+
if (st.isFile() && st.size <= CONTENT_MAX_BYTES) out[p] = readFileSync(join(root, p), 'utf8');
|
|
239
|
+
} catch { /* gone or unreadable */ }
|
|
240
|
+
};
|
|
241
|
+
if (!base) { paths.forEach(readWhole); return out; }
|
|
242
|
+
const tracked = new Set(nulList(git(root, 'ls-files', '-z')));
|
|
243
|
+
const diff = git(root, 'diff', '-U0', '--no-renames', '--no-color', base) || '';
|
|
244
|
+
let cur = null;
|
|
245
|
+
let header = false;
|
|
246
|
+
for (const line of diff.split('\n')) {
|
|
247
|
+
if (line.startsWith('diff --git ')) { header = true; cur = null; continue; }
|
|
248
|
+
if (header) {
|
|
249
|
+
// git quotes a path with a tab, newline or quote even under core.quotePath=false: "b/tab\tx.js".
|
|
250
|
+
const q = /^\+\+\+ "b\/((?:[^"\\]|\\.)*)"$/.exec(line);
|
|
251
|
+
const m = q ? [null, q[1].replace(/\\([tn"\\])/g, (_, c) => ({ t: '\t', n: '\n', '"': '"', '\\': '\\' })[c])]
|
|
252
|
+
: /^\+\+\+ b\/(.+?)\t?$/.exec(line);
|
|
253
|
+
if (m && wanted.has(m[1])) { cur = m[1]; out[cur] ||= ''; }
|
|
254
|
+
if (line.startsWith('@@')) header = false;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (line.startsWith('@@')) continue;
|
|
258
|
+
if (cur && line.startsWith('+')) out[cur] += `${line.slice(1)}\n`;
|
|
259
|
+
}
|
|
260
|
+
paths.filter((p) => !tracked.has(p)).forEach(readWhole);
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Path rows and content rows together. A path scores the higher of the two; `content` lists each
|
|
265
|
+
// content hit (path, class, the matched text) so the reader can see why. Lowered classes apply to
|
|
266
|
+
// content rows too; floor classes stay at tier 2.
|
|
267
|
+
export function classifyChange(paths, texts = {}, cfg = null) {
|
|
268
|
+
const byPath = classifyRisk(paths, cfg);
|
|
269
|
+
const lowered = byPath.lowered || [];
|
|
270
|
+
const content = [];
|
|
271
|
+
let { tier } = byPath;
|
|
272
|
+
const classes = { ...byPath.classes };
|
|
273
|
+
const rowTier = (cls) => (lowered.includes(cls) && !FLOOR_CLASSES.includes(cls) ? 1 : 2);
|
|
274
|
+
const docs = new RegExp(RISK_TABLE.find((r) => r.class === 'docs').pattern);
|
|
275
|
+
for (const [p, text] of Object.entries(texts)) {
|
|
276
|
+
if (!text) continue;
|
|
277
|
+
const codeText = docs.test(p) || NOT_CODE.test(p) ? '' : text.split('\n').filter((l) => !COMMENT_LINE.test(l)).join('\n');
|
|
278
|
+
for (const row of CONTENT_RISK) {
|
|
279
|
+
const m = new RegExp(row.pattern, row.scope === 'all' ? '' : 'i').exec(row.scope === 'all' ? text : codeText);
|
|
280
|
+
if (!m) continue;
|
|
281
|
+
const t = rowTier(row.class);
|
|
282
|
+
content.push({ path: p, class: row.class, match: m[0].slice(0, 60) });
|
|
283
|
+
if (t > classifyRisk([p], cfg).tier) classes[p] = row.class;
|
|
284
|
+
if (t > tier) tier = t;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return { ...byPath, tier, classes, content };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function classifyCandidate(rawRoot, candidate, cfg = null) {
|
|
292
|
+
const paths = Object.keys(candidate.files);
|
|
293
|
+
return classifyChange(paths, changeText(rawRoot, candidate.base, paths), cfg);
|
|
294
|
+
}
|
|
295
|
+
|
|
205
296
|
// How many lenses a tier requires (capped by an explicit config knob, floor 1).
|
|
206
297
|
export function lensesRequired(tier, cfg = null) {
|
|
207
298
|
const base = tier === 0 ? 0 : tier === 1 ? 1 : 3;
|
|
@@ -418,7 +509,7 @@ export function checkSello(rawRoot) {
|
|
|
418
509
|
const paths = Object.keys(candidate.files);
|
|
419
510
|
if (paths.length === 0) return { ok: true, code: 'clean' };
|
|
420
511
|
|
|
421
|
-
const risk =
|
|
512
|
+
const risk = classifyCandidate(root, candidate, cfg);
|
|
422
513
|
// P7 first, before any sello state is consulted: a docs-only change must never
|
|
423
514
|
// be blocked by a leftover sello from some other change.
|
|
424
515
|
if (risk.tier === 0) return { ok: true, code: 'risk0' };
|