@codedology/code-forge 0.5.1 → 0.6.1
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/CHANGELOG.md +59 -0
- package/README.md +39 -2
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +127 -1
- package/docs/reference/config.md +2 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +7 -0
- package/skill/SKILL.md +2 -0
- package/skill/references/adapters/claude-code.md +14 -0
- package/skill/references/autopilot.md +48 -0
- package/skill/references/code.md +1 -1
- package/skill/references/decisions.md +3 -1
- package/skill/references/harden.md +1 -1
- package/skill/references/ledger.md +2 -0
- package/skill/references/plan.md +6 -4
- package/skill/references/review.md +2 -2
- package/skill/references/rules-core.md +4 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/plan.md +4 -3
- package/src/autopilot/actions.mjs +491 -0
- package/src/autopilot/binnacle.mjs +494 -0
- package/src/autopilot/delegate.mjs +422 -0
- package/src/autopilot/grant.mjs +354 -0
- package/src/autopilot/limits.mjs +848 -0
- package/src/autopilot/scopes.mjs +95 -0
- package/src/cli/author.mjs +7 -5
- package/src/cli/autopilot.mjs +500 -0
- package/src/cli/block.mjs +40 -8
- package/src/cli/facts.mjs +7 -6
- package/src/cli/jev.mjs +10 -6
- package/src/cli/logs.mjs +4 -142
- package/src/cli/plan.mjs +18 -7
- package/src/cli/run.mjs +20 -61
- package/src/cli/s2.mjs +5 -4
- package/src/cli/spawn.mjs +5 -4
- package/src/config/closed-book.mjs +20 -3
- package/src/config/load.mjs +113 -1
- package/src/doctor/probes.mjs +34 -11
- package/src/engines/builders/claude.mjs +3 -3
- package/src/engines/builders/codex.mjs +6 -5
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/builders/validate-params.mjs +3 -3
- package/src/ledger/report.mjs +2 -0
- package/src/review/fixloop.mjs +69 -5
- package/src/review/gate-check.mjs +156 -6
- package/src/session/facts.mjs +291 -73
- package/src/session/plan-check.mjs +75 -6
- package/src/session/spawn.mjs +68 -8
- package/src/state/run.mjs +8 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +10 -0
- package/src/util/gh.mjs +175 -0
- package/src/worker/engine.mjs +8 -4
- package/src/worker/loop.mjs +42 -0
package/src/session/facts.mjs
CHANGED
|
@@ -117,11 +117,93 @@ const COMMAND_IN_SPAN = /^([a-z][a-z0-9-]*) ([a-z][a-z0-9-]*)(?![\w.\/-])/;
|
|
|
117
117
|
const trimTail = (token) => token.replace(/[.,:;]+$/, '');
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
|
-
*
|
|
120
|
+
* @typedef {object} ExtractOpts
|
|
121
|
+
* @property {ReadonlyArray<string>} [projectBins] - the project's own CLI names (its package.json
|
|
122
|
+
* `bin` keys, {@link readProjectBins}); none ⇒ command claims are kept as written.
|
|
123
|
+
* @property {string} [briefText] - the brief alone (no sources): only IT can name the project CLI
|
|
124
|
+
* that a command claim gets prefixed with (default: the whole text).
|
|
125
|
+
*/
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `word` as a whole CLI word: not inside another word (`a-word`, `word2`), a path (`./word`,
|
|
129
|
+
* `dir/word`) or a file name (`.word.yml`, `word.config`); a sentence's full stop after it is fine.
|
|
130
|
+
* @param {string} word @returns {RegExp}
|
|
131
|
+
*/
|
|
132
|
+
export const cliWordRe = (word) => new RegExp(`(?<![\\w./-])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w/-]|\\.\\w)`);
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Does the brief use `word` as a CLI of its own in a code span — the span is `word` alone, or `word`
|
|
136
|
+
* followed by an option or a path (`` `gh` ``, `` `gh --repo x` ``, `` `jq ./a.json` ``)? A
|
|
137
|
+
* `` `word verb` `` span is the claim shape itself and does not count.
|
|
138
|
+
* @param {string} word @param {string} brief
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
function namedAsCli(word, brief) {
|
|
142
|
+
return [...brief.matchAll(/`([^`\n]+)`/g)].some((m) => {
|
|
143
|
+
const span = m[1].trim();
|
|
144
|
+
return span === word || (span.startsWith(`${word} `) && /^[-./~]/.test(span.slice(word.length + 1).trimStart()));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** CLIs that are never a verb of the project's own CLI: a claim starting with one is never prefixed. */
|
|
149
|
+
const WELL_KNOWN_CLIS = new Set(['git', 'gh', 'npm', 'npx', 'node', 'pnpm', 'yarn', 'bun', 'deno', 'ls', 'cat', 'grep', 'which', 'find', 'test', 'make', 'docker', 'curl', 'claude', 'codex', 'python', 'python3', 'pip', 'go', 'cargo', 'op']);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* B50: a `<cli> <verb>` claim written without the project's CLI (`run reload` in a brief about
|
|
153
|
+
* `code-forge`) gets it — deterministically, from the brief and package.json only (never PATH):
|
|
154
|
+
* the BRIEF (`opts.briefText`, never a source) names a project bin as a whole word
|
|
155
|
+
* ({@link cliWordRe}), the claim's first word is not itself a project bin nor a well-known CLI
|
|
156
|
+
* ({@link WELL_KNOWN_CLIS}), and the brief never uses that first word as a CLI of its own
|
|
157
|
+
* ({@link namedAsCli}). Then the token becomes `<bin> <token>`; anything else stays as written.
|
|
158
|
+
* @param {string} token @param {string} text @param {ExtractOpts} opts
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
161
|
+
function withProjectCli(token, text, opts) {
|
|
162
|
+
const bins = (opts.projectBins ?? []).filter((b) => BARE_CLI.test(b));
|
|
163
|
+
const first = token.split(' ')[0];
|
|
164
|
+
const brief = opts.briefText ?? text;
|
|
165
|
+
if (bins.length === 0 || bins.includes(first) || WELL_KNOWN_CLIS.has(first) || namedAsCli(first, brief)) return token;
|
|
166
|
+
const named = bins.find((b) => cliWordRe(b).test(brief));
|
|
167
|
+
return named === undefined ? token : `${named} ${token}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The CLI names a project ships: the keys of its package.json `bin` (a string `bin` names the
|
|
172
|
+
* package, without its scope). No package.json, or no `bin` ⇒ none.
|
|
173
|
+
* @param {string} projectDir
|
|
174
|
+
* @returns {string[]}
|
|
175
|
+
* @throws {FactsError} `usage` when package.json exists but cannot be read or is not JSON (the
|
|
176
|
+
* message names the file only, never its content).
|
|
177
|
+
*/
|
|
178
|
+
export function readProjectBins(projectDir) {
|
|
179
|
+
let text;
|
|
180
|
+
try {
|
|
181
|
+
text = readFileSync(path.join(projectDir, 'package.json'), 'utf8');
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return []; // the project ships no CLI
|
|
184
|
+
throw new FactsError('usage', `package.json at the project root cannot be read (${/** @type {NodeJS.ErrnoException} */ (err).code ?? 'error'})`);
|
|
185
|
+
}
|
|
186
|
+
let pkg;
|
|
187
|
+
try {
|
|
188
|
+
pkg = JSON.parse(text);
|
|
189
|
+
} catch {
|
|
190
|
+
throw new FactsError('usage', 'package.json at the project root is not valid JSON');
|
|
191
|
+
}
|
|
192
|
+
if (typeof pkg?.bin === 'string' && typeof pkg.name === 'string') return [pkg.name.replace(/^@[^/]+\//, '')];
|
|
193
|
+
if (pkg?.bin !== null && typeof pkg?.bin === 'object' && !Array.isArray(pkg.bin)) return Object.keys(pkg.bin);
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Every claim token of `text`, deduplicated by (kind, token), in order of first appearance. With
|
|
200
|
+
* `opts.projectBins`, a command claim written without its CLI gets the project's (B50,
|
|
201
|
+
* {@link withProjectCli}).
|
|
121
202
|
* @param {string} text
|
|
203
|
+
* @param {ExtractOpts} [opts]
|
|
122
204
|
* @returns {Claim[]}
|
|
123
205
|
*/
|
|
124
|
-
export function extractClaims(text) {
|
|
206
|
+
export function extractClaims(text, opts = {}) {
|
|
125
207
|
let masked = String(text);
|
|
126
208
|
/** @type {Array<{index: number, token: string, kind: ClaimKind}>} */
|
|
127
209
|
const found = [];
|
|
@@ -145,7 +227,7 @@ export function extractClaims(text) {
|
|
|
145
227
|
const cmd = COMMAND_IN_SPAN.exec(m[1]);
|
|
146
228
|
if (!cmd) continue;
|
|
147
229
|
const start = /** @type {number} */ (m.index) + 1;
|
|
148
|
-
found.push({ index: start, token: `${cmd[1]} ${cmd[2]}`, kind: 'command' });
|
|
230
|
+
found.push({ index: start, token: withProjectCli(`${cmd[1]} ${cmd[2]}`, String(text), opts), kind: 'command' });
|
|
149
231
|
mask(start, cmd[0].length);
|
|
150
232
|
}
|
|
151
233
|
for (const entry of PATTERNS.slice(3)) scan(...entry);
|
|
@@ -322,11 +404,13 @@ function pathViolation(word) {
|
|
|
322
404
|
}
|
|
323
405
|
|
|
324
406
|
/** Commands that read and print, allowed with any in-cwd arguments (after the secret-path check). */
|
|
325
|
-
|
|
407
|
+
// B50: `rg` is not one — it searches recursively by default, so any pattern could sweep the tree
|
|
408
|
+
const PLAIN_READERS = new Set(['ls', 'cat', 'head', 'tail', 'wc', 'grep', 'stat', 'file', 'uname', 'sw_vers', 'jq', 'which']);
|
|
326
409
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const
|
|
410
|
+
// B50: no `--help`, `-h` or `--version` form anywhere (a program may read it as an operand: BSD
|
|
411
|
+
// `rm foo --help` deletes); a version claim is checked with `npm view`, `which` or `ls`
|
|
412
|
+
const GIT_READS = new Set(['log', 'show', 'status', 'rev-parse', 'ls-files', 'cat-file']);
|
|
413
|
+
const NPM_READS = new Set(['view', 'ls']);
|
|
330
414
|
/**
|
|
331
415
|
* find: an ALLOWED predicate list (B9b.1). Any other word starting with `-` (`-exec`, `-execdir`,
|
|
332
416
|
* `-ok`, `-okdir`, `-delete`, `-fls`, `-fprint*`, …) is refused by omission; `!` and bare words are
|
|
@@ -356,7 +440,7 @@ function findViolation(rest) {
|
|
|
356
440
|
}
|
|
357
441
|
return null;
|
|
358
442
|
}
|
|
359
|
-
/** Build/task runners: their
|
|
443
|
+
/** Build/task runners: their targets run user recipes; named in the refusal so the delegate sees why. */
|
|
360
444
|
const BUILD_RUNNERS = new Set(['make', 'gmake', 'just', 'task', 'rake', 'gradle', 'gradlew', 'mvn', 'mvnw', 'ant', 'ninja', 'cmake', 'bazel', 'bazelisk', 'tox', 'nox', 'invoke', 'inv', 'doit', 'earthly', 'mage', 'npx', 'pnpx', 'bunx']);
|
|
361
445
|
/** curl: the long options allowed; the short ones are any cluster of `s S I L f`; `--max-time <N>` is handled apart. */
|
|
362
446
|
const CURL_LONG = new Set(['--head', '--silent', '--show-error', '--location', '--fail']);
|
|
@@ -404,8 +488,7 @@ function segmentViolation(argv) {
|
|
|
404
488
|
if (why) return why;
|
|
405
489
|
}
|
|
406
490
|
if (PLAIN_READERS.has(verb)) {
|
|
407
|
-
if (verb === '
|
|
408
|
-
if (verb === 'grep' && rest.some((t) => /^-[a-zA-Z]*[rR]/.test(t) || t === '--recursive' || t === '--dereference-recursive')) return 'grep: -r, -R and --recursive are not allowed';
|
|
491
|
+
if (verb === 'grep') return grepViolation(rest);
|
|
409
492
|
return null;
|
|
410
493
|
}
|
|
411
494
|
switch (verb) {
|
|
@@ -414,84 +497,178 @@ function segmentViolation(argv) {
|
|
|
414
497
|
case 'find':
|
|
415
498
|
return findViolation(rest);
|
|
416
499
|
case 'git':
|
|
417
|
-
if (rest.length === 0 || !GIT_READS.has(rest[0])) return 'git: only log, show, status, rev-parse, ls-files
|
|
500
|
+
if (rest.length === 0 || !GIT_READS.has(rest[0])) return 'git: only log, show, status, rev-parse, ls-files or cat-file, with no global option';
|
|
418
501
|
return rest.some((t) => /^--(output|ext-diff|textconv)/.test(t)) ? 'git: --output, --ext-diff and --textconv are not allowed' : null;
|
|
419
502
|
case 'npm':
|
|
420
|
-
return rest.length > 0 && NPM_READS.has(rest[0]) ? null : 'npm: only view
|
|
421
|
-
case 'node':
|
|
422
|
-
return rest.length === 1 && (rest[0] === '--version' || rest[0] === '-v') ? null : 'node: only --version';
|
|
503
|
+
return rest.length > 0 && NPM_READS.has(rest[0]) ? null : 'npm: only view or ls';
|
|
423
504
|
case 'test':
|
|
424
505
|
return rest.length === 2 && ['-e', '-f', '-d', '-n'].includes(rest[0]) ? null : 'test: only -e, -f, -d or -n with one operand';
|
|
425
506
|
case 'curl':
|
|
426
507
|
return curlViolation(rest);
|
|
427
508
|
default:
|
|
428
|
-
// `<cli> --help|-h|--version` —
|
|
509
|
+
// B50: no `<cli> --help|-h|--version` form — any other program is refused
|
|
429
510
|
if (BUILD_RUNNERS.has(verb)) return `${verb}: build and task runners are not allowed`;
|
|
430
|
-
return
|
|
511
|
+
return `not an allowed read-only form: "${verb}"`;
|
|
431
512
|
}
|
|
432
513
|
}
|
|
433
514
|
|
|
434
|
-
/** A bare CLI name (never a path)
|
|
515
|
+
/** A bare CLI name (never a path). */
|
|
435
516
|
const BARE_CLI = /^[a-z0-9][a-z0-9._-]*$/;
|
|
436
|
-
/**
|
|
437
|
-
const
|
|
438
|
-
/** The flag a help page is searched for (`grep -c -- <flag>`). */
|
|
439
|
-
const HELP_FLAG = /^--?[A-Za-z0-9][A-Za-z0-9-]*$/;
|
|
517
|
+
/** The pattern of the recursive source grep: a long flag with at least 3 characters after `--`. */
|
|
518
|
+
const SOURCE_GREP_FLAG = /^--[a-z][a-z0-9-]{2,}$/;
|
|
440
519
|
|
|
441
520
|
/**
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
521
|
+
* B50: the ONLY option words grep may carry before `--` (an exact allow-list — no digits, no long
|
|
522
|
+
* option, no other cluster: `-2r`, `--recur`, `--dir=recurse`, `-d`, `-R`, `-l`, `-L`, `-e`, `-f` are
|
|
523
|
+
* all refused). `-rn`/`-nr` is the recursive source grep and needs its exact form ({@link sourceGrep}).
|
|
445
524
|
*/
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
])
|
|
452
|
-
|
|
525
|
+
const GREP_OPTIONS = new Set(['-n', '-c', '-i', '-w', '-F', '-rn', '-nr']);
|
|
526
|
+
const GREP_RECURSIVE = new Set(['-rn', '-nr']);
|
|
527
|
+
const GREP_MESSAGE = 'grep: options only -n -c -i -w -F before --, or the source grep grep -rn -- <flag> <path>';
|
|
528
|
+
const SOURCE_GREP_MESSAGE = 'grep -rn: only grep -rn -- <--long-flag> <path> (one relative subdirectory or file, never . or the root)';
|
|
529
|
+
|
|
530
|
+
/** @param {string[]} rest - the words after `grep`. @returns {string[]} every dash word before `--` (GNU grep permutes, so an option may follow an operand). */
|
|
531
|
+
function grepOptionWords(rest) {
|
|
532
|
+
const end = rest.indexOf('--');
|
|
533
|
+
return (end < 0 ? rest : rest.slice(0, end)).filter((t) => t.startsWith('-'));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** @param {string[]} rest @returns {boolean} the grep asks for recursion (`-rn`/`-nr` before `--`). */
|
|
537
|
+
function grepRecurses(rest) {
|
|
538
|
+
return grepOptionWords(rest).some((t) => GREP_RECURSIVE.has(t));
|
|
539
|
+
}
|
|
453
540
|
|
|
454
541
|
/**
|
|
455
|
-
*
|
|
456
|
-
* @
|
|
457
|
-
* @
|
|
542
|
+
* Why a grep segment is not allowed, or null: every option word before `--` is in
|
|
543
|
+
* {@link GREP_OPTIONS}; a recursive one is exactly the source grep ({@link sourceGrep}).
|
|
544
|
+
* @param {string[]} rest - the words after `grep`.
|
|
545
|
+
* @returns {string | null}
|
|
546
|
+
*/
|
|
547
|
+
function grepViolation(rest) {
|
|
548
|
+
if (!grepOptionWords(rest).every((t) => GREP_OPTIONS.has(t))) return GREP_MESSAGE;
|
|
549
|
+
if (grepRecurses(rest)) return sourceGrep(rest) === null ? SOURCE_GREP_MESSAGE : null;
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Is `where` a relative subdirectory or file of the cwd — never the cwd itself (`.`, `./`, `src/..`),
|
|
555
|
+
* the root, `~`, an absolute path, a `..` segment or a secret-looking path? The facts snapshot
|
|
556
|
+
* already drops every secret-looking file at any depth (`sealTree` and `copyNoFollow` test the full
|
|
557
|
+
* relative path against {@link SECRET_PATH}, so `config/.env` goes too); a named subdirectory is a
|
|
558
|
+
* second fence, so a grep never sweeps the whole tree.
|
|
559
|
+
* @param {string} where
|
|
560
|
+
* @returns {boolean}
|
|
561
|
+
*/
|
|
562
|
+
function isSubPath(where) {
|
|
563
|
+
if (where.length === 0 || where.startsWith('/') || where.startsWith('~') || where.startsWith('-')) return false;
|
|
564
|
+
if (pathViolation(where) !== null || SECRET_PATH.some((re) => re.test(where))) return false;
|
|
565
|
+
const norm = path.posix.normalize(where);
|
|
566
|
+
return norm !== '.' && norm !== './' && !norm.startsWith('..') && !norm.split('/').some((seg) => SECRET_PATH.some((re) => re.test(seg)));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* B50: the one recursive `grep` form — a flag claim checked in the project's SOURCE TEXT, inside the
|
|
571
|
+
* read-only snapshot, alone (never in a pipe), EXACTLY `grep -rn -- <flag> <path>` (or `-nr`): five
|
|
572
|
+
* words, the pattern a long flag of 3+ characters ({@link SOURCE_GREP_FLAG}, so `-a`, `-e` or `-1`
|
|
573
|
+
* cannot match every line), one relative subdirectory or file ({@link isSubPath}). Claude runs grep
|
|
574
|
+
* as a read-only command, so the delegate needs no permission rule for it.
|
|
575
|
+
* @param {string[]} rest - the words after `grep`.
|
|
576
|
+
* @returns {{pattern: string, path: string} | null} null when `rest` is not exactly that form.
|
|
577
|
+
*/
|
|
578
|
+
function sourceGrep(rest) {
|
|
579
|
+
if (rest.length !== 4 || !GREP_RECURSIVE.has(rest[0]) || rest[1] !== '--') return null;
|
|
580
|
+
const [, , pattern, where] = rest;
|
|
581
|
+
return SOURCE_GREP_FLAG.test(pattern) && isSubPath(where) ? { pattern, path: where } : null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The pattern and path of a command that is exactly one source-grep segment ({@link sourceGrep}), or null.
|
|
586
|
+
* @param {string} command
|
|
587
|
+
* @returns {{pattern: string, path: string} | null}
|
|
458
588
|
*/
|
|
459
|
-
|
|
460
|
-
|
|
589
|
+
function sourceGrepOf(command) {
|
|
590
|
+
const { segments, separators, refused } = splitCommand(command);
|
|
591
|
+
if (refused || segments.length !== 1 || separators.length !== 0 || segments[0][0] !== 'grep') return null;
|
|
592
|
+
return sourceGrep(segments[0].slice(1));
|
|
461
593
|
}
|
|
462
594
|
|
|
595
|
+
/** Folders whose text is about the code, not the code: a hit there could be the claim quoting itself. */
|
|
596
|
+
const NON_SOURCE_DIRS = new Set(['docs', 'plans', 'test', 'tests', 'fixtures', 'node_modules']);
|
|
597
|
+
/** Top-level folders a NOT-FOUND never searches as "the source": text, tests, vendored or built output. */
|
|
598
|
+
const NON_SOURCE_FOLDERS = new Set([...NON_SOURCE_DIRS, 'dist', 'build', 'coverage', 'out']);
|
|
599
|
+
/** Text files never prove a flag (the brief and every note are one). */
|
|
600
|
+
const NON_SOURCE_FILE = /\.(md|markdown|txt)$/i;
|
|
601
|
+
|
|
602
|
+
/** @param {string} p @returns {string} `p` as a normalised POSIX path without a leading `./` or a trailing `/`. */
|
|
603
|
+
const relPosix = (p) => path.posix.normalize(p).replace(/^(\.\/)+/, '').replace(/\/+$/, '');
|
|
604
|
+
|
|
463
605
|
/**
|
|
464
|
-
* Is `
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
* own ({@link segmentViolation}).
|
|
469
|
-
* @param {string[][]} segments @param {string[]} separators @param {ReadonlySet<string>} clis
|
|
606
|
+
* Is `file` (relative to the delegate's cwd) a SOURCE file — not a `.md`, `.markdown` or `.txt`
|
|
607
|
+
* file, under no `docs/ plans/ test/ tests/ fixtures/ node_modules/` folder (at any depth), and
|
|
608
|
+
* not the brief itself?
|
|
609
|
+
* @param {string} file @param {string | undefined} briefRel
|
|
470
610
|
* @returns {boolean}
|
|
471
611
|
*/
|
|
472
|
-
function
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (help.length !== 3 || help[2] !== '--help' || !BARE_CLI.test(cli) || !clis.has(cli) || BUILD_RUNNERS.has(cli) || SUBCOMMAND_HELP_DENIED.has(cli) || !SUBCOMMAND_WORD.test(help[1])) return false;
|
|
477
|
-
return grep.length === 4 && grep[0] === 'grep' && grep[1] === '-c' && grep[2] === '--' && HELP_FLAG.test(grep[3]);
|
|
612
|
+
function isSourceFile(file, briefRel) {
|
|
613
|
+
const rel = relPosix(file);
|
|
614
|
+
if ((briefRel !== undefined && rel === relPosix(briefRel)) || NON_SOURCE_FILE.test(rel)) return false;
|
|
615
|
+
return !rel.split('/').slice(0, -1).some((seg) => NON_SOURCE_DIRS.has(seg));
|
|
478
616
|
}
|
|
479
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Does the excerpt hold at least one `file:line:text` hit whose file sits under the grep's `under`
|
|
620
|
+
* path, is a source file ({@link isSourceFile}) and whose text holds `flag` as a whole token
|
|
621
|
+
* ({@link holdsFlag})? A hit line without the `file:line:` prefix never counts.
|
|
622
|
+
* @param {string} excerpt @param {string} flag @param {string} under - the grep's path.
|
|
623
|
+
* @param {string | undefined} briefRel
|
|
624
|
+
* @returns {boolean}
|
|
625
|
+
*/
|
|
626
|
+
function sourceHit(excerpt, flag, under, briefRel) {
|
|
627
|
+
const scope = relPosix(under);
|
|
628
|
+
return excerpt.split(/\r?\n/).some((line) => {
|
|
629
|
+
const hit = /^([^:\n]+):(\d+):(.*)$/.exec(line.trim());
|
|
630
|
+
if (hit === null) return false;
|
|
631
|
+
const file = relPosix(hit[1]);
|
|
632
|
+
return (file === scope || file.startsWith(`${scope}/`)) && isSourceFile(file, briefRel) && holdsFlag(hit[3], flag);
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Is `where` a source FOLDER of the delegate's cwd (the snapshot): an existing directory, no segment
|
|
638
|
+
* hidden (`.x`) or one of {@link NON_SOURCE_FOLDERS}, and — with `topLevel` — one single name (a
|
|
639
|
+
* NOT-FOUND proves absence only over a whole top-level folder). A file (`package.json`,
|
|
640
|
+
* `src/a.mjs`) never counts; with no known cwd nothing can be checked, so nothing counts.
|
|
641
|
+
* @param {string} where @param {string | undefined} cwd @param {boolean} topLevel
|
|
642
|
+
* @returns {boolean}
|
|
643
|
+
*/
|
|
644
|
+
function isSourceFolder(where, cwd, topLevel) {
|
|
645
|
+
const rel = relPosix(where);
|
|
646
|
+
const segs = rel.split('/');
|
|
647
|
+
if (cwd === undefined || rel.length === 0 || rel === '.' || (topLevel && segs.length !== 1)) return false;
|
|
648
|
+
if (segs.some((seg) => seg.startsWith('.') || NON_SOURCE_FOLDERS.has(seg))) return false;
|
|
649
|
+
try {
|
|
650
|
+
return statSync(path.join(cwd, rel)).isDirectory();
|
|
651
|
+
} catch {
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const ONE_COMMAND_MESSAGE = 'one command per row: no pipe or list (| ; && || & or a newline)';
|
|
657
|
+
|
|
480
658
|
/**
|
|
481
659
|
* Why a delegate's `command` is not an allowed read-only command, or null when it is. An
|
|
482
|
-
* ALLOW-LIST (fail closed):
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
* ({@link isSubcommandHelpGrep}); `$`, backticks and redirections are refused.
|
|
660
|
+
* ALLOW-LIST (fail closed): exactly ONE command (B50: any `;`, `&&`, `||`, `|`, `&`, newline or CR
|
|
661
|
+
* is refused, as the packet says), and it is one of the forms in {@link segmentViolation}; `$`,
|
|
662
|
+
* backticks and redirections are refused.
|
|
486
663
|
* @param {string} command
|
|
487
|
-
* @param {{clis?: ReadonlySet<string>}} [opts] - `clis`: {@link claimClis} of the brief (none ⇒ the subcommand form is never admitted).
|
|
488
664
|
* @returns {string | null}
|
|
489
665
|
*/
|
|
490
|
-
export function readOnlyViolation(command
|
|
666
|
+
export function readOnlyViolation(command) {
|
|
491
667
|
const { segments, separators, refused } = splitCommand(command);
|
|
492
668
|
if (refused) return refused;
|
|
493
669
|
if (segments.length === 0) return 'empty command';
|
|
494
|
-
|
|
670
|
+
// B50: one command per row, as the packet says — no pipe and no list
|
|
671
|
+
if (separators.length > 0) return ONE_COMMAND_MESSAGE;
|
|
495
672
|
for (const argv of segments) {
|
|
496
673
|
const why = segmentViolation(argv);
|
|
497
674
|
if (why) return why;
|
|
@@ -552,22 +729,48 @@ export function isSilentCommand(command) {
|
|
|
552
729
|
|
|
553
730
|
/** @typedef {{fact_id: string, reason: string}} Downgrade */
|
|
554
731
|
|
|
732
|
+
const SOURCE_GREP_CLAIM_MESSAGE = 'a source grep counts only for a flag claim, with that flag as its pattern; not counted';
|
|
733
|
+
const SOURCE_GREP_HIT_MESSAGE = 'VERIFIED source grep without a file:line: hit in a source file naming the claimed flag (docs, plans, tests, fixtures, text files and the brief do not count); not counted';
|
|
734
|
+
const SOURCE_GREP_SCOPE_MESSAGE = 'NOT-FOUND source grep must search one whole top-level source folder (not docs, plans, test, tests, fixtures or node_modules); not counted';
|
|
735
|
+
const FLAG_PROOF_MESSAGE = 'a flag claim is proved only by grep -rn -- <flag> <source folder>; not counted';
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Does a grep hit line hold `flag` as a whole token — not preceded by a word character or `-`, not
|
|
739
|
+
* followed by `[A-Za-z0-9_-]` (`--force-push` does not hold `--force`)? A `-l` file-name line never does.
|
|
740
|
+
* @param {string} excerpt @param {string} flag
|
|
741
|
+
* @returns {boolean}
|
|
742
|
+
*/
|
|
743
|
+
function holdsFlag(excerpt, flag) {
|
|
744
|
+
return new RegExp(`(?<![\\w-])${flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![A-Za-z0-9_-])`).test(excerpt);
|
|
745
|
+
}
|
|
746
|
+
|
|
555
747
|
/**
|
|
556
748
|
* Which check rule a counted row (VERIFIED or NOT-FOUND) breaks, or null. A row the delegate
|
|
557
749
|
* already tagged UNVERIFIABLE is never counted, so it keeps its own `why`.
|
|
558
|
-
* @param {Fact} row @param {Claim} claim
|
|
750
|
+
* @param {Fact} row @param {Claim} claim
|
|
751
|
+
* @param {{briefRel?: string, cwd?: string}} ctx - the brief relative to the delegate's cwd, and that cwd (the snapshot).
|
|
559
752
|
* @returns {string | null}
|
|
560
753
|
*/
|
|
561
|
-
function ruleBroken(row, claim,
|
|
754
|
+
function ruleBroken(row, claim, ctx) {
|
|
562
755
|
if (row.tag === 'UNVERIFIABLE') return null;
|
|
563
756
|
if (row.command.trim().length === 0) return `${row.tag} without a check command; not counted`;
|
|
564
757
|
if (claim.kind === 'env') {
|
|
565
758
|
const env = ENV_CHECK.exec(row.command);
|
|
566
759
|
if (!env || env[1] !== claim.token) return `an env claim may only be checked with printenv ${claim.token} >/dev/null; not counted`;
|
|
567
760
|
} else {
|
|
568
|
-
const why = readOnlyViolation(row.command
|
|
761
|
+
const why = readOnlyViolation(row.command);
|
|
569
762
|
if (why) return `check command not allowed: ${why}; not counted`;
|
|
570
763
|
if (row.tag === 'VERIFIED' && isSilentCommand(row.command)) return 'silent command; excerpt cannot be its output; not counted';
|
|
764
|
+
// B50: a flag claim is proved (VERIFIED or NOT-FOUND) ONLY by `grep -rn -- <flag> <dir>`, its
|
|
765
|
+
// pattern the claimed flag, over an existing source directory of the snapshot; a source grep
|
|
766
|
+
// proves nothing else. A VERIFIED one needs a `file:line:` hit in a source file under that
|
|
767
|
+
// directory holding the flag as a whole token (never the brief, a doc or a test: the claim would
|
|
768
|
+
// prove itself); a NOT-FOUND one needs one whole top-level source folder
|
|
769
|
+
const grep = sourceGrepOf(row.command);
|
|
770
|
+
if (grep !== null && (claim.kind !== 'flag' || grep.pattern !== claim.token)) return SOURCE_GREP_CLAIM_MESSAGE;
|
|
771
|
+
if (claim.kind === 'flag' && (grep === null || !isSourceFolder(grep.path, ctx.cwd, false))) return FLAG_PROOF_MESSAGE;
|
|
772
|
+
if (grep !== null && row.tag === 'VERIFIED' && !sourceHit(row.output_excerpt, grep.pattern, grep.path, ctx.briefRel)) return SOURCE_GREP_HIT_MESSAGE;
|
|
773
|
+
if (grep !== null && row.tag === 'NOT-FOUND' && !isSourceFolder(grep.path, ctx.cwd, true)) return SOURCE_GREP_SCOPE_MESSAGE;
|
|
571
774
|
}
|
|
572
775
|
if (row.tag === 'VERIFIED' && row.output_excerpt.trim().length === 0) return 'VERIFIED with an empty output excerpt; not counted';
|
|
573
776
|
return null;
|
|
@@ -584,10 +787,13 @@ function ruleBroken(row, claim, clis) {
|
|
|
584
787
|
* empty excerpt or from a silent `test`) becomes UNVERIFIABLE with `why` = the rule and is listed
|
|
585
788
|
* in `downgraded`. Every row shows the CANONICAL claim token and kind.
|
|
586
789
|
* @param {unknown} answer @param {ReadonlyArray<Claim>} claims
|
|
790
|
+
* @param {{briefRel?: string, cwd?: string}} [opts] - `briefRel`: the brief's path relative to the
|
|
791
|
+
* delegate's cwd (a source-grep hit in it never counts); `cwd`: that cwd (the project snapshot),
|
|
792
|
+
* where a NOT-FOUND source grep's folder must exist as a directory (none ⇒ no NOT-FOUND grep counts).
|
|
587
793
|
* @returns {{facts: Fact[], downgraded: Downgrade[]}} the rows in claim order, and the downgrades.
|
|
588
794
|
* @throws {FactsError} `refused` when the answer does not match the schema.
|
|
589
795
|
*/
|
|
590
|
-
export function validateFacts(answer, claims) {
|
|
796
|
+
export function validateFacts(answer, claims, opts = {}) {
|
|
591
797
|
if (!validateFile(answer)) {
|
|
592
798
|
const where = (validateFile.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`).slice(0, 5);
|
|
593
799
|
throw new FactsError('refused', `facts answer does not match facts.schema.json: ${where.join('; ')}`);
|
|
@@ -597,7 +803,6 @@ export function validateFacts(answer, claims) {
|
|
|
597
803
|
const facts = [];
|
|
598
804
|
/** @type {Downgrade[]} */
|
|
599
805
|
const downgraded = [];
|
|
600
|
-
const clis = claimClis(claims);
|
|
601
806
|
// each row answers at most ONE claim: pass 1 anchors rows by fact_id (with the kind, else with the
|
|
602
807
|
// echoed claim); pass 2 gives the rows left over to claims still open, by kind + echoed claim
|
|
603
808
|
/** @type {Set<Fact>} */
|
|
@@ -623,7 +828,7 @@ export function validateFacts(answer, claims) {
|
|
|
623
828
|
return;
|
|
624
829
|
}
|
|
625
830
|
const base = { fact_id: id, claim: redact(claim.token), kind: claim.kind, command: redact(row.command), output_excerpt: claim.kind === 'env' ? '' : redact(row.output_excerpt) };
|
|
626
|
-
const broken = ruleBroken(row, claim,
|
|
831
|
+
const broken = ruleBroken(row, claim, opts);
|
|
627
832
|
if (broken) {
|
|
628
833
|
const reason = redact(broken);
|
|
629
834
|
downgraded.push({ fact_id: id, reason });
|
|
@@ -718,20 +923,22 @@ export function checkSheetFresh(sheetText, briefPath) {
|
|
|
718
923
|
const RULE = [
|
|
719
924
|
'# Facts delegate',
|
|
720
925
|
'',
|
|
721
|
-
'For each claim below, run the cheapest READ-ONLY check (
|
|
722
|
-
'
|
|
723
|
-
'`npm view <name> version`). Quote the exact command and the output line it printed (at most 200',
|
|
926
|
+
'For each claim below, run the cheapest READ-ONLY check (`grep -rn -- <flag> <path>`, `which <cli>`,',
|
|
927
|
+
'`ls <path>`, `npm view <name> version`). Quote the exact command and the output line it printed (at most 200',
|
|
724
928
|
'characters). Tag each claim VERIFIED (the output shows it), NOT-FOUND (the check ran and it is absent) or',
|
|
725
929
|
'UNVERIFIABLE (no read-only check can decide it — say why in `why`). Never infer, never guess: the excerpt is the',
|
|
726
930
|
"command's literal output, never your reading of it. For a path use `ls <path>` (it prints the path); `test` prints",
|
|
727
|
-
'nothing, so a `test` check can never be VERIFIED.
|
|
728
|
-
'
|
|
931
|
+
'nothing, so a `test` check can never be VERIFIED. A flag is checked in the source text of the project, never by',
|
|
932
|
+
'running a program: `grep -rn -- <flag> <path>` (`<path>` a top-level source folder such as `src`, never `.`,',
|
|
933
|
+
'the root, docs, plans or tests) — VERIFIED only with a hit line (`file:line:text`) from a source file (not',
|
|
934
|
+
'Markdown or text, not docs, plans, tests or fixtures) that contains the flag, as the excerpt. Never run a program with `--help`, `-h` or `--version`: a version is checked with `npm view <name>',
|
|
935
|
+
'version`, `which` or `ls`. Run one command at a time, never a pipe. Only these forms are accepted: which,',
|
|
936
|
+
'command -v, ls, cat, head, tail, wc, grep (options only -n -c -i -w -F; recursive only as',
|
|
937
|
+
'`grep -rn -- <flag> <path>`),',
|
|
729
938
|
'find (only -name -iname -path -ipath -type -maxdepth -mindepth -print -print0 -newer -size -empty -mtime -mmin',
|
|
730
|
-
'-not ! -a -o -and -or, plus paths), stat, file, uname, sw_vers, jq,
|
|
731
|
-
'log|show|status|rev-parse|ls-files|cat-file, npm view|ls,
|
|
732
|
-
'
|
|
733
|
-
'command claim below, never an interpreter, shell or file tool; a lowercase subcommand word; no other pipe or',
|
|
734
|
-
'argument). A check that breaks these rules is not counted.',
|
|
939
|
+
'-not ! -a -o -and -or, plus paths), stat, file, uname, sw_vers, jq, git',
|
|
940
|
+
'log|show|status|rev-parse|ls-files|cat-file, npm view|ls, test -e|-f|-d|-n, `curl -sI <one URL>`.',
|
|
941
|
+
'A check that breaks these rules is not counted.',
|
|
735
942
|
'No `$`, no backticks, no redirection, no unquoted glob, no `~`, no absolute path outside /usr, /opt/homebrew or',
|
|
736
943
|
'/bin, no secret files.',
|
|
737
944
|
'An env claim is checked ONLY with `printenv NAME >/dev/null` (quote its exit status; the value is never shown).',
|
|
@@ -966,7 +1173,15 @@ export async function buildSnapshot(projectDir, sources, snapDir, workDir, run =
|
|
|
966
1173
|
*/
|
|
967
1174
|
export async function buildFacts(opts, deps = {}) {
|
|
968
1175
|
const brief = readBriefOnce(opts.briefPath);
|
|
969
|
-
|
|
1176
|
+
// B50: a command claim written without its CLI gets the project's own (package.json `bin`)
|
|
1177
|
+
const briefText = brief.bytes.toString('utf8');
|
|
1178
|
+
// prefix; a broken package.json only drops that optional prefix, never the whole sheet.
|
|
1179
|
+
let projectBins = [];
|
|
1180
|
+
if (opts.projectDir !== undefined) {
|
|
1181
|
+
try { projectBins = readProjectBins(opts.projectDir); } catch { projectBins = []; }
|
|
1182
|
+
}
|
|
1183
|
+
const extractOpts = opts.projectDir === undefined ? {} : { projectBins, briefText };
|
|
1184
|
+
const claims = extractClaims([briefText, ...readSourcesText(opts.sources ?? [])].join('\n'), extractOpts);
|
|
970
1185
|
const root = opts.runRoot ?? currentRunRoot();
|
|
971
1186
|
// B9b.1: a run root inside a git work tree (`tmp.root` configured in the project) cannot hold
|
|
972
1187
|
// the snapshot — git and the harness would walk up into the live repo — so the facts area moves
|
|
@@ -996,7 +1211,10 @@ export async function buildFacts(opts, deps = {}) {
|
|
|
996
1211
|
deps,
|
|
997
1212
|
);
|
|
998
1213
|
if (result.status !== 'ok') return { status: result.status, reason: result.reason ?? null, claims };
|
|
999
|
-
|
|
1214
|
+
// the delegate's cwd is the project (snapshot): the brief, seen from there
|
|
1215
|
+
const briefRel = opts.projectDir === undefined ? undefined : path.relative(realOrResolved(opts.projectDir), realOrResolved(opts.briefPath)).split(path.sep).join('/');
|
|
1216
|
+
// validated while the snapshot still exists (it is removed in `finally`)
|
|
1217
|
+
const { facts, downgraded } = validateFacts(result.answer, claims, { briefRel, ...(cwd === undefined ? {} : { cwd }) });
|
|
1000
1218
|
const now = (opts.now ?? (() => new Date()))();
|
|
1001
1219
|
const rel = path.relative(path.dirname(opts.outPath), opts.briefPath).split(path.sep).join('/');
|
|
1002
1220
|
const text = renderSheet({
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* `acceptance` columns; the wave is the nearest heading that says `Wave <n>`) and checks:
|
|
6
6
|
* - a facts sheet is present (pasted in the plan's §0 or given as `--facts`) and was built from
|
|
7
7
|
* the brief as it is now; a separate sheet's `brief_sha256` line must also appear in the plan;
|
|
8
|
-
* - every acceptance clause (the cell split on `;`
|
|
9
|
-
* VERIFIED is listed in the "… the facts sheet cannot
|
|
10
|
-
* token, the block, and a tolerance
|
|
8
|
+
* - every acceptance clause (the cell split on `;` and on ` · (n) ` clause markers, B50) whose
|
|
9
|
+
* claim tokens the sheet does not mark VERIFIED is listed in the "… the facts sheet cannot
|
|
10
|
+
* back" section by a line that names the token, the block, and a tolerance (a list item saying
|
|
11
|
+
* `tolerance`, or a row of an older-template table with a non-empty tolerance column) — else
|
|
12
|
+
* `unbackable clause without tolerance: <block> <clause>`;
|
|
11
13
|
* - a caller map section exists and names every block (C9);
|
|
12
14
|
* - the required sections are found by their exact headings (`plan-sections.mjs`); a near miss at
|
|
13
15
|
* the expected position is accepted with a WARN naming the exact heading (B36);
|
|
@@ -190,6 +192,65 @@ const listItems = (body) =>
|
|
|
190
192
|
.filter((l) => /^\s*(?:[-*]|\d+\.)\s+/.test(l))
|
|
191
193
|
.map((l) => l.trim());
|
|
192
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The tolerance lines of the unbackable-clauses section (B50): every list item that says
|
|
197
|
+
* `tolerance` (the form `templates/plan.md` shows), plus — so plans written from the older table
|
|
198
|
+
* template keep passing — every data row of a table in the section whose HEADER (its first row,
|
|
199
|
+
* never a later one) has a `tolerance` cell, when that row's tolerance cell is filled: empty and
|
|
200
|
+
* the placeholders in {@link EMPTY_CELLS} do not count.
|
|
201
|
+
* @param {string} body @returns {string[]}
|
|
202
|
+
*/
|
|
203
|
+
export function toleranceLines(body) {
|
|
204
|
+
const out = listItems(body).filter((l) => /\btolerance\b/i.test(l));
|
|
205
|
+
/** @type {number | null} null: not in a table; -1: in a table without a tolerance column. */
|
|
206
|
+
let col = null;
|
|
207
|
+
for (const raw of body.split('\n')) {
|
|
208
|
+
const line = raw.trim();
|
|
209
|
+
if (!line.startsWith('|')) {
|
|
210
|
+
col = null;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const row = cells(line);
|
|
214
|
+
if (col === null) {
|
|
215
|
+
col = row.findIndex((c) => /\btolerance\b/i.test(plain(c)));
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (col < 0 || row.every((c) => /^:?-+:?$/.test(c))) continue; // no tolerance column, or the separator row
|
|
219
|
+
if (!EMPTY_CELLS.has(plain(row[col] ?? '').toLowerCase())) out.push(line);
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Tolerance cells that say nothing (compared lower-cased, after trimming). */
|
|
225
|
+
const EMPTY_CELLS = new Set(['', '—', '–', '-', '--', '…', '...', 'n/a']);
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Is a clause's claim backed by a VERIFIED fact? Its token is, or (B50) a command claim written
|
|
229
|
+
* without its CLI (`run reload`) matches the sheet's token that `forge facts` prefixed with the
|
|
230
|
+
* project's CLI (`code-forge run reload`: exactly one more leading word, and that word one of the
|
|
231
|
+
* project's own CLIs — `projectBins`).
|
|
232
|
+
* @param {{token: string, kind: string}} claim @param {ReadonlySet<string>} verified
|
|
233
|
+
* @param {() => ReadonlyArray<string>} projectBins - called only when a command claim needs it.
|
|
234
|
+
* @returns {boolean}
|
|
235
|
+
*/
|
|
236
|
+
function isVerified(claim, verified, projectBins) {
|
|
237
|
+
if (verified.has(claim.token)) return true;
|
|
238
|
+
if (claim.kind !== 'command') return false;
|
|
239
|
+
return projectBins().some((bin) => verified.has(`${bin} ${claim.token}`));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The clauses of an acceptance cell (B50): split on `;` and on a ` · (n) ` clause marker (the
|
|
244
|
+
* template numbers clauses `(1) … · (2) …`); a `·` not followed by `(n)` stays inside its clause.
|
|
245
|
+
* @param {string} acceptance @returns {string[]}
|
|
246
|
+
*/
|
|
247
|
+
export function splitClauses(acceptance) {
|
|
248
|
+
return acceptance
|
|
249
|
+
.split(/;|\s·\s+(?=\(\d+\))/)
|
|
250
|
+
.map((c) => plain(c))
|
|
251
|
+
.filter((c) => c.length > 0);
|
|
252
|
+
}
|
|
253
|
+
|
|
193
254
|
/** Where a lane may come from: Jev's `lane` answer, or the deterministic fallback rule (`--rules`). */
|
|
194
255
|
export const LANE_SOURCES = Object.freeze(['jev', 'rules']);
|
|
195
256
|
|
|
@@ -233,6 +294,10 @@ export function recordedLanes(rows, planName) {
|
|
|
233
294
|
* @property {string} [planPath] - where the plan lives (resolves the sheet's brief reference).
|
|
234
295
|
* @property {string} [factsText] @property {string} [factsPath] - a separate sheet (else the plan's own §0).
|
|
235
296
|
* @property {Record<string, any>} [cfg] - `caps.coders`, `budget.block_cases`, `budget.block_lines`.
|
|
297
|
+
* @property {ReadonlyArray<string> | (() => ReadonlyArray<string>)} [projectBins] - B50: the
|
|
298
|
+
* project's own CLI names (`readProjectBins` of the project root), or a function giving them —
|
|
299
|
+
* called at most once, and only when a command claim is not VERIFIED as written; a clause's
|
|
300
|
+
* `run reload` is then backed by a VERIFIED `<bin> run reload`.
|
|
236
301
|
* @property {Array<Record<string, any>>} [decisions] - the project's ledger rows (the recorded lanes);
|
|
237
302
|
* none given ⇒ no block has a recorded lane.
|
|
238
303
|
*/
|
|
@@ -282,10 +347,14 @@ export function checkPlan(text, opts = {}) {
|
|
|
282
347
|
// unbackable clauses
|
|
283
348
|
const unbackable = required(REQUIRED_SECTIONS.unbackable);
|
|
284
349
|
if (!unbackable) errors.push('unbackable-clauses section missing (write "none" when there is none)');
|
|
285
|
-
const tolerances = unbackable ?
|
|
350
|
+
const tolerances = unbackable ? toleranceLines(unbackable.body) : [];
|
|
351
|
+
/** @type {ReadonlyArray<string> | undefined} */
|
|
352
|
+
let binsCache;
|
|
353
|
+
const given = opts.projectBins;
|
|
354
|
+
const projectBins = () => (binsCache ??= typeof given === 'function' ? given() : given ?? []);
|
|
286
355
|
for (const b of blocks) {
|
|
287
|
-
for (const clause of b.acceptance
|
|
288
|
-
const unbacked = extractClaims(clause).filter((c) => !
|
|
356
|
+
for (const clause of splitClauses(b.acceptance)) {
|
|
357
|
+
const unbacked = extractClaims(clause).filter((c) => !isVerified(c, verified, projectBins));
|
|
289
358
|
if (unbacked.every((c) => tolerances.some((t) => t.includes(c.token) && idRe(b.id).test(t)))) continue;
|
|
290
359
|
errors.push(`unbackable clause without tolerance: ${b.id} ${clause}`);
|
|
291
360
|
}
|