@golden-frijoles/kit 0.12.1 → 0.14.0
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/dist/jev-eval.mjs +85 -8
- package/dist/lib/config-registry.mjs +10 -0
- package/dist/lib/config.mjs +1 -0
- package/dist/lib/cross-agent-cli.mjs +2 -2
- package/dist/lib/jev.mjs +21 -2
- package/dist/semantic-lint.mjs +450 -0
- package/dist/skeleton/Roadmap/LEARNINGS.md +6 -3
- package/dist/skeleton/Roadmap/WAYS-OF-WORKING.md +4 -2
- package/package.json +1 -1
package/dist/jev-eval.mjs
CHANGED
|
@@ -27,15 +27,26 @@
|
|
|
27
27
|
// wording of intent-match's questions on labelled items, and there is no regex to compare it with, no mode and no
|
|
28
28
|
// threshold. Its report adds how many answers were DECIDED (P ≤ 0.2 or ≥ 0.8) and how many of those were right.
|
|
29
29
|
//
|
|
30
|
+
// The `lint` rail (semantic-lint D8) is reported the same way — its selector picks candidates but has no verdict to
|
|
31
|
+
// compare with — and "decided" means raised or cleared at the rail's own threshold. Its question is project data, so a
|
|
32
|
+
// recording pins the wording's hash, and the fixtures live in the project's own jev-eval.lint.fixtures.json.
|
|
33
|
+
//
|
|
30
34
|
// Zero deps — Node 18+.
|
|
31
35
|
|
|
32
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
36
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
33
37
|
import { dirname, join, resolve } from 'node:path';
|
|
34
38
|
import { fileURLToPath } from 'node:url';
|
|
39
|
+
import { readSection } from './lib/config.mjs';
|
|
35
40
|
import { loadJevConfig, parseJevConfig, RAILS, readApiKey, repoRoot } from './lib/jev.mjs';
|
|
36
41
|
|
|
37
42
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
38
43
|
export const FIXTURES_PATH = join(__dirname, 'jev-eval.fixtures.json');
|
|
44
|
+
/**
|
|
45
|
+
* The `lint` set's fixtures are PROJECT data, like the rules they measure (semantic-lint D8, fresh review of #200):
|
|
46
|
+
* this repo's rule-1 labels shipped in the shared file would fail any consumer whose own rule reused the id, and fail
|
|
47
|
+
* every consumer with a rule but no such fixtures. So they live beside the script in a file the template never has.
|
|
48
|
+
*/
|
|
49
|
+
export const LINT_FIXTURES_PATH = join(__dirname, 'jev-eval.lint.fixtures.json');
|
|
39
50
|
|
|
40
51
|
/** Shadow is a short, expiring measurement: at most this many days out, ever. */
|
|
41
52
|
export const MAX_SHADOW_DAYS = 21;
|
|
@@ -74,6 +85,11 @@ export function expiredShadowRails(config, today) {
|
|
|
74
85
|
export function coverageFailures(fixtures, rails) {
|
|
75
86
|
const out = [];
|
|
76
87
|
for (const name of EVAL_SETS) {
|
|
88
|
+
// A set that knows its own coverage rule (lint: per configured rule) answers for itself.
|
|
89
|
+
if (rails[name]?.coverage) {
|
|
90
|
+
out.push(...rails[name].coverage(fixtures[name] ?? []));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
77
93
|
const n = (fixtures[name] ?? []).length;
|
|
78
94
|
if (!rails[name] && n)
|
|
79
95
|
out.push(`${name}: ${n} fixture(s) but no judge to replay them — was the judge renamed?`);
|
|
@@ -132,7 +148,7 @@ const sortedCodes = (findings) =>
|
|
|
132
148
|
* The rails this harness can evaluate. Each judge is looked up by name so a rail whose judge has not
|
|
133
149
|
* landed yet is SKIPPED loudly instead of failing the import.
|
|
134
150
|
*/
|
|
135
|
-
export async function loadRails() {
|
|
151
|
+
export async function loadRails({ lintRules = [] } = {}) {
|
|
136
152
|
const rails = {};
|
|
137
153
|
const review = await import('./lib/review-guard.mjs');
|
|
138
154
|
if (typeof review.judgeReviewOutput === 'function')
|
|
@@ -161,6 +177,43 @@ export async function loadRails() {
|
|
|
161
177
|
expected: (fx) => fx.label,
|
|
162
178
|
summary: (d) => ({ value: d.value, p: d.p, decider: d.decider }),
|
|
163
179
|
};
|
|
180
|
+
// semantic-lint D8. The QUESTION is the project's own data (`lint.rules`), so a fixture is replayed against the
|
|
181
|
+
// rule this project has; one whose rule it lacks (the template's own run) is skipped and counted, never scored.
|
|
182
|
+
const lint = await import('./semantic-lint.mjs');
|
|
183
|
+
if (typeof lint.judgeCandidate === 'function') {
|
|
184
|
+
const byId = new Map(lintRules.map((r) => [r.id, r]));
|
|
185
|
+
rails.lint = {
|
|
186
|
+
// Never replayed (there is no question to replay it against) — and `coverage` below fails it, so a renamed or
|
|
187
|
+
// removed rule cannot go green by skipping every fixture it had (fresh review of #200).
|
|
188
|
+
skip: (fx) => (byId.has(fx.rule) ? null : `no rule "${fx.rule}" in this project's lint config`),
|
|
189
|
+
coverage: (cases) => [
|
|
190
|
+
...[...new Set(cases.filter((fx) => !byId.has(fx.rule)).map((fx) => fx.rule))].map(
|
|
191
|
+
(id) => `lint: fixtures for "${id}", which this project's lint config does not define — renamed or removed?`
|
|
192
|
+
),
|
|
193
|
+
...[...byId.keys()]
|
|
194
|
+
.map((id) => [id, cases.filter((fx) => fx.rule === id).length])
|
|
195
|
+
.filter(([, n]) => n < MIN_FIXTURES)
|
|
196
|
+
.map(([id, n]) => `lint/${id}: only ${n} labelled fixture(s); a rule needs ≥${MIN_FIXTURES}`),
|
|
197
|
+
],
|
|
198
|
+
// A recording answers the wording that produced it: an edited question replaying green would prove nothing.
|
|
199
|
+
stale: (fx) =>
|
|
200
|
+
fx.recorded?.questionHash === lint.questionHash(byId.get(fx.rule))
|
|
201
|
+
? null
|
|
202
|
+
: `recorded against another wording of ${fx.rule}'s question — run --live`,
|
|
203
|
+
recordExtra: (fx) => ({ questionHash: lint.questionHash(byId.get(fx.rule)) }),
|
|
204
|
+
run: (fx, deps) =>
|
|
205
|
+
lint.judgeCandidate(
|
|
206
|
+
{ rule: byId.get(fx.rule), file: fx.file, hunk: fx.hunk },
|
|
207
|
+
{ ask: deps.ask, threshold: lint.thresholdFor(deps.config.rails.lint, fx.rule) }
|
|
208
|
+
),
|
|
209
|
+
regex: null, // the selector only picks candidates; it has no verdict of its own to compare with
|
|
210
|
+
predicted: (d) => (d.outcome === 'raise' ? true : d.outcome === 'clear' ? false : null),
|
|
211
|
+
expected: (fx) => fx.label,
|
|
212
|
+
// Decided = raised or cleared at THIS rail's threshold, not intent's fixed margin.
|
|
213
|
+
decided: (d) => d.outcome === 'raise' || d.outcome === 'clear',
|
|
214
|
+
summary: (d) => ({ outcome: d.outcome, p: d.p }),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
164
217
|
return rails;
|
|
165
218
|
}
|
|
166
219
|
|
|
@@ -215,6 +268,13 @@ export async function evaluate({
|
|
|
215
268
|
const tally = { n: cases.length, jevRight: 0, regexRight: 0, disagreements: 0, families: {} };
|
|
216
269
|
if (!rail.regex) Object.assign(tally, { regexRight: null, disagreements: null, decided: 0, decidedRight: 0 });
|
|
217
270
|
for (const fx of cases) {
|
|
271
|
+
const skip = rail.skip?.(fx);
|
|
272
|
+
if (skip) {
|
|
273
|
+
tally.n--;
|
|
274
|
+
tally.skipped = (tally.skipped ?? 0) + 1;
|
|
275
|
+
tally.skipWhy = skip;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
218
278
|
const sink = { answers: {}, model: null, errors: [] };
|
|
219
279
|
const deps = {
|
|
220
280
|
config: cfg,
|
|
@@ -230,6 +290,11 @@ export async function evaluate({
|
|
|
230
290
|
);
|
|
231
291
|
continue;
|
|
232
292
|
}
|
|
293
|
+
const stale = live ? null : rail.stale?.(fx);
|
|
294
|
+
if (stale) {
|
|
295
|
+
failures.push(`${name}/${fx.id}: ${stale}`);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
233
298
|
const decision = await rail.run(fx, deps);
|
|
234
299
|
const summary = rail.summary(decision);
|
|
235
300
|
if (live && sink.errors.length) {
|
|
@@ -242,7 +307,7 @@ export async function evaluate({
|
|
|
242
307
|
if (live) {
|
|
243
308
|
// A draft the judge needed no answers for (e.g. a heading-only unit, which is never asked about) is
|
|
244
309
|
// recorded as answered by the pinned model with no answers — replaying it asks nothing.
|
|
245
|
-
fx.recorded = { model: sink.model ?? cfg.model, answers: sink.answers };
|
|
310
|
+
fx.recorded = { model: sink.model ?? cfg.model, answers: sink.answers, ...(rail.recordExtra?.(fx) ?? {}) };
|
|
246
311
|
fx.decision = summary;
|
|
247
312
|
} else if (!same(summary, fx.decision)) {
|
|
248
313
|
failures.push(
|
|
@@ -253,7 +318,10 @@ export async function evaluate({
|
|
|
253
318
|
const jevRight = same(rail.predicted(decision), expected);
|
|
254
319
|
tally.jevRight += jevRight;
|
|
255
320
|
if (!rail.regex) {
|
|
256
|
-
|
|
321
|
+
const decided = rail.decided
|
|
322
|
+
? rail.decided(decision)
|
|
323
|
+
: typeof decision.p === 'number' && Math.abs(decision.p - 0.5) >= DECIDED_MARGIN;
|
|
324
|
+
if (decided) {
|
|
257
325
|
tally.decided++;
|
|
258
326
|
tally.decidedRight += jevRight;
|
|
259
327
|
}
|
|
@@ -283,7 +351,8 @@ export function formatReport(report) {
|
|
|
283
351
|
for (const [rail, t] of Object.entries(report)) {
|
|
284
352
|
if (t.regexRight === null) {
|
|
285
353
|
lines.push(
|
|
286
|
-
`${rail}: ${t.n} labelled · jev ${pct(t.jevRight, t.n)} · decided ${t.decided}/${t.n}, ${t.decidedRight} right · no deterministic rule`
|
|
354
|
+
`${rail}: ${t.n} labelled · jev ${pct(t.jevRight, t.n)} · decided ${t.decided}/${t.n}, ${t.decidedRight} right · no deterministic rule` +
|
|
355
|
+
(t.skipped ? ` · ${t.skipped} skipped (${t.skipWhy})` : '')
|
|
287
356
|
);
|
|
288
357
|
continue;
|
|
289
358
|
}
|
|
@@ -378,14 +447,22 @@ export async function run(argv, io = {}) {
|
|
|
378
447
|
async function main() {
|
|
379
448
|
const root = repoRoot();
|
|
380
449
|
const { askJev } = await import('./lib/jev.mjs');
|
|
450
|
+
const { parseLintRules } = await import('./semantic-lint.mjs');
|
|
381
451
|
const code = await run(process.argv.slice(2), {
|
|
382
452
|
root,
|
|
383
453
|
config: loadJevConfig({ root }),
|
|
384
|
-
fixtures:
|
|
385
|
-
|
|
454
|
+
fixtures: {
|
|
455
|
+
...JSON.parse(readFileSync(FIXTURES_PATH, 'utf8')),
|
|
456
|
+
lint: existsSync(LINT_FIXTURES_PATH) ? JSON.parse(readFileSync(LINT_FIXTURES_PATH, 'utf8')) : [],
|
|
457
|
+
},
|
|
458
|
+
// A malformed lint section throws here: CI must go red on it, not replay against no rules.
|
|
459
|
+
rails: await loadRails({ lintRules: parseLintRules(readSection('lint', { root }).raw) }),
|
|
386
460
|
key: () => readApiKey({ root }),
|
|
387
461
|
makeAsk: ({ key, model }) => (req) => askJev(req, { key, model }),
|
|
388
|
-
writeFixtures: (
|
|
462
|
+
writeFixtures: ({ lint, ...shared }) => {
|
|
463
|
+
writeFileSync(FIXTURES_PATH, `${JSON.stringify(shared, null, 2)}\n`);
|
|
464
|
+
if (lint.length) writeFileSync(LINT_FIXTURES_PATH, `${JSON.stringify(lint, null, 2)}\n`);
|
|
465
|
+
},
|
|
389
466
|
stdout: (t) => process.stdout.write(t),
|
|
390
467
|
stderr: (t) => process.stderr.write(t),
|
|
391
468
|
today: new Date().toISOString().slice(0, 10),
|
|
@@ -152,6 +152,16 @@ export const REGISTRY = Object.freeze([
|
|
|
152
152
|
choices: ['off', 'on'],
|
|
153
153
|
question: 'At the architecture lock, ask one other model family (codex, agy or vibe) to read the pitch and score whether it would build the same thing? Off by default; any failure is skipped.',
|
|
154
154
|
},
|
|
155
|
+
{
|
|
156
|
+
// semantic-lint D4: the rules the lint rail's selectors run (id, globs, allowlist, patterns, question). never-yet,
|
|
157
|
+
// like `routines`: it declares the section so `config get/set` accept it, and `gf doctor` (which skips never-yet
|
|
158
|
+
// rows and prints one line per MODULE) never reports Build unconfigured because a project has no lint rules.
|
|
159
|
+
key: 'lint.rules',
|
|
160
|
+
module: 'Build',
|
|
161
|
+
askWhen: 'never-yet',
|
|
162
|
+
default: null,
|
|
163
|
+
question: 'Which rules should semantic-lint check on every push (selectors plus one question each for Jev)?',
|
|
164
|
+
},
|
|
155
165
|
{
|
|
156
166
|
key: 'spend.telemetry',
|
|
157
167
|
module: 'Spend',
|
package/dist/lib/config.mjs
CHANGED
|
@@ -53,10 +53,10 @@ export const AGENT_BIN = {
|
|
|
53
53
|
// Harmless here since AGY_MODEL/AGY_FALLBACK_MODEL below are always valid, listed model names (checked via
|
|
54
54
|
// `agy models`), but it means a future typo in either constant would silently review with the WRONG model
|
|
55
55
|
// instead of failing loud — watch for that if either constant is ever edited.
|
|
56
|
-
// agy-doctor: last verified 2026-09-
|
|
56
|
+
// agy-doctor: last verified 2026-09-30 against 1.2.14.
|
|
57
57
|
// ^ machine-managed marker — `node scripts/cross-agent-doctor.mjs agy --fix` rewrites it (with the constant
|
|
58
58
|
// below) after a green live contract probe. Don't hand-edit the marker's shape.
|
|
59
|
-
export const AGY_PINNED = '1.2.
|
|
59
|
+
export const AGY_PINNED = '1.2.14';
|
|
60
60
|
|
|
61
61
|
// agy's `--print` mode prints NOTHING unless `--model` names a model — and, crucially, it ALSO prints
|
|
62
62
|
// nothing (exit 0, empty stdout — the error lands only in agy's log, see --log-file) when the model is
|
package/dist/lib/jev.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { needSetting, readSection } from './config.mjs';
|
|
|
34
34
|
export const JEV_ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
|
|
35
35
|
export const DEFAULT_MODEL = 'jev-1.13.0';
|
|
36
36
|
export const MODES = ['off', 'shadow', 'jev'];
|
|
37
|
-
export const RAILS = ['review', 'prose'];
|
|
37
|
+
export const RAILS = ['review', 'prose', 'lint'];
|
|
38
38
|
|
|
39
39
|
/** 32k tokens covers state + the longest question. ~4 chars/token, with headroom for the question. */
|
|
40
40
|
export const STATE_CHAR_BUDGET = 110_000;
|
|
@@ -53,11 +53,19 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|
|
53
53
|
// thresholds are the MEASURED ones (jev-semantic-guards S5.2, sprint-5.md), not the pitch's starting guesses.
|
|
54
54
|
review: { mode: 'off', thresholds: { real: 0.85, notReal: 0.3 }, shadowExpires: null },
|
|
55
55
|
prose: { mode: 'off', thresholds: { claim: 0.8 }, shadowExpires: null },
|
|
56
|
+
// semantic-lint D3: `default` plus one optional key per rule id (see LINT_THRESHOLD_KEY below).
|
|
57
|
+
lint: { mode: 'off', thresholds: { default: 0.8 }, shadowExpires: null },
|
|
56
58
|
},
|
|
57
59
|
});
|
|
58
60
|
|
|
59
61
|
export class JevConfigError extends Error {}
|
|
60
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The lint rail's threshold keys are OPEN — `default` or any rule id — because rules are data (semantic-lint D3/D4):
|
|
65
|
+
* a new rule must not need a code change here to get its own threshold. Every other rail's keys stay a closed set.
|
|
66
|
+
*/
|
|
67
|
+
export const LINT_THRESHOLD_KEY = /^[a-z0-9][a-z0-9-]*$/;
|
|
68
|
+
|
|
61
69
|
const isUnit = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1;
|
|
62
70
|
|
|
63
71
|
/**
|
|
@@ -105,10 +113,21 @@ export function parseJevConfig(json) {
|
|
|
105
113
|
throw new JevConfigError(`jev.config.json: rails.${name}.mode must be one of ${MODES.join(' | ')}`);
|
|
106
114
|
if (r.thresholds !== undefined && !isObj(r.thresholds))
|
|
107
115
|
throw new JevConfigError(`jev.config.json: rails.${name}.thresholds must be an object`);
|
|
108
|
-
|
|
116
|
+
if (name === 'lint') {
|
|
117
|
+
const bad = Object.keys(r.thresholds ?? {}).filter((k) => !LINT_THRESHOLD_KEY.test(k));
|
|
118
|
+
if (bad.length)
|
|
119
|
+
throw new JevConfigError(
|
|
120
|
+
`jev.config.json: rails.lint.thresholds: ${bad.join(', ')} is not \`default\` or a rule id (a-z, 0-9, -)`
|
|
121
|
+
);
|
|
122
|
+
} else refuseUnknown(r.thresholds ?? {}, Object.keys(d.thresholds), `rails.${name}.thresholds`);
|
|
109
123
|
const thresholds = { ...d.thresholds, ...(r.thresholds ?? {}) };
|
|
110
124
|
for (const [k, v] of Object.entries(thresholds))
|
|
111
125
|
if (!isUnit(v)) throw new JevConfigError(`jev.config.json: rails.${name}.thresholds.${k} must be 0…1`);
|
|
126
|
+
// lint raises at p ≥ t and clears at p ≤ 1 − t (semantic-lint D2): below 0.5 those bands overlap, and one
|
|
127
|
+
// answer would be both a finding and a pass.
|
|
128
|
+
if (name === 'lint')
|
|
129
|
+
for (const [k, v] of Object.entries(thresholds))
|
|
130
|
+
if (v < 0.5) throw new JevConfigError(`jev.config.json: rails.lint.thresholds.${k} must be 0.5…1`);
|
|
112
131
|
if (name === 'review' && thresholds.notReal >= thresholds.real)
|
|
113
132
|
throw new JevConfigError('jev.config.json: rails.review.thresholds.notReal must be below .real');
|
|
114
133
|
const shadowExpires = r.shadowExpires ?? null;
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// semantic-lint.mjs — deterministic selectors pick candidate hunks, Jev judges only those (semantic-lint D1–D9).
|
|
3
|
+
//
|
|
4
|
+
// node scripts/semantic-lint.mjs the branch against @{upstream}, else origin/main
|
|
5
|
+
// node scripts/semantic-lint.mjs --range <base>...<head> what the pre-push hook passes, once per pushed ref
|
|
6
|
+
//
|
|
7
|
+
// Some rules cannot be a regex: "no parallel telemetry pipeline" (AGENTS rule 1) is broken by a usage table under
|
|
8
|
+
// another name, an analytics route or a vendor SDK call — paraphrases a regex either misses or flags on every read.
|
|
9
|
+
// So the regex only SELECTS (globs + added-line patterns − an allowlist, all data in `lint.rules`), and one Noul per
|
|
10
|
+
// selected hunk asks whether it actually breaks the rule. Most pushes select nothing and make no call at all.
|
|
11
|
+
//
|
|
12
|
+
// Four outcomes, never two (D2): raise, clear, uncertain, and NOT CHECKED — no key, egress off, a timeout, a malformed
|
|
13
|
+
// answer, an oversized hunk, past the candidate cap or the time budget. "Could not look" is printed and logged, and is
|
|
14
|
+
// never counted as clear.
|
|
15
|
+
//
|
|
16
|
+
// Raise-only: this adds findings; it never clears another check's finding, and it never blocks. v1 runs in `shadow`
|
|
17
|
+
// from the advisory pre-push hook: it logs every decision to .jev/decisions.jsonl as `lint:<rule id>` and prints one
|
|
18
|
+
// line per rule. Exit 0 always, except 2 for bad usage or a malformed config — a lint that cannot run must say so, and
|
|
19
|
+
// the hook still ignores the code.
|
|
20
|
+
//
|
|
21
|
+
// Zero deps — Node 18+.
|
|
22
|
+
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
24
|
+
import { resolve } from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { readSection } from './lib/config.mjs';
|
|
27
|
+
import { jevContext, LINT_THRESHOLD_KEY, loadJevConfig, repoRoot, textHash } from './lib/jev.mjs';
|
|
28
|
+
import { globToRegExp } from './lib/review-guard.mjs';
|
|
29
|
+
|
|
30
|
+
export const QUESTION_ID = 'violates';
|
|
31
|
+
export const SEVERITIES = ['blocking', 'should-fix', 'nit'];
|
|
32
|
+
/** A hunk this long is not sent (D2/D6): it is rarely one decision, and it is the most code to send off the machine. */
|
|
33
|
+
export const HUNK_CHAR_LIMIT = 6_000;
|
|
34
|
+
/** A push selecting more than this is a refactor, not a telemetry change; the rest are not checked, and say so. */
|
|
35
|
+
export const MAX_CANDIDATES = 20;
|
|
36
|
+
/** A git hook must stay a hook: nothing new starts after this, and what is left is not checked. */
|
|
37
|
+
export const TIME_BUDGET_MS = 60_000;
|
|
38
|
+
const CONCURRENCY = 4;
|
|
39
|
+
|
|
40
|
+
export class LintConfigError extends Error {}
|
|
41
|
+
|
|
42
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
43
|
+
const isUnit = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1;
|
|
44
|
+
const RULE_KEYS = ['id', 'source', 'severity', 'globs', 'allowlist', 'patterns', 'question'];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The `lint` section → rules with their selectors compiled. Pure; throws LintConfigError naming the problem, because a
|
|
48
|
+
* typo in a glob or a pattern would otherwise select nothing and read as a clean push (CODE-QUALITY §7).
|
|
49
|
+
* `null` (no section anywhere) is no rules.
|
|
50
|
+
*/
|
|
51
|
+
export function parseLintRules(raw) {
|
|
52
|
+
if (raw === null || raw === undefined) return [];
|
|
53
|
+
const fail = (m) => {
|
|
54
|
+
throw new LintConfigError(`lint: ${m}`);
|
|
55
|
+
};
|
|
56
|
+
if (!isObj(raw)) fail('the section must be an object');
|
|
57
|
+
const extra = Object.keys(raw).filter((k) => k !== 'rules' && !k.startsWith('$'));
|
|
58
|
+
if (extra.length) fail(`unknown key(s): ${extra.join(', ')}`);
|
|
59
|
+
if (raw.rules === undefined) return [];
|
|
60
|
+
if (!Array.isArray(raw.rules)) fail('rules must be an array');
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
const strings = (v, where, { nonEmpty }) => {
|
|
63
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== 'string' || !x))
|
|
64
|
+
fail(`${where} must be an array of strings`);
|
|
65
|
+
if (nonEmpty && !v.length) fail(`${where} must not be empty`);
|
|
66
|
+
return v;
|
|
67
|
+
};
|
|
68
|
+
return raw.rules.map((r, i) => {
|
|
69
|
+
if (!isObj(r)) fail(`rules[${i}] must be an object`);
|
|
70
|
+
const at = `rules[${i}]${typeof r.id === 'string' ? ` (${r.id})` : ''}`;
|
|
71
|
+
const unknown = Object.keys(r).filter((k) => !RULE_KEYS.includes(k) && !k.startsWith('$'));
|
|
72
|
+
if (unknown.length) fail(`${at}: unknown key(s): ${unknown.join(', ')}`);
|
|
73
|
+
// The id is also a threshold key in jev.config.json (rails.lint.thresholds.<id>), so it obeys that key's shape.
|
|
74
|
+
if (typeof r.id !== 'string' || !LINT_THRESHOLD_KEY.test(r.id) || r.id === 'default')
|
|
75
|
+
fail(`${at}: id must be lowercase a-z, 0-9 and -, and not "default"`);
|
|
76
|
+
if (seen.has(r.id)) fail(`${at}: duplicate id`);
|
|
77
|
+
seen.add(r.id);
|
|
78
|
+
if (typeof r.source !== 'string' || !r.source) fail(`${at}: source must name the rule it enforces`);
|
|
79
|
+
if (!SEVERITIES.includes(r.severity)) fail(`${at}: severity must be one of ${SEVERITIES.join(' | ')}`);
|
|
80
|
+
const globs = strings(r.globs, `${at}.globs`, { nonEmpty: true });
|
|
81
|
+
const allowlist = strings(r.allowlist ?? [], `${at}.allowlist`, { nonEmpty: false });
|
|
82
|
+
const patterns = strings(r.patterns, `${at}.patterns`, { nonEmpty: true }).map((p) => {
|
|
83
|
+
try {
|
|
84
|
+
return new RegExp(p, 'i');
|
|
85
|
+
} catch (e) {
|
|
86
|
+
return fail(`${at}.patterns: ${JSON.stringify(p)} is not a valid RegExp (${e.message})`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
const q = r.question;
|
|
90
|
+
if (
|
|
91
|
+
!isObj(q) ||
|
|
92
|
+
typeof q.instructions !== 'string' ||
|
|
93
|
+
!q.instructions ||
|
|
94
|
+
!isObj(q.criteria) ||
|
|
95
|
+
typeof q.criteria.true !== 'string' ||
|
|
96
|
+
typeof q.criteria.false !== 'string'
|
|
97
|
+
)
|
|
98
|
+
fail(`${at}.question must be { instructions, criteria: { true, false } }, all strings`);
|
|
99
|
+
return {
|
|
100
|
+
id: r.id,
|
|
101
|
+
source: r.source,
|
|
102
|
+
severity: r.severity,
|
|
103
|
+
question: {
|
|
104
|
+
instructions: q.instructions,
|
|
105
|
+
criteria: { true: q.criteria.true, false: q.criteria.false },
|
|
106
|
+
},
|
|
107
|
+
match: { globs: globs.map(globToRegExp), allow: allowlist.map(globToRegExp), patterns },
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The one Noul a rule asks, in the shape askJev sends. */
|
|
113
|
+
export const questionsFor = (rule) => ({ [QUESTION_ID]: { type: 'noul', ...rule.question } });
|
|
114
|
+
|
|
115
|
+
/** Pins a recording to the wording that produced it: an edited question must be re-measured, not replayed (D8). */
|
|
116
|
+
export const questionHash = (rule) =>
|
|
117
|
+
// `source` is sent in the state beside the question, so it is part of what was measured (fresh review of #200).
|
|
118
|
+
textHash(JSON.stringify({ questions: questionsFor(rule), source: rule.source }));
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `git diff` text → [{ file, hunks: [{ header, lines: [diff line], added: [line] }] }]. Pure. `lines` keep their +/-/space
|
|
122
|
+
* prefix, as the diff shows them.
|
|
123
|
+
*/
|
|
124
|
+
export function parseDiff(diffText) {
|
|
125
|
+
const files = [];
|
|
126
|
+
let file = null;
|
|
127
|
+
let hunk = null;
|
|
128
|
+
for (const line of String(diffText).split('\n')) {
|
|
129
|
+
if (line.startsWith('diff --git ')) {
|
|
130
|
+
file = null;
|
|
131
|
+
hunk = null;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// Only a header before the first `@@` is a file header: inside a hunk, an added line whose content starts with
|
|
135
|
+
// `++ ` also reads `+++ …`, and taking it for a new file would drop the rest of the hunk.
|
|
136
|
+
if (!hunk && line.startsWith('+++ ')) {
|
|
137
|
+
// `+++ /dev/null` is a deletion: it adds nothing, so it selects nothing.
|
|
138
|
+
const path = line.slice(4).replace(/\t.*$/, '');
|
|
139
|
+
file = path === '/dev/null' ? null : { file: path.replace(/^b\//, ''), hunks: [] };
|
|
140
|
+
if (file) files.push(file);
|
|
141
|
+
hunk = null;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!file) continue;
|
|
145
|
+
if (line.startsWith('@@')) {
|
|
146
|
+
hunk = { header: line, lines: [], added: [] };
|
|
147
|
+
file.hunks.push(hunk);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!hunk) continue;
|
|
151
|
+
if (line.startsWith('+')) hunk.added.push(line.slice(1));
|
|
152
|
+
if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) hunk.lines.push(line);
|
|
153
|
+
}
|
|
154
|
+
return files;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Lines of a hunk kept around each matching added line. */
|
|
158
|
+
export const WINDOW = 15;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* What is sent for one candidate (D1/D6): the hunk's `@@` header and a window of WINDOW lines either side of each
|
|
162
|
+
* matching added line, overlapping windows merged, gaps marked `…`. Not the whole hunk: a new file is ONE hunk, and
|
|
163
|
+
* measured on 220 commits of this repo's history, 33 of 60 candidate hunks were over HUNK_CHAR_LIMIT — nearly every
|
|
164
|
+
* new route and migration would have been "not checked". Pure.
|
|
165
|
+
*/
|
|
166
|
+
export function candidateText(hunk, isHit) {
|
|
167
|
+
const keep = new Set();
|
|
168
|
+
hunk.lines.forEach((l, i) => {
|
|
169
|
+
if (!l.startsWith('+') || !isHit(l.slice(1))) return;
|
|
170
|
+
for (let j = Math.max(0, i - WINDOW); j <= Math.min(hunk.lines.length - 1, i + WINDOW); j++) keep.add(j);
|
|
171
|
+
});
|
|
172
|
+
const out = [hunk.header];
|
|
173
|
+
let prev = -1;
|
|
174
|
+
for (const i of [...keep].sort((a, b) => a - b)) {
|
|
175
|
+
if (i !== prev + 1) out.push('…');
|
|
176
|
+
out.push(hunk.lines[i]);
|
|
177
|
+
prev = i;
|
|
178
|
+
}
|
|
179
|
+
if (prev < hunk.lines.length - 1) out.push('…');
|
|
180
|
+
return out.join('\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The deterministic half (D1): every hunk, per rule, in a file its globs match and its allowlist does not, with at
|
|
185
|
+
* least one ADDED line a pattern matches. Removed and context lines never select — deleting a vendor call is not a
|
|
186
|
+
* reason to ask. Duplicates (the same hunk reached through two pushed refs) collapse. Pure.
|
|
187
|
+
*/
|
|
188
|
+
export function selectCandidates(files, rules) {
|
|
189
|
+
const out = [];
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
for (const rule of rules)
|
|
192
|
+
for (const { file, hunks } of files) {
|
|
193
|
+
if (!rule.match.globs.some((re) => re.test(file))) continue;
|
|
194
|
+
if (rule.match.allow.some((re) => re.test(file))) continue;
|
|
195
|
+
for (const h of hunks) {
|
|
196
|
+
const hits = rule.match.patterns
|
|
197
|
+
.filter((re) => h.added.some((l) => re.test(l)))
|
|
198
|
+
.map((re) => re.source);
|
|
199
|
+
if (!hits.length) continue;
|
|
200
|
+
const text = candidateText(h, (l) => rule.match.patterns.some((re) => re.test(l)));
|
|
201
|
+
const key = `${rule.id}\0${file}\0${textHash(text)}`;
|
|
202
|
+
if (seen.has(key)) continue;
|
|
203
|
+
seen.add(key);
|
|
204
|
+
out.push({ rule, file, hunk: text, hits });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** D2 — one statistic per rule. Only a probability in [0,1] is a verdict (LEARNINGS: `Number(true)` once passed). */
|
|
211
|
+
export function outcomeOf(p, threshold) {
|
|
212
|
+
if (!isUnit(p)) return null;
|
|
213
|
+
if (p >= threshold) return 'raise';
|
|
214
|
+
// `1 - p >= t`, not `p <= 1 - t`: 1 - 0.8 is 0.19999…, which put p = 0.2 in the uncertain band.
|
|
215
|
+
if (1 - p >= threshold) return 'clear';
|
|
216
|
+
return 'uncertain';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A rule's threshold: its own key in rails.lint.thresholds, else `default`. */
|
|
220
|
+
export const thresholdFor = (rail, ruleId) => rail.thresholds[ruleId] ?? rail.thresholds.default;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Ask about one candidate. Never throws (askJev does not, and a non-probability is not-checked).
|
|
224
|
+
* Returns { outcome: raise|clear|uncertain|not-checked, p, error }.
|
|
225
|
+
*/
|
|
226
|
+
export async function judgeCandidate(c, { ask, threshold }) {
|
|
227
|
+
const res = await ask({
|
|
228
|
+
state: { rule: c.rule.id, source: c.rule.source, file: c.file, hunk: c.hunk },
|
|
229
|
+
questions: questionsFor(c.rule),
|
|
230
|
+
});
|
|
231
|
+
if (!res?.ok) return { outcome: 'not-checked', p: null, error: res?.error ?? 'no response' };
|
|
232
|
+
const raw = res.answers?.[QUESTION_ID]?.noul;
|
|
233
|
+
const outcome = outcomeOf(raw, threshold);
|
|
234
|
+
if (!outcome) return { outcome: 'not-checked', p: null, error: `invalid noul (${JSON.stringify(raw)})` };
|
|
235
|
+
return { outcome, p: Math.round(raw * 1000) / 1000, error: null };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The reason a person reads. `no key` is the words the smoke walkthrough and the hook's users look for. */
|
|
239
|
+
const reasonFor = (why) => (why === 'no TYPESAFE_API_KEY' ? 'no key' : why);
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Judge every candidate under the caps and the budget (D2), log each decision (D7). Returns the results in candidate
|
|
243
|
+
* order. `notChecked` (a reason) short-circuits every call: Jev could not legitimately be asked at all.
|
|
244
|
+
*/
|
|
245
|
+
export async function judgeAll(
|
|
246
|
+
candidates,
|
|
247
|
+
{ ask, log, rail, mode, sha, notChecked = null, now = Date.now, budgetMs = TIME_BUDGET_MS }
|
|
248
|
+
) {
|
|
249
|
+
const deadline = now() + budgetMs;
|
|
250
|
+
const results = new Array(candidates.length);
|
|
251
|
+
const judgeOne = async (i) => {
|
|
252
|
+
const c = candidates[i];
|
|
253
|
+
let r;
|
|
254
|
+
if (notChecked) r = { outcome: 'not-checked', p: null, error: notChecked };
|
|
255
|
+
else if (i >= MAX_CANDIDATES)
|
|
256
|
+
r = { outcome: 'not-checked', p: null, error: `over the ${MAX_CANDIDATES}-candidate cap` };
|
|
257
|
+
else if (c.hunk.length > HUNK_CHAR_LIMIT)
|
|
258
|
+
r = { outcome: 'not-checked', p: null, error: `hunk over ${HUNK_CHAR_LIMIT} chars (${c.hunk.length})` };
|
|
259
|
+
else if (now() >= deadline)
|
|
260
|
+
r = { outcome: 'not-checked', p: null, error: `time budget (${budgetMs / 1000}s) spent` };
|
|
261
|
+
else r = await judgeCandidate(c, { ask, threshold: thresholdFor(rail, c.rule.id) });
|
|
262
|
+
results[i] = r;
|
|
263
|
+
log({
|
|
264
|
+
rail: `lint:${c.rule.id}`,
|
|
265
|
+
mode,
|
|
266
|
+
decider: r.outcome === 'not-checked' ? 'not-checked' : 'jev',
|
|
267
|
+
regex: null,
|
|
268
|
+
jev: r.outcome === 'not-checked' ? null : r.outcome === 'raise',
|
|
269
|
+
confidence: r.p,
|
|
270
|
+
text: c.hunk,
|
|
271
|
+
source: `${c.file}@${sha ?? 'unknown'}`,
|
|
272
|
+
evidence: { file: c.file, outcome: r.outcome, hits: c.hits },
|
|
273
|
+
error: r.error,
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
let next = 0;
|
|
277
|
+
const worker = async () => {
|
|
278
|
+
while (next < candidates.length) await judgeOne(next++);
|
|
279
|
+
};
|
|
280
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, candidates.length) }, worker));
|
|
281
|
+
return results;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** D9 — one line per rule, plus one finding line per raise in `jev` mode. Pure. */
|
|
285
|
+
export function summarize(candidates, results, { mode }) {
|
|
286
|
+
const lines = [];
|
|
287
|
+
const byRule = new Map();
|
|
288
|
+
candidates.forEach((c, i) => {
|
|
289
|
+
if (!byRule.has(c.rule.id)) byRule.set(c.rule.id, []);
|
|
290
|
+
byRule.get(c.rule.id).push({ c, r: results[i] });
|
|
291
|
+
});
|
|
292
|
+
for (const [id, rows] of byRule) {
|
|
293
|
+
const of = (o) => rows.filter((x) => x.r.outcome === o);
|
|
294
|
+
const raised = of('raise');
|
|
295
|
+
const nc = of('not-checked');
|
|
296
|
+
if (nc.length === rows.length) {
|
|
297
|
+
const reasons = [...new Set(nc.map((x) => reasonFor(x.r.error)))];
|
|
298
|
+
lines.push(
|
|
299
|
+
`semantic-lint: not checked (${reasons.join('; ')}) — lint:${id}, ${rows.length} candidate(s) logged, none judged`
|
|
300
|
+
);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const parts = [];
|
|
304
|
+
if (raised.length)
|
|
305
|
+
parts.push(
|
|
306
|
+
`${raised.length} ${mode === 'jev' ? 'raised' : 'would raise'} (${raised.map((x) => `p=${x.r.p} ${x.c.file}`).join(', ')})`
|
|
307
|
+
);
|
|
308
|
+
for (const o of ['uncertain', 'clear']) if (of(o).length) parts.push(`${of(o).length} ${o}`);
|
|
309
|
+
if (nc.length)
|
|
310
|
+
parts.push(
|
|
311
|
+
`${nc.length} not checked (${[...new Set(nc.map((x) => reasonFor(x.r.error)))].join('; ')})`
|
|
312
|
+
);
|
|
313
|
+
const tail = mode === 'jev' ? 'logged' : 'logged, not shown as findings';
|
|
314
|
+
lines.push(
|
|
315
|
+
`semantic-lint (${mode}): lint:${id} — ${rows.length} candidate(s): ${parts.join(', ')} · ${tail}`
|
|
316
|
+
);
|
|
317
|
+
if (mode === 'jev')
|
|
318
|
+
for (const { c, r } of raised)
|
|
319
|
+
lines.push(` [${c.rule.severity}] ${c.file}: p=${r.p} — may break ${c.rule.source}`);
|
|
320
|
+
}
|
|
321
|
+
return lines;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const USAGE = 'usage: node scripts/semantic-lint.mjs [--range <base>...<head>]…';
|
|
325
|
+
|
|
326
|
+
/** Pure — `--range` values, or { error }. */
|
|
327
|
+
export function parseArgs(argv) {
|
|
328
|
+
const ranges = [];
|
|
329
|
+
for (let i = 0; i < argv.length; i++) {
|
|
330
|
+
if (argv[i] === '--range' && argv[i + 1] && !argv[i + 1].startsWith('--')) ranges.push(argv[++i]);
|
|
331
|
+
else return { error: `unexpected argument ${JSON.stringify(argv[i])}` };
|
|
332
|
+
}
|
|
333
|
+
return { ranges };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The CLI with every side effect injected (specs never touch git, the network or the real log). Returns the exit code.
|
|
338
|
+
* io: { root, jevConfig(), lintSection(), defaultRange(), diff(range), headSha(range), context(config), stdout, stderr }
|
|
339
|
+
*/
|
|
340
|
+
export async function run(argv, io) {
|
|
341
|
+
const args = parseArgs(argv);
|
|
342
|
+
if (args.error) {
|
|
343
|
+
io.stderr(`semantic-lint: ${args.error}\n${USAGE}\n`);
|
|
344
|
+
return 2;
|
|
345
|
+
}
|
|
346
|
+
let config;
|
|
347
|
+
let rules;
|
|
348
|
+
try {
|
|
349
|
+
config = io.jevConfig();
|
|
350
|
+
// Off exits before anything else — before the rules are read and, above all, before jevContext, whose unanswered-
|
|
351
|
+
// egress ask must never fire on a stranger's push for a rail they never turned on (D6).
|
|
352
|
+
if (config.rails.lint.mode === 'off') {
|
|
353
|
+
io.stdout('semantic-lint: off (jev.rails.lint.mode)\n');
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
rules = parseLintRules(io.lintSection());
|
|
357
|
+
} catch (e) {
|
|
358
|
+
io.stderr(`semantic-lint: not run — ${e.message}\n`);
|
|
359
|
+
return 2;
|
|
360
|
+
}
|
|
361
|
+
if (!rules.length) {
|
|
362
|
+
io.stdout('semantic-lint: no rules (lint.rules is empty)\n');
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
|
365
|
+
const ranges = args.ranges.length ? args.ranges : [io.defaultRange()];
|
|
366
|
+
let files = [];
|
|
367
|
+
try {
|
|
368
|
+
for (const range of ranges) files = files.concat(parseDiff(io.diff(range)));
|
|
369
|
+
} catch (e) {
|
|
370
|
+
// Could not look is never a pass: say it, even though there is nothing to log per candidate.
|
|
371
|
+
io.stdout(`semantic-lint: not checked (could not read the diff: ${String(e.message).split('\n')[0]})\n`);
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
const candidates = selectCandidates(files, rules);
|
|
375
|
+
const mode = config.rails.lint.mode;
|
|
376
|
+
if (!candidates.length) {
|
|
377
|
+
io.stdout(`semantic-lint (${mode}): nothing to judge — no added line matched a rule's selector\n`);
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
const ctx = io.context(config);
|
|
381
|
+
const results = await judgeAll(candidates, {
|
|
382
|
+
ask: ctx.ask,
|
|
383
|
+
log: ctx.log,
|
|
384
|
+
rail: config.rails.lint,
|
|
385
|
+
mode,
|
|
386
|
+
sha: io.headSha(ranges[ranges.length - 1]),
|
|
387
|
+
notChecked: ctx.mode === 'off' ? ctx.why : null,
|
|
388
|
+
});
|
|
389
|
+
for (const line of summarize(candidates, results, { mode })) io.stdout(`${line}\n`);
|
|
390
|
+
return 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const git = (root, args) =>
|
|
394
|
+
execFileSync('git', args, {
|
|
395
|
+
cwd: root,
|
|
396
|
+
encoding: 'utf8',
|
|
397
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
398
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
async function main() {
|
|
402
|
+
const root = repoRoot();
|
|
403
|
+
const code = await run(process.argv.slice(2), {
|
|
404
|
+
jevConfig: () => loadJevConfig({ root }),
|
|
405
|
+
lintSection: () => readSection('lint', { root }).raw,
|
|
406
|
+
defaultRange: () => {
|
|
407
|
+
try {
|
|
408
|
+
git(root, ['rev-parse', '--verify', '--quiet', '@{upstream}']);
|
|
409
|
+
return '@{upstream}...HEAD';
|
|
410
|
+
} catch {
|
|
411
|
+
return 'origin/main...HEAD';
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
diff: (range) =>
|
|
415
|
+
// Pinned, not inherited from the person's git config: the default core.quotePath turns a non-ASCII path into
|
|
416
|
+
// "b/…\303\261.ts", and a custom diff.dstPrefix changes `b/` — either one made a real candidate match no glob
|
|
417
|
+
// and read as "nothing to judge" (fresh review of #200).
|
|
418
|
+
git(root, [
|
|
419
|
+
'-c',
|
|
420
|
+
'core.quotePath=false',
|
|
421
|
+
'diff',
|
|
422
|
+
'--src-prefix=a/',
|
|
423
|
+
'--dst-prefix=b/',
|
|
424
|
+
'-U3',
|
|
425
|
+
'--no-color',
|
|
426
|
+
'--no-ext-diff',
|
|
427
|
+
'--diff-filter=d',
|
|
428
|
+
range,
|
|
429
|
+
]),
|
|
430
|
+
headSha: (range) => {
|
|
431
|
+
try {
|
|
432
|
+
return git(root, ['rev-parse', '--short=12', range.split('...').pop() || 'HEAD']).trim();
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
context: (config) => jevContext('lint', { root, config }),
|
|
438
|
+
stdout: (t) => process.stdout.write(t),
|
|
439
|
+
stderr: (t) => process.stderr.write(t),
|
|
440
|
+
});
|
|
441
|
+
process.exitCode = code;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
445
|
+
if (isMain)
|
|
446
|
+
main().catch((e) => {
|
|
447
|
+
// A hook caller ignores the code, but a person running it by hand must see a crash as one.
|
|
448
|
+
process.stderr.write(`semantic-lint: ${e?.stack || e}\n`);
|
|
449
|
+
process.exitCode = 2;
|
|
450
|
+
});
|
|
@@ -159,6 +159,9 @@ one-liner + why + date shape.
|
|
|
159
159
|
non-state-mutating and lock that with a test.
|
|
160
160
|
|
|
161
161
|
## Working efficiently
|
|
162
|
-
- **
|
|
163
|
-
state (the plan file, sprint docs, team memory) makes
|
|
164
|
-
|
|
162
|
+
- **Session length is set by a measured line, not a stamina rule.** A whole epic in one session is the
|
|
163
|
+
main context-cost driver, and the durable state (the plan file, sprint docs, team memory) makes
|
|
164
|
+
re-entry cheap by design — so checkpoint or hand off when the budget line says so (its thresholds
|
|
165
|
+
live in one table, `THRESHOLDS` in groom's `session-budget.mjs`), not per sprint. Groom's twin rule is "one deep ask
|
|
166
|
+
per approval gate". *(Sharpened 2026-09-30, session-budget: "fresh session per sprint" was set for
|
|
167
|
+
earlier models.)*
|
|
@@ -224,8 +224,10 @@ derived views. **`Roadmap/bets/`** holds one file per wave; **`tasks/`** is the
|
|
|
224
224
|
degrade gracefully.
|
|
225
225
|
- **Worker death is a normal case.** Each builder on its own worktree; a killed worker's uncommitted tree
|
|
226
226
|
is evidence, not garbage; **verify by re-deriving repo state, never by trusting a completion report** —
|
|
227
|
-
a rate-limited subagent still returns a plausible-sounding result.
|
|
228
|
-
|
|
227
|
+
a rate-limited subagent still returns a plausible-sounding result. Checkpoint and hand
|
|
228
|
+
off when the budget line says so (Claude Code: under the prompt; groom: at each approval gate) —
|
|
229
|
+
not by sprint count. Where no line shows (function hooks off, or no figure yet), compact at
|
|
230
|
+
sprint/PR boundaries.
|
|
229
231
|
- Commit messages end with the `Co-Authored-By: Claude` trailer.
|
|
230
232
|
- **Language.** Docs, comments and PR descriptions are in **English**. **TEMPLATE FILL-IN:** if your user-facing copy has its own policy (a default plus a bilingual allow-list), state it here and in `AGENTS.md`.
|
|
231
233
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@golden-frijoles/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "The scripts the Golden Frijoles skills run, packaged so they work in any repo: planning, build-order, reporting and verification rails. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|