@codedology/code-forge 0.15.3 → 0.16.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/CHANGELOG.md +51 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +9 -1
- package/docs/reference/config.md +11 -1
- package/native/README.md +10 -0
- package/native/bin/darwin-arm64/code-forge-native +0 -0
- package/native/bin/darwin-x64/code-forge-native +0 -0
- package/native/bin/linux-arm64/code-forge-native +0 -0
- package/native/bin/linux-x64/code-forge-native +0 -0
- package/package.json +7 -2
- package/schema/code-forge.schema.json +26 -1
- package/schema/facts.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/autopilot.md +6 -1
- package/skill/references/code.md +37 -0
- package/skill/references/decisions.md +1 -1
- package/skill/references/plan.md +18 -5
- package/skill/references/review.md +47 -9
- package/skill/scripts/forge +1 -1
- package/skill/templates/facts-sheet.md +9 -3
- package/src/cli/block.mjs +284 -11
- package/src/cli/doctor.mjs +32 -1
- package/src/cli/help.mjs +1 -0
- package/src/cli/native-status.mjs +13 -0
- package/src/cli/review-file.mjs +126 -14
- package/src/cli/spawn.mjs +26 -22
- package/src/cli/status.mjs +80 -14
- package/src/decide/escalation.mjs +5 -5
- package/src/doctor/index.mjs +11 -0
- package/src/doctor/worker-probe.mjs +1 -1
- package/src/gates/dependents.mjs +200 -0
- package/src/gates/expand.mjs +83 -26
- package/src/gates/run.mjs +76 -14
- package/src/ledger/tail.mjs +89 -0
- package/src/review/agent-waiver.mjs +95 -8
- package/src/review/definitions.mjs +170 -0
- package/src/review/fixloop.mjs +4 -4
- package/src/review/gate-check.mjs +33 -13
- package/src/review/model-stats.mjs +125 -0
- package/src/review/packet.mjs +111 -22
- package/src/review/reopen.mjs +38 -13
- package/src/review/route.mjs +58 -4
- package/src/session/facts.mjs +252 -70
- package/src/session/plan-check.mjs +4 -1
- package/src/session/spawn.mjs +70 -19
- package/src/state/block-end.mjs +3 -1
- package/src/state/block.mjs +56 -21
- package/src/state/run.mjs +12 -5
- package/src/util/native.mjs +133 -0
- package/src/worker/engine.mjs +11 -26
- package/src/worker/loop.mjs +150 -20
- package/src/worker/queue.mjs +3 -2
- package/src/worker/review-state.mjs +15 -3
- package/src/worker/watch.mjs +97 -83
- package/src/worker/worker-warning.mjs +35 -0
package/src/session/facts.mjs
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* The facts rule (plan §3.8, C16; block B9b): `forge facts` builds the facts sheet a Plan job
|
|
3
3
|
* must read before it designs anything.
|
|
4
4
|
*
|
|
5
|
-
* Step 1 (deterministic): {@link extractClaims} lists
|
|
6
|
-
*
|
|
7
|
-
* paths, versions, http(s) endpoints, `@scope/name` packages and environment variables.
|
|
5
|
+
* Step 1 (deterministic): {@link extractClaims} lists claim tokens from the brief alone — flags,
|
|
6
|
+
* commands, paths, versions, endpoints, packages, environment variables and opt-in code symbols.
|
|
8
7
|
* Step 2: ONE L0 `facts` session (closed-book; Claude gets `--tools "Bash"` with the forbidden list
|
|
9
8
|
* rendered, §5.2) whose packet is the claim list and the rule "run the cheapest read-only check,
|
|
10
9
|
* quote the command and the output line, tag it, never infer". B9b.1: when the caller names the
|
|
11
10
|
* project (`projectDir`), the session runs in a READ-ONLY SNAPSHOT of it ({@link buildSnapshot}:
|
|
12
|
-
* `git archive HEAD` plus
|
|
11
|
+
* `git archive HEAD` plus in-project sources and outside roots under `_sources/<label>`, symlinks and secret-looking files
|
|
13
12
|
* dropped, every file 0444 and directory 0555) so `./…` path claims can be checked; the snapshot
|
|
14
13
|
* lives under the run root — or, when that root itself sits inside a git work tree, under a
|
|
15
14
|
* private B0.1 root in `os.tmpdir()` (`factsTmpRoot`) — and is removed when the verb ends;
|
|
@@ -43,13 +42,15 @@ import { redact } from '../util/redact.mjs';
|
|
|
43
42
|
import { currentRunRoot } from '../util/tmp.mjs';
|
|
44
43
|
import { factsTmpRoot, gitWorkTreeAncestor, spawnSession } from './spawn.mjs';
|
|
45
44
|
|
|
46
|
-
/** @typedef {'flag'|'command'|'path'|'version'|'endpoint'|'package'|'env'} ClaimKind */
|
|
47
|
-
/** @typedef {{
|
|
45
|
+
/** @typedef {'flag'|'command'|'path'|'version'|'endpoint'|'package'|'env'|'symbol'} ClaimKind */
|
|
46
|
+
/** @typedef {{dir: string, label: string, prefix: string, file?: string}} SourceRoot */
|
|
47
|
+
/** @typedef {{token: string, kind: ClaimKind, source?: string, checkPath?: string, root?: SourceRoot, unavailable?: string}} Claim */
|
|
48
48
|
/**
|
|
49
49
|
* @typedef {object} Fact
|
|
50
50
|
* @property {string} fact_id @property {string} claim @property {ClaimKind} kind
|
|
51
51
|
* @property {string} command @property {string} output_excerpt
|
|
52
52
|
* @property {'VERIFIED'|'NOT-FOUND'|'UNVERIFIABLE'} tag @property {string | null} why
|
|
53
|
+
* @property {string} [source]
|
|
53
54
|
*/
|
|
54
55
|
|
|
55
56
|
export const FACTS_SCHEMA_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'schema', 'facts.schema.json');
|
|
@@ -104,9 +105,9 @@ export class FactsError extends Error {
|
|
|
104
105
|
const PATTERNS = /** @type {ReadonlyArray<[ClaimKind, RegExp]>} */ ([
|
|
105
106
|
['endpoint', /https?:\/\/[^\s`'"<>)\]|]+/g],
|
|
106
107
|
['package', /(?<![\w./@-])@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*/g],
|
|
107
|
-
['path', /(?<![\w/.~-])(?:~|\.{1,2})
|
|
108
|
+
['path', /(?<![\w/.~-])(?:~|\.{1,2}|[\w.-]+)?\/[^\s`'"<>)\]|,;]*/g],
|
|
108
109
|
['flag', /(?<![\w-])--[a-z][a-z0-9-]*/g],
|
|
109
|
-
['version', /(?<![\w.§])\d+\.\d+(?:\.\d+)*/g],
|
|
110
|
+
['version', /(?<![\w.§])(?<!§ )\d+\.\d+(?:\.\d+)*/g],
|
|
110
111
|
['env', /(?<![\w])\$?[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?![\w])/g],
|
|
111
112
|
]);
|
|
112
113
|
|
|
@@ -122,6 +123,9 @@ const trimTail = (token) => token.replace(/[.,:;]+$/, '');
|
|
|
122
123
|
* `bin` keys, {@link readProjectBins}); none ⇒ command claims are kept as written.
|
|
123
124
|
* @property {string} [briefText] - the brief alone (no sources): only IT can name the project CLI
|
|
124
125
|
* that a command claim gets prefixed with (default: the whole text).
|
|
126
|
+
* @property {string} [projectDir] - project root whose top-level folders admit extensionless paths.
|
|
127
|
+
* @property {boolean} [symbols] - whole code spans naming code identifiers or code file names.
|
|
128
|
+
* @property {boolean} [globPaths] - keep shell glob characters in path tokens (facts only).
|
|
125
129
|
*/
|
|
126
130
|
|
|
127
131
|
/**
|
|
@@ -205,6 +209,8 @@ export function readProjectBins(projectDir) {
|
|
|
205
209
|
*/
|
|
206
210
|
export function extractClaims(text, opts = {}) {
|
|
207
211
|
let masked = String(text);
|
|
212
|
+
const topLevelFolders = new Set(opts.projectDir === undefined ? [] : readdirSync(opts.projectDir, { withFileTypes: true })
|
|
213
|
+
.filter((entry) => entry.isDirectory()).map((entry) => entry.name));
|
|
208
214
|
/** @type {Array<{index: number, token: string, kind: ClaimKind}>} */
|
|
209
215
|
const found = [];
|
|
210
216
|
/** @param {number} start @param {number} length */
|
|
@@ -215,15 +221,24 @@ export function extractClaims(text, opts = {}) {
|
|
|
215
221
|
const scan = (kind, re) => {
|
|
216
222
|
for (const m of [...masked.matchAll(re)]) {
|
|
217
223
|
const token = trimTail(kind === 'env' ? m[0].replace(/^\$/, '') : m[0]);
|
|
218
|
-
if (token.length === 0 || (kind === 'path' && /^(~|\.{1,2})
|
|
224
|
+
if (token.length === 0 || (kind === 'path' && /^(~|\.{1,2})?\/$/.test(token))) continue;
|
|
225
|
+
if (kind === 'path' && !/^(~|\.{1,2})?\//.test(token)
|
|
226
|
+
&& !(opts.globPaths && /[*?\[\]{}]/.test(token))
|
|
227
|
+
&& !/\.[a-zA-Z0-9][\w-]*$/.test(token) && !topLevelFolders.has(token.split('/')[0])) continue;
|
|
219
228
|
found.push({ index: /** @type {number} */ (m.index), token, kind });
|
|
220
229
|
mask(/** @type {number} */ (m.index), m[0].length);
|
|
221
230
|
}
|
|
222
231
|
};
|
|
223
232
|
scan(...PATTERNS[0]);
|
|
224
233
|
scan(...PATTERNS[1]);
|
|
225
|
-
scan(
|
|
234
|
+
scan('path', opts.globPaths ? /(?<![\w/.~-])(?:~|\.{1,2}|[\w.-]+)?\/[^\s`'"<>)|;]*/g : PATTERNS[2][1]);
|
|
226
235
|
for (const m of [...masked.matchAll(/`([^`\n]+)`/g)]) {
|
|
236
|
+
const symbol = m[1].trim();
|
|
237
|
+
if (opts.symbols && (IDENTIFIER_SYMBOL.test(symbol) || FILE_SYMBOL.test(symbol))) {
|
|
238
|
+
found.push({ index: /** @type {number} */ (m.index) + 1, token: symbol, kind: 'symbol' });
|
|
239
|
+
mask(/** @type {number} */ (m.index) + 1, m[1].length);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
227
242
|
const cmd = COMMAND_IN_SPAN.exec(m[1]);
|
|
228
243
|
if (!cmd) continue;
|
|
229
244
|
const start = /** @type {number} */ (m.index) + 1;
|
|
@@ -244,35 +259,8 @@ export function extractClaims(text, opts = {}) {
|
|
|
244
259
|
return claims;
|
|
245
260
|
}
|
|
246
261
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
* @param {ReadonlyArray<string>} sources
|
|
250
|
-
* @returns {string[]}
|
|
251
|
-
*/
|
|
252
|
-
function readSourcesText(sources) {
|
|
253
|
-
/** @type {string[]} */
|
|
254
|
-
const parts = [];
|
|
255
|
-
for (const source of sources) {
|
|
256
|
-
const st = statSync(source);
|
|
257
|
-
if (st.isDirectory()) {
|
|
258
|
-
for (const name of readdirSync(source).filter((n) => /\.(md|txt)$/.test(n)).sort()) {
|
|
259
|
-
parts.push(readFileSync(path.join(source, name), 'utf8'));
|
|
260
|
-
}
|
|
261
|
-
} else {
|
|
262
|
-
parts.push(readFileSync(source, 'utf8'));
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
return parts;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* The brief's text plus every source it names ({@link readSourcesText}).
|
|
270
|
-
* @param {string} briefPath @param {ReadonlyArray<string>} [sources]
|
|
271
|
-
* @returns {string}
|
|
272
|
-
*/
|
|
273
|
-
export function readClaimText(briefPath, sources = []) {
|
|
274
|
-
return [readFileSync(briefPath, 'utf8'), ...readSourcesText(sources)].join('\n');
|
|
275
|
-
}
|
|
262
|
+
const IDENTIFIER_SYMBOL = /^(?:(?:[A-Z][a-z0-9]+|[A-Z]+(?=[A-Z][a-z]|$)){2,}|[a-z][a-z0-9]*(?:_[a-z0-9]+)+)$/;
|
|
263
|
+
const FILE_SYMBOL = /^[\w.-]+\.(?:php|mjs|cjs|js|ts|tsx|jsx|go|py|rb|swift|kt|java|rs|cs|vue|svelte|sql)$/;
|
|
276
264
|
|
|
277
265
|
/**
|
|
278
266
|
* The brief, read ONCE: its bytes (sha256 and claim text) and its mtime from the same open file.
|
|
@@ -304,12 +292,12 @@ const GLOB_CHARS = new Set(['*', '?', '[', ']', '{', '}']);
|
|
|
304
292
|
* reported instead of parsed, and so is an unquoted `* ? [ ] { }` (a glob expands AFTER the
|
|
305
293
|
* secret-path check and could name what the check never saw): the allow-list never needs one
|
|
306
294
|
* (fail closed).
|
|
307
|
-
* @param {string} command
|
|
295
|
+
* @param {string} command @param {boolean} [allowGlob] - only the exact glob ls proof uses this.
|
|
308
296
|
* @returns {{segments: string[][], separators: string[], pipedOut?: boolean[], refused: string | null}}
|
|
309
297
|
* `separators`: every separator met, in order (`|`, `||`, `&&`, `;`, `&`, newline, CR);
|
|
310
298
|
* `pipedOut[i]`: segment i feeds a single `|` (its output is not the command's output).
|
|
311
299
|
*/
|
|
312
|
-
function splitCommand(command) {
|
|
300
|
+
function splitCommand(command, allowGlob = false) {
|
|
313
301
|
if (/[`]/.test(command)) return { segments: [], separators: [], refused: 'backticks are not allowed' };
|
|
314
302
|
if (/\$/.test(command)) return { segments: [], separators: [], refused: '$ expansion is not allowed' };
|
|
315
303
|
if (/[<>]/.test(command)) return { segments: [], separators: [], refused: 'redirection is not allowed' };
|
|
@@ -353,7 +341,7 @@ function splitCommand(command) {
|
|
|
353
341
|
} else if (c === ' ' || c === '\t') {
|
|
354
342
|
flush();
|
|
355
343
|
} else {
|
|
356
|
-
if (GLOB_CHARS.has(c)) return { segments: [], separators: [], refused: 'unquoted glob characters (* ? [ ] { }) are not allowed' };
|
|
344
|
+
if (!allowGlob && GLOB_CHARS.has(c)) return { segments: [], separators: [], refused: 'unquoted glob characters (* ? [ ] { }) are not allowed' };
|
|
357
345
|
word += c;
|
|
358
346
|
inWord = true;
|
|
359
347
|
}
|
|
@@ -578,7 +566,8 @@ function isSubPath(where) {
|
|
|
578
566
|
function sourceGrep(rest) {
|
|
579
567
|
if (rest.length !== 4 || !GREP_RECURSIVE.has(rest[0]) || rest[1] !== '--') return null;
|
|
580
568
|
const [, , pattern, where] = rest;
|
|
581
|
-
return SOURCE_GREP_FLAG.test(pattern)
|
|
569
|
+
return (SOURCE_GREP_FLAG.test(pattern) || /^(?:(?:class|interface|trait|enum|struct|type|function|func|def) )?[A-Za-z_][A-Za-z0-9_]*$/.test(pattern))
|
|
570
|
+
&& isSubPath(where) ? { pattern, path: where } : null;
|
|
582
571
|
}
|
|
583
572
|
|
|
584
573
|
/**
|
|
@@ -620,16 +609,18 @@ function isSourceFile(file, briefRel) {
|
|
|
620
609
|
* path, is a source file ({@link isSourceFile}) and whose text holds `flag` as a whole token
|
|
621
610
|
* ({@link holdsFlag})? A hit line without the `file:line:` prefix never counts.
|
|
622
611
|
* @param {string} excerpt @param {string} flag @param {string} under - the grep's path.
|
|
623
|
-
* @param {string | undefined} briefRel
|
|
612
|
+
* @param {string | undefined} briefRel @param {boolean} [symbol] @param {string} [prefix]
|
|
624
613
|
* @returns {boolean}
|
|
625
614
|
*/
|
|
626
|
-
function sourceHit(excerpt, flag, under, briefRel) {
|
|
615
|
+
function sourceHit(excerpt, flag, under, briefRel, symbol = false, prefix = '') {
|
|
627
616
|
const scope = relPosix(under);
|
|
628
617
|
return excerpt.split(/\r?\n/).some((line) => {
|
|
629
618
|
const hit = /^([^:\n]+):(\d+):(.*)$/.exec(line.trim());
|
|
630
619
|
if (hit === null) return false;
|
|
631
620
|
const file = relPosix(hit[1]);
|
|
632
|
-
|
|
621
|
+
const sourceFile = prefix ? file.slice(prefix.length + 1) : file;
|
|
622
|
+
return (file === scope || file.startsWith(`${scope}/`)) && file !== briefRel && isSourceFile(sourceFile, prefix ? undefined : briefRel)
|
|
623
|
+
&& (symbol ? new RegExp(`\\b${flag}\\b`).test(hit[3]) : holdsFlag(hit[3], flag));
|
|
633
624
|
});
|
|
634
625
|
}
|
|
635
626
|
|
|
@@ -638,14 +629,15 @@ function sourceHit(excerpt, flag, under, briefRel) {
|
|
|
638
629
|
* hidden (`.x`) or one of {@link NON_SOURCE_FOLDERS}, and — with `topLevel` — one single name (a
|
|
639
630
|
* NOT-FOUND proves absence only over a whole top-level folder). A file (`package.json`,
|
|
640
631
|
* `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
|
|
632
|
+
* @param {string} where @param {string | undefined} cwd @param {boolean} topLevel @param {string} [prefix]
|
|
642
633
|
* @returns {boolean}
|
|
643
634
|
*/
|
|
644
|
-
function isSourceFolder(where, cwd, topLevel) {
|
|
635
|
+
function isSourceFolder(where, cwd, topLevel, prefix = '') {
|
|
645
636
|
const rel = relPosix(where);
|
|
646
|
-
|
|
637
|
+
if (prefix && !rel.startsWith(`${prefix}/`)) return false;
|
|
638
|
+
const segs = (prefix ? rel.slice(prefix.length + 1) : rel).split('/');
|
|
647
639
|
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;
|
|
640
|
+
if (segs.some((seg) => seg === '_sources' || seg.startsWith('.') || NON_SOURCE_FOLDERS.has(seg))) return false;
|
|
649
641
|
try {
|
|
650
642
|
return statSync(path.join(cwd, rel)).isDirectory();
|
|
651
643
|
} catch {
|
|
@@ -733,6 +725,45 @@ const SOURCE_GREP_CLAIM_MESSAGE = 'a source grep counts only for a flag claim, w
|
|
|
733
725
|
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
726
|
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
727
|
const FLAG_PROOF_MESSAGE = 'a flag claim is proved only by grep -rn -- <flag> <source folder>; not counted';
|
|
728
|
+
const SYMBOL_PROOF_MESSAGE = 'a symbol claim needs grep -rn -- <quoted token or keyword token> <top-level source folder> with a file:line: source hit, or find <root> -name <file name>; not counted';
|
|
729
|
+
|
|
730
|
+
/** @param {Fact} row @param {Claim} claim @param {{briefRel?: string, cwd?: string}} ctx */
|
|
731
|
+
function symbolRule(row, claim, ctx) {
|
|
732
|
+
if (readOnlyViolation(row.command)) return SYMBOL_PROOF_MESSAGE;
|
|
733
|
+
const prefix = claim.root?.prefix ?? '';
|
|
734
|
+
if (FILE_SYMBOL.test(claim.token)) {
|
|
735
|
+
const { segments } = splitCommand(row.command);
|
|
736
|
+
const argv = segments[0] ?? [];
|
|
737
|
+
if (argv.length !== 4 || argv[0] !== 'find' || argv[1] !== (prefix || '.') || argv[2] !== '-name' || argv[3] !== claim.token) return SYMBOL_PROOF_MESSAGE;
|
|
738
|
+
if (row.tag === 'VERIFIED' && !row.output_excerpt.split(/\r?\n/).some((line) => {
|
|
739
|
+
const file = relPosix(line.trim());
|
|
740
|
+
return line.trim().endsWith(`/${claim.token}`) && (prefix ? file.startsWith(`${prefix}/`) : !file.startsWith('_sources/')) && isSubPath(file);
|
|
741
|
+
})) return SYMBOL_PROOF_MESSAGE;
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
const grep = sourceGrepOf(row.command);
|
|
745
|
+
if (!grep || !/^grep -rn -- (['"])[^'"]+\1(?:\s|$)/.test(row.command)
|
|
746
|
+
|| !(grep.pattern === claim.token || /^(class|interface|trait|enum|struct|type|function|func|def) /.test(grep.pattern) && grep.pattern.split(' ')[1] === claim.token)
|
|
747
|
+
|| !isSourceFolder(grep.path, ctx.cwd, true, prefix)) return SYMBOL_PROOF_MESSAGE;
|
|
748
|
+
if (row.tag === 'VERIFIED' && !sourceHit(row.output_excerpt, claim.token, grep.path, ctx.briefRel, true, prefix)) return SYMBOL_PROOF_MESSAGE;
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** @param {Fact} row @param {Claim} claim */
|
|
753
|
+
function globRule(row, claim) {
|
|
754
|
+
const token = claim.checkPath ?? claim.token;
|
|
755
|
+
const { segments, separators, refused } = splitCommand(row.command, true);
|
|
756
|
+
const argv = segments[0] ?? [];
|
|
757
|
+
const ls = argv.length === 3 && argv[0] === 'ls' && argv[1] === '-d' && argv[2] === token;
|
|
758
|
+
const find = !/[*?\[\]{}]/.test(path.posix.dirname(token))
|
|
759
|
+
&& argv.length === 4 && argv[0] === 'find' && argv[1] === path.posix.dirname(token)
|
|
760
|
+
&& argv[2] === '-name' && argv[3] === path.posix.basename(token) && readOnlyViolation(row.command) === null
|
|
761
|
+
&& /-name (['"])[^'"]+\1$/.test(row.command);
|
|
762
|
+
if (refused || separators.length || segments.length !== 1 || (!ls && !find) || segmentViolation(argv)) return 'a glob needs find <literal dir> -name <quoted pattern> or ls -d <exact glob>; not counted';
|
|
763
|
+
if (row.tag === 'NOT-FOUND') return /No such file/i.test(row.output_excerpt) ? null : 'a NOT-FOUND glob needs No such file output; not counted';
|
|
764
|
+
return row.output_excerpt.split(/\r?\n/).some((line) => path.matchesGlob(relPosix(line.trim()), relPosix(token)))
|
|
765
|
+
? null : 'VERIFIED glob without a matching output path; not counted';
|
|
766
|
+
}
|
|
736
767
|
|
|
737
768
|
/**
|
|
738
769
|
* Does a grep hit line hold `flag` as a whole token — not preceded by a word character or `-`, not
|
|
@@ -754,6 +785,11 @@ function holdsFlag(excerpt, flag) {
|
|
|
754
785
|
function ruleBroken(row, claim, ctx) {
|
|
755
786
|
if (row.tag === 'UNVERIFIABLE') return null;
|
|
756
787
|
if (row.command.trim().length === 0) return `${row.tag} without a check command; not counted`;
|
|
788
|
+
if (claim.kind === 'path' && (claim.token.startsWith('~') || /(^|\/)\.\.(\/|$)/.test(claim.token))) {
|
|
789
|
+
return `check command not allowed: ${pathViolation(claim.token)}; not counted`;
|
|
790
|
+
}
|
|
791
|
+
if (claim.kind === 'symbol') return symbolRule(row, claim, ctx);
|
|
792
|
+
if (claim.kind === 'path' && /[*?\[\]{}]/.test(claim.token)) return globRule(row, claim);
|
|
757
793
|
if (claim.kind === 'env') {
|
|
758
794
|
const env = ENV_CHECK.exec(row.command);
|
|
759
795
|
if (!env || env[1] !== claim.token) return `an env claim may only be checked with printenv ${claim.token} >/dev/null; not counted`;
|
|
@@ -768,9 +804,9 @@ function ruleBroken(row, claim, ctx) {
|
|
|
768
804
|
// prove itself); a NOT-FOUND one needs one whole top-level source folder
|
|
769
805
|
const grep = sourceGrepOf(row.command);
|
|
770
806
|
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;
|
|
807
|
+
if (claim.kind === 'flag' && (grep === null || !isSourceFolder(grep.path, ctx.cwd, false, claim.root?.prefix))) return FLAG_PROOF_MESSAGE;
|
|
808
|
+
if (grep !== null && row.tag === 'VERIFIED' && !sourceHit(row.output_excerpt, grep.pattern, grep.path, ctx.briefRel, false, claim.root?.prefix)) return SOURCE_GREP_HIT_MESSAGE;
|
|
809
|
+
if (grep !== null && row.tag === 'NOT-FOUND' && !isSourceFolder(grep.path, ctx.cwd, true, claim.root?.prefix)) return SOURCE_GREP_SCOPE_MESSAGE;
|
|
774
810
|
}
|
|
775
811
|
if (row.tag === 'VERIFIED' && row.output_excerpt.trim().length === 0) return 'VERIFIED with an empty output excerpt; not counted';
|
|
776
812
|
return null;
|
|
@@ -778,7 +814,8 @@ function ruleBroken(row, claim, ctx) {
|
|
|
778
814
|
|
|
779
815
|
/**
|
|
780
816
|
* Validate the delegate's answer (step 3). Null commands/excerpts are normalized on a copy and
|
|
781
|
-
* their matched claims downgraded;
|
|
817
|
+
* their matched claims downgraded; long excerpts are capped without changing the tag. Every other
|
|
818
|
+
* schema-invalid answer is refused. A row answers
|
|
782
819
|
* claim `F<n>` when its fact_id is `F<n>` and its kind is the claim's kind (the id is the anchor,
|
|
783
820
|
* the claim text may be reworded); failing that, when its echoed claim ({@link echoedClaim})
|
|
784
821
|
* equals the claim token and its fact_id is `F<n>` or its kind is the claim's kind. Claims the
|
|
@@ -802,9 +839,11 @@ export function validateFacts(answer, claims, opts = {}) {
|
|
|
802
839
|
for (const row of normalized.facts) {
|
|
803
840
|
if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
|
|
804
841
|
const fields = ['command', 'output_excerpt'].filter((field) => row[field] === null);
|
|
805
|
-
if (fields.length === 0) continue;
|
|
806
842
|
for (const field of fields) row[field] = '';
|
|
807
|
-
nullFields.set(row, fields);
|
|
843
|
+
if (fields.length > 0) nullFields.set(row, fields);
|
|
844
|
+
if (typeof row.output_excerpt === 'string' && row.output_excerpt.length > EXCERPT_MAX) {
|
|
845
|
+
row.output_excerpt = `${row.output_excerpt.slice(0, EXCERPT_MAX - 1)}…`;
|
|
846
|
+
}
|
|
808
847
|
}
|
|
809
848
|
}
|
|
810
849
|
if (!validateFile(normalized)) {
|
|
@@ -829,6 +868,7 @@ export function validateFacts(answer, claims, opts = {}) {
|
|
|
829
868
|
});
|
|
830
869
|
claims.forEach((claim, i) => {
|
|
831
870
|
if (chosen[i]) return;
|
|
871
|
+
if (claims.filter((c) => c.token === claim.token).length > 1) return;
|
|
832
872
|
const row = rows.find((r) => !taken.has(r) && r.kind === claim.kind && echoedClaim(r.claim, claim.kind) === claim.token);
|
|
833
873
|
if (row) taken.add(row);
|
|
834
874
|
chosen[i] = row;
|
|
@@ -836,11 +876,12 @@ export function validateFacts(answer, claims, opts = {}) {
|
|
|
836
876
|
claims.forEach((claim, i) => {
|
|
837
877
|
const id = `F${i + 1}`;
|
|
838
878
|
const row = chosen[i];
|
|
839
|
-
|
|
840
|
-
|
|
879
|
+
const source = claim.source === undefined ? {} : { source: redact(claim.source) };
|
|
880
|
+
if (claim.unavailable || !row) {
|
|
881
|
+
facts.push({ fact_id: id, claim: redact(claim.token), kind: claim.kind, ...source, command: '', output_excerpt: '', tag: 'UNVERIFIABLE', why: redact(claim.unavailable ?? 'no answer from the delegate') });
|
|
841
882
|
return;
|
|
842
883
|
}
|
|
843
|
-
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) };
|
|
884
|
+
const base = { fact_id: id, claim: redact(claim.token), kind: claim.kind, ...source, command: redact(row.command), output_excerpt: claim.kind === 'env' ? '' : redact(row.output_excerpt) };
|
|
844
885
|
const fields = nullFields.get(row);
|
|
845
886
|
const broken = fields ? `delegate returned null ${fields.join(', ')}` : ruleBroken(row, claim, opts);
|
|
846
887
|
if (broken) {
|
|
@@ -873,6 +914,7 @@ const code = (text) => (text.length === 0 ? '—' : text.includes('`') ? `\`\` $
|
|
|
873
914
|
*/
|
|
874
915
|
export function renderSheet({ briefRef, briefSha, briefMtime, builtAt, facts }) {
|
|
875
916
|
const count = (/** @type {string} */ tag) => facts.filter((f) => f.tag === tag).length;
|
|
917
|
+
const sources = facts.some((f) => f.source !== undefined);
|
|
876
918
|
const lines = [
|
|
877
919
|
'# Facts sheet',
|
|
878
920
|
'',
|
|
@@ -882,9 +924,9 @@ export function renderSheet({ briefRef, briefSha, briefMtime, builtAt, facts })
|
|
|
882
924
|
`- built_at: \`${builtAt}\``,
|
|
883
925
|
`- claims: ${facts.length} (VERIFIED ${count('VERIFIED')} · NOT-FOUND ${count('NOT-FOUND')} · UNVERIFIABLE ${count('UNVERIFIABLE')})`,
|
|
884
926
|
'',
|
|
885
|
-
|
|
886
|
-
'
|
|
887
|
-
...facts.map((f) => `| ${f.fact_id} | ${f.tag} | ${f.kind}
|
|
927
|
+
`| fact_id | tag | kind |${sources ? ' source |' : ''} claim | command | output | why |`,
|
|
928
|
+
`|---|---|---|---|---|---|---|${sources ? '---|' : ''}`,
|
|
929
|
+
...facts.map((f) => `| ${f.fact_id} | ${f.tag} | ${f.kind} |${sources ? ` ${cell(f.source ?? '—')} |` : ''} ${cell(code(f.claim))} | ${cell(code(f.command))} | ${cell(f.output_excerpt || '—')} | ${cell(f.why ?? '—')} |`),
|
|
888
930
|
'',
|
|
889
931
|
'<!-- facts-json',
|
|
890
932
|
`[\n${facts.map((f) => JSON.stringify(f)).join(',\n')}\n]`.replace(/</g, '\\u003c').replace(/>/g, '\\u003e'),
|
|
@@ -953,13 +995,24 @@ const RULE = [
|
|
|
953
995
|
'-not ! -a -o -and -or, plus paths), stat, file, uname, sw_vers, jq, git',
|
|
954
996
|
'log|show|status|rev-parse|ls-files|cat-file, npm view|ls, test -e|-f|-d|-n, `curl -sI <one URL>`.',
|
|
955
997
|
'A check that breaks these rules is not counted.',
|
|
956
|
-
'
|
|
998
|
+
'A source root is the project (`.`) or `_sources/<label>`. A top-level source folder is directly',
|
|
999
|
+
'under that root: `src` or `_sources/<label>/app`. `_sources` itself is never a source folder.',
|
|
1000
|
+
'An identifier symbol needs `grep -rn -- <quoted token or keyword token> <top-level source folder>`.',
|
|
1001
|
+
'Quote the pattern. Keywords: class interface trait enum struct type function func def.',
|
|
1002
|
+
'VERIFIED needs a file:line: hit in a source file holding the whole token; NOT-FOUND needs a whole top-level source folder.',
|
|
1003
|
+
'A file-name symbol needs `find <root> -name <token>`; VERIFIED needs an output path ending in /<token>.',
|
|
1004
|
+
'For a glob path use the per-claim check command. A find directory must be literal and its pattern quoted.',
|
|
1005
|
+
'If a directory segment has glob characters, only `ls -d <exact glob token>` is accepted. For any glob claim,',
|
|
1006
|
+
'`ls -d <exact glob token>` is also accepted, quoted or unquoted. VERIFIED needs a matching output path;',
|
|
1007
|
+
'NOT-FOUND needs No such file output. Use the check path given for a source, never its live absolute path.',
|
|
1008
|
+
'No `$`, no backticks, no redirection, no unquoted glob except the exact ls -d form above, no `~`, no absolute path outside /usr, /opt/homebrew or',
|
|
957
1009
|
'/bin, no secret files.',
|
|
958
1010
|
'An env claim is checked ONLY with `printenv NAME >/dev/null` (quote its exit status; the value is never shown).',
|
|
959
1011
|
'',
|
|
960
1012
|
'Reply with ONE JSON object: {"facts": [{"fact_id", "claim", "kind", "command", "output_excerpt", "tag", "why"}]} —',
|
|
961
1013
|
'one row per claim: `fact_id` (F<n>) and `kind` copied from the list, `claim` = the token alone (the text after',
|
|
962
1014
|
'`<kind>: `), `why` null unless UNVERIFIABLE.',
|
|
1015
|
+
'The claim is the token alone, without source/check annotations or the bracketed glob check hint.',
|
|
963
1016
|
'',
|
|
964
1017
|
'## Claims',
|
|
965
1018
|
'',
|
|
@@ -970,7 +1023,23 @@ const RULE = [
|
|
|
970
1023
|
* @returns {string} the delegate's packet.
|
|
971
1024
|
*/
|
|
972
1025
|
export function buildFactsPacket(claims) {
|
|
973
|
-
return `${RULE}${claims.
|
|
1026
|
+
return `${RULE}${claims.flatMap((c, i) => {
|
|
1027
|
+
if (c.unavailable) return [];
|
|
1028
|
+
const token = c.checkPath ?? c.token;
|
|
1029
|
+
let hint = '';
|
|
1030
|
+
if (c.kind === 'path' && /[*?\[\]{}]/.test(c.token)) {
|
|
1031
|
+
const dir = path.posix.dirname(token);
|
|
1032
|
+
hint = /[*?\[\]{}]/.test(dir)
|
|
1033
|
+
? ` [check with ls -d ${shellQuote(token)}]`
|
|
1034
|
+
: ` [check with find ${shellQuote(dir)} -name ${shellQuote(path.posix.basename(token), true)}]`;
|
|
1035
|
+
}
|
|
1036
|
+
return [`- F${i + 1} ${c.kind}: ${c.token}${c.source === undefined ? (c.checkPath ? ` (check at ${c.checkPath})` : '') : ` (source ${c.source}, check at ${c.checkPath})`}${hint}`];
|
|
1037
|
+
}).join('\n')}\n`;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** @param {string} value @param {boolean} [always] */
|
|
1041
|
+
function shellQuote(value, always = false) {
|
|
1042
|
+
return !always && /^[\w./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\\''") + "'";
|
|
974
1043
|
}
|
|
975
1044
|
|
|
976
1045
|
/**
|
|
@@ -984,6 +1053,7 @@ export function buildFactsPacket(claims) {
|
|
|
984
1053
|
* @property {string} [projectDir] - the project (the verb's cwd): the delegate runs in a read-only
|
|
985
1054
|
* snapshot of its git HEAD plus the in-project `sources`; absent ⇒ an empty cwd.
|
|
986
1055
|
* @property {() => Date} [now]
|
|
1056
|
+
* @property {typeof exec} [gitRun] - real git runner seam for the batched availability check.
|
|
987
1057
|
*/
|
|
988
1058
|
|
|
989
1059
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -1128,13 +1198,91 @@ function copyNoFollow(src, dst, snapDir) {
|
|
|
1128
1198
|
}
|
|
1129
1199
|
}
|
|
1130
1200
|
|
|
1201
|
+
/** @param {string} root @param {string} file */
|
|
1202
|
+
function belowRoot(root, file) {
|
|
1203
|
+
const rel = path.relative(root, file);
|
|
1204
|
+
return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** @param {string | undefined} projectDir @param {ReadonlyArray<string>} sources @returns {SourceRoot[]} */
|
|
1208
|
+
function sourceRoots(projectDir, sources) {
|
|
1209
|
+
/** @type {SourceRoot[]} */
|
|
1210
|
+
const roots = [];
|
|
1211
|
+
const project = projectDir === undefined ? undefined : realpathSync(projectDir);
|
|
1212
|
+
/** @param {string} dir @param {string} [file] */
|
|
1213
|
+
const add = (dir, file) => {
|
|
1214
|
+
const existing = roots.find((r) => r.dir === dir);
|
|
1215
|
+
if (existing && (!existing.file || existing.file === file)) return;
|
|
1216
|
+
const base = path.basename(dir);
|
|
1217
|
+
let label = base;
|
|
1218
|
+
for (let n = 2; roots.some((r) => r.label === label); n += 1) label = `${base}-${n}`;
|
|
1219
|
+
roots.push({ dir, label, prefix: dir === project ? '' : `_sources/${label}`, ...(file ? { file } : {}) });
|
|
1220
|
+
};
|
|
1221
|
+
if (project !== undefined) add(project);
|
|
1222
|
+
for (const source of sources) {
|
|
1223
|
+
const real = realpathSync(source);
|
|
1224
|
+
if (project !== undefined && belowRoot(project, real)) continue;
|
|
1225
|
+
if (statSync(real).isDirectory()) add(real);
|
|
1226
|
+
else add(path.dirname(real), real);
|
|
1227
|
+
}
|
|
1228
|
+
return roots;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** @param {ReadonlyArray<Claim>} claims @param {SourceRoot[]} roots @returns {Claim[]} */
|
|
1232
|
+
function mapClaims(claims, roots) {
|
|
1233
|
+
// An explicitly named outside file is mounted, but only directories multiply relative claims.
|
|
1234
|
+
const directories = roots.filter((r) => !r.file);
|
|
1235
|
+
return claims.flatMap((claim) => {
|
|
1236
|
+
if (claim.kind === 'path' && path.isAbsolute(claim.token)) {
|
|
1237
|
+
if (claim.token === '/dev' || claim.token.startsWith('/dev/')) return [];
|
|
1238
|
+
// A nested source is the most specific root for an absolute path.
|
|
1239
|
+
const root = [...roots].sort((a, b) => b.dir.length - a.dir.length).find((r) => belowRoot(r.dir, claim.token) && (!r.file || r.file === claim.token));
|
|
1240
|
+
if (root) return [{ ...claim, ...(roots.length > 1 || root.prefix !== '' ? { source: root.label } : {}), root, checkPath: path.posix.join(root.prefix, path.relative(root.dir, claim.token).split(path.sep).join('/')) || '.' }];
|
|
1241
|
+
if (TOOL_ROOTS.some((r) => belowRoot(r, claim.token))) return [claim];
|
|
1242
|
+
try {
|
|
1243
|
+
if (!statSync('/' + claim.token.split('/')[1]).isDirectory()) return [];
|
|
1244
|
+
} catch { return []; }
|
|
1245
|
+
return [{ ...claim, unavailable: 'outside every --sources root and the snapshot' }];
|
|
1246
|
+
}
|
|
1247
|
+
if (directories.length < 2 || !['path', 'symbol', 'flag'].includes(claim.kind)) return [claim];
|
|
1248
|
+
return directories.map((root) => ({ ...claim, source: root.label, root,
|
|
1249
|
+
checkPath: claim.kind === 'path' ? (root.prefix ? `${root.prefix}/${claim.token}` : claim.token) : root.prefix || '.',
|
|
1250
|
+
}));
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* One git process per root, never per claim. Only lstat checks working-tree presence; Node never
|
|
1256
|
+
* searches source text. A path omitted by the snapshot cannot become a delegate NOT-FOUND.
|
|
1257
|
+
* @param {Claim[]} claims @param {SourceRoot[]} roots @param {string} cwd @param {typeof exec} run
|
|
1258
|
+
*/
|
|
1259
|
+
async function markUnavailable(claims, roots, cwd, run) {
|
|
1260
|
+
const directories = roots.filter((r) => !r.file);
|
|
1261
|
+
for (const root of roots) {
|
|
1262
|
+
const paths = claims.filter((c) => c.kind === 'path' && !c.unavailable && (c.root === root || !c.root && directories.length === 1 && root === directories[0] && !path.isAbsolute(c.token))
|
|
1263
|
+
&& pathViolation(c.checkPath ?? c.token) === null);
|
|
1264
|
+
const relatives = paths.map((c) => path.isAbsolute(c.token) ? path.relative(root.dir, c.token) || '.' : c.token);
|
|
1265
|
+
const checked = await run(['git', 'check-ignore', '-z', '--stdin'], {
|
|
1266
|
+
cwd: root.dir, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS, input: relatives.map((p) => `${p}\0`).join(''), okExitCodes: [0, 1],
|
|
1267
|
+
});
|
|
1268
|
+
if (checked.result !== 'ok' && !(checked.code === 128 && /not a git repository/i.test(checked.stderr))) throw new FactsError('snapshot', 'git check-ignore could not check the source paths');
|
|
1269
|
+
const ignored = new Set(checked.stdout.split('\0').filter(Boolean));
|
|
1270
|
+
paths.forEach((claim, i) => {
|
|
1271
|
+
if (ignored.has(relatives[i])) claim.unavailable = `git-ignored in ${root.label}: the snapshot leaves ignored paths out`;
|
|
1272
|
+
else if (lstatOrNull(path.join(root.dir, relatives[i])) && !lstatOrNull(path.join(cwd, claim.checkPath ?? claim.token))) {
|
|
1273
|
+
claim.unavailable = `not at HEAD in ${root.label}: outside the snapshot`;
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1131
1279
|
/**
|
|
1132
1280
|
* Build the facts delegate's read-only view of the project at `snapDir`: the tracked tree at HEAD
|
|
1133
1281
|
* (`git archive` + `tar -x`, binary-safe, the real index and tree never touched; every symlink and
|
|
1134
1282
|
* special entry dropped right after extraction) plus every `source` inside the project at its
|
|
1135
|
-
* current content ({@link copyNoFollow}; untracked sources included
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1283
|
+
* current content ({@link copyNoFollow}; untracked sources included). Outside source roots live
|
|
1284
|
+
* under `_sources/<label>`: their HEAD subtree, or a no-follow copy for non-git sources and files.
|
|
1285
|
+
* Not a git repo, or no commit yet (`git rev-parse --verify HEAD` fails) ⇒ the sources
|
|
1138
1286
|
* alone; HEAD resolves but the archive or the extraction fails ⇒ `FactsError('snapshot')`. The
|
|
1139
1287
|
* tar file never outlives the call.
|
|
1140
1288
|
* @param {string} projectDir @param {ReadonlyArray<string>} sources - absolute.
|
|
@@ -1175,6 +1323,34 @@ export async function buildSnapshot(projectDir, sources, snapDir, workDir, run =
|
|
|
1175
1323
|
if (inProject === '' || inProject.startsWith('..') || path.isAbsolute(inProject)) continue;
|
|
1176
1324
|
copyNoFollow(real, path.join(sessionCwd, inProject), snapDir);
|
|
1177
1325
|
}
|
|
1326
|
+
for (const source of sourceRoots(projectDir, sources).filter((r) => r.prefix)) {
|
|
1327
|
+
const dest = path.join(sessionCwd, source.prefix);
|
|
1328
|
+
assertNoLinkSegments(snapDir, dest);
|
|
1329
|
+
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
1330
|
+
if (source.file) {
|
|
1331
|
+
copyNoFollow(source.file, path.join(dest, path.basename(source.file)), snapDir);
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
const sourceTop = await run(['git', 'rev-parse', '--show-toplevel'], { cwd: source.dir, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
1335
|
+
if (sourceTop.result !== 'ok') {
|
|
1336
|
+
copyNoFollow(source.dir, dest, snapDir);
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
const repo = realpathSync(sourceTop.stdout.trim());
|
|
1340
|
+
const head = await run(['git', 'rev-parse', '--verify', '-q', 'HEAD'], { cwd: repo, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
1341
|
+
if (head.result !== 'ok') continue;
|
|
1342
|
+
const subfolder = path.relative(repo, source.dir).split(path.sep).join('/');
|
|
1343
|
+
const tarPath = path.join(workDir, 'source.tar');
|
|
1344
|
+
try {
|
|
1345
|
+
const archived = await run(['git', 'archive', '--format=tar', '-o', tarPath, subfolder ? `HEAD:${subfolder}` : 'HEAD'], { cwd: repo, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
1346
|
+
if (archived.result !== 'ok') throw new FactsError('snapshot', `source ${source.label} could not be archived`);
|
|
1347
|
+
const untar = await run(['tar', '-xf', tarPath, '-C', dest], { timeoutMs: GIT_TIMEOUT_MS });
|
|
1348
|
+
if (untar.result !== 'ok') throw new FactsError('snapshot', `source ${source.label} could not be extracted`);
|
|
1349
|
+
dropLinks(dest);
|
|
1350
|
+
} finally {
|
|
1351
|
+
rmSync(tarPath, { force: true });
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1178
1354
|
sealTree(snapDir);
|
|
1179
1355
|
return sessionCwd;
|
|
1180
1356
|
}
|
|
@@ -1194,8 +1370,13 @@ export async function buildFacts(opts, deps = {}) {
|
|
|
1194
1370
|
if (opts.projectDir !== undefined) {
|
|
1195
1371
|
try { projectBins = readProjectBins(opts.projectDir); } catch { projectBins = []; }
|
|
1196
1372
|
}
|
|
1197
|
-
const extractOpts =
|
|
1198
|
-
const
|
|
1373
|
+
const extractOpts = { symbols: true, globPaths: true, projectBins, briefText, projectDir: opts.projectDir };
|
|
1374
|
+
const roots = sourceRoots(opts.projectDir, opts.sources ?? []);
|
|
1375
|
+
const claims = mapClaims(extractClaims(briefText, extractOpts), roots);
|
|
1376
|
+
const lines = briefText.split(/\r?\n/).length - Number(briefText.endsWith('\n'));
|
|
1377
|
+
if (lines > 40 && claims.length < 5) {
|
|
1378
|
+
(deps.stderr ?? process.stderr).write(`forge facts: the brief has ${lines} lines but only ${claims.length} claims; the sheet checks almost nothing\n`);
|
|
1379
|
+
}
|
|
1199
1380
|
const root = opts.runRoot ?? currentRunRoot();
|
|
1200
1381
|
// B9b.1: a run root inside a git work tree (`tmp.root` configured in the project) cannot hold
|
|
1201
1382
|
// the snapshot — git and the harness would walk up into the live repo — so the facts area moves
|
|
@@ -1206,8 +1387,9 @@ export async function buildFacts(opts, deps = {}) {
|
|
|
1206
1387
|
const packetPath = path.join(dir, 'packet.md');
|
|
1207
1388
|
const snapDir = path.join(dir, 'snapshot');
|
|
1208
1389
|
try {
|
|
1209
|
-
writeFileSync(packetPath, buildFactsPacket(claims), { mode: 0o600 });
|
|
1210
1390
|
const cwd = opts.projectDir === undefined ? undefined : await buildSnapshot(opts.projectDir, opts.sources ?? [], snapDir, dir);
|
|
1391
|
+
if (cwd !== undefined) await markUnavailable(claims, roots, cwd, opts.gitRun ?? exec);
|
|
1392
|
+
writeFileSync(packetPath, buildFactsPacket(claims), { mode: 0o600 });
|
|
1211
1393
|
const result = await spawnSession(
|
|
1212
1394
|
{
|
|
1213
1395
|
cfg: opts.cfg,
|
|
@@ -358,7 +358,10 @@ export function checkPlan(text, opts = {}) {
|
|
|
358
358
|
const projectBins = () => (binsCache ??= typeof given === 'function' ? given() : given ?? []);
|
|
359
359
|
for (const b of blocks) {
|
|
360
360
|
for (const clause of splitClauses(b.acceptance)) {
|
|
361
|
-
|
|
361
|
+
// A trailing parenthesized test citation names planned coverage, not a fact about
|
|
362
|
+
// an existing file. Paths in the clause itself still need facts or tolerances.
|
|
363
|
+
const factualClause = clause.replace(/\((?:\s*`(?:\.\/)?(?:test|tests|spec)\/[^`\n]+\.[\w]+`[\s,]*)+\)\s*$/, '');
|
|
364
|
+
const unbacked = extractClaims(factualClause).filter((c) => !isVerified(c, verified, projectBins));
|
|
362
365
|
if (unbacked.every((c) => tolerances.some((t) => t.includes(c.token) && idRe(b.id).test(t)))) continue;
|
|
363
366
|
errors.push(`unbackable clause without tolerance: ${b.id} ${clause}`);
|
|
364
367
|
}
|