@codedology/code-forge 0.8.0 → 0.10.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 +114 -0
- package/README.md +46 -7
- package/docs/reference/blocks.json +618 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +21 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +29 -4
- package/skill/references/security.md +5 -5
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +4 -2
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +300 -33
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +264 -13
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +7 -5
- package/src/doctor/local.mjs +116 -8
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/ledger/write.mjs +7 -1
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +117 -31
- package/src/review/fixloop.mjs +70 -28
- package/src/review/gate-check.mjs +378 -80
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +256 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/stop-exits.mjs +15 -0
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +11 -1
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +82 -15
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +9 -1
package/src/cli/keys.mjs
CHANGED
|
@@ -5,43 +5,96 @@
|
|
|
5
5
|
* message would be masked once the chain registered it.
|
|
6
6
|
*
|
|
7
7
|
* keys list
|
|
8
|
-
* keys set <name> [--op <ref>
|
|
8
|
+
* keys set <name> [--op <ref> [--cache-hours <n>]]
|
|
9
|
+
* hidden prompt, or read once from 1Password and cache it
|
|
9
10
|
* keys test <name> [--ref <ref>] resolve through the chain (no prompt)
|
|
10
11
|
* keys remove <name>
|
|
11
12
|
*
|
|
13
|
+
* Cache time (B84): `--cache-hours` (only with `--op`; a typed key never expires), else
|
|
14
|
+
* `keys.cache_hours` from the `.code-forge.yml` of the project root of the current directory, else
|
|
15
|
+
* 8 h — whole hours from 1 to 24. `keys set --op` and `keys test` read that config; a missing file
|
|
16
|
+
* is the default, a file that cannot be loaded is the default with one WARN line. An invalid
|
|
17
|
+
* `--cache-hours`, or an invalid `keys.cache_hours` for `set --op`, exits 2 before any 1Password
|
|
18
|
+
* call; `keys test` warns once and uses 8 h for an invalid `keys.cache_hours` (as `jev ask` does).
|
|
19
|
+
* A cached entry is never
|
|
20
|
+
* trusted for longer than those hours (`store.read`).
|
|
21
|
+
*
|
|
22
|
+
* Expiry warning (B85): `keys list` keeps its stdout table and writes one stderr line per key with
|
|
23
|
+
* less than 1 hour left — `WARN <name> expires in <m> min; re-link: code-forge keys set <name> --op
|
|
24
|
+
* <ref>` — naming the `op://` reference from the index row, or a placeholder for a row written
|
|
25
|
+
* before the index carried it.
|
|
26
|
+
*
|
|
12
27
|
* An `--op`/`--ref` value may be a 1Password item ID or item link instead of `op://vault/item/field`:
|
|
13
28
|
* it is resolved to `op://<vaultId>/<itemId>/<fieldId>` first (`op item get`, B25).
|
|
14
29
|
*
|
|
15
30
|
* Every name is validated before any store call, on every subcommand.
|
|
16
31
|
*/
|
|
17
32
|
|
|
33
|
+
import path from 'node:path';
|
|
18
34
|
import { writeSafe } from '../util/redact.mjs';
|
|
35
|
+
import { DEFAULT_CONFIG_FILENAME, loadConfigFile, projectRootFor } from '../config/load.mjs';
|
|
19
36
|
import { describeOpItem, isOpRef, OP_APPROVE_NOTE, OP_MESSAGES, opItemIdFromInput, toOpRef } from '../keys/onepassword.mjs';
|
|
20
|
-
import { assertKeyName, createDefaultKeyStore, parseRef, readOpAndCache, resolveKey } from '../keys/store.mjs';
|
|
37
|
+
import { assertKeyName, CACHE_HOURS_RULE, cacheHoursFrom, createDefaultKeyStore, DEFAULT_CACHE_HOURS, isCacheHours, isTime, parseRef, readOpAndCache, resolveKey, shellWord } from '../keys/store.mjs';
|
|
38
|
+
|
|
39
|
+
const USAGE = 'usage: code-forge keys list | set <name> [--op <item-id|link|op://ref> [--cache-hours <1-24>]] | test <name> [--ref <ref>] | remove <name>\n';
|
|
21
40
|
|
|
22
|
-
|
|
41
|
+
/** The flags each subcommand accepts (list and remove take none); each at most once, any order. */
|
|
42
|
+
const FLAGS = /** @type {Record<string, string[]>} */ ({ list: [], set: ['--op', '--cache-hours'], test: ['--ref'], remove: [] });
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} text
|
|
46
|
+
* @returns {number | null} the whole number of hours `text` spells (digits only) within the
|
|
47
|
+
* bounds, else null.
|
|
48
|
+
*/
|
|
49
|
+
export function parseCacheHours(text) {
|
|
50
|
+
if (!/^[0-9]{1,3}$/.test(text)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const hours = Number(text);
|
|
54
|
+
return isCacheHours(hours) ? hours : null;
|
|
55
|
+
}
|
|
23
56
|
|
|
24
|
-
/**
|
|
25
|
-
const
|
|
57
|
+
/** Below this much time left, `keys list` warns (B85). */
|
|
58
|
+
const WARN_BELOW_MS = 3_600_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {{name: string, exp: number|null, ref?: string}} row
|
|
62
|
+
* @param {number} now
|
|
63
|
+
* @returns {string|null} the stderr warning for a key with less than 1 hour left, else null (also
|
|
64
|
+
* for an `exp` that is not a valid time).
|
|
65
|
+
*/
|
|
66
|
+
export function expiryWarning(row, now) {
|
|
67
|
+
if (!isTime(row.exp) || row.exp <= now || row.exp - now >= WARN_BELOW_MS) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
// whole minutes left, never 0 and never 60 (under 1 hour is at most 59 min 59 s)
|
|
71
|
+
const minutes = Math.max(1, Math.floor((row.exp - now) / 60_000));
|
|
72
|
+
const ref = typeof row.ref === 'string' ? shellWord(row.ref) : '<your 1Password reference>';
|
|
73
|
+
return `WARN ${row.name} expires in ${minutes} min; re-link: code-forge keys set ${row.name} --op ${ref}\n`;
|
|
74
|
+
}
|
|
26
75
|
|
|
27
76
|
/**
|
|
28
77
|
* @param {number|null} exp
|
|
29
78
|
* @param {number} now
|
|
30
|
-
* @returns {string}
|
|
79
|
+
* @returns {string} `unknown` for an `exp` that is not a valid time (a hand-edited index).
|
|
31
80
|
*/
|
|
32
81
|
export function formatExpiry(exp, now) {
|
|
33
82
|
if (exp === null) {
|
|
34
83
|
return 'never';
|
|
35
84
|
}
|
|
85
|
+
if (!isTime(exp)) {
|
|
86
|
+
return 'unknown';
|
|
87
|
+
}
|
|
36
88
|
return exp <= now ? 'expired' : new Date(exp).toISOString();
|
|
37
89
|
}
|
|
38
90
|
|
|
39
91
|
/**
|
|
40
|
-
* Strict parse: `<sub> [<name>] [<flag> <value>]
|
|
41
|
-
*
|
|
42
|
-
* a
|
|
92
|
+
* Strict parse: `<sub> [<name>] [<flag> <value>]...` — the name first, then the subcommand's flags,
|
|
93
|
+
* each at most once, in any order. A flag with no value, a value that looks like a flag, a name
|
|
94
|
+
* that looks like a flag, an unknown or repeated flag, an extra argument, or `--cache-hours`
|
|
95
|
+
* without `--op` (a typed key never expires) is a usage error — never silently reinterpreted.
|
|
43
96
|
* @param {string[]} args
|
|
44
|
-
* @returns {{sub: string, name?: string, flagArg?: string} | null}
|
|
97
|
+
* @returns {{sub: string, name?: string, flagArg?: string, cacheHoursArg?: string} | null}
|
|
45
98
|
*/
|
|
46
99
|
export function parseKeysArgs(args) {
|
|
47
100
|
const [sub, ...rest] = args;
|
|
@@ -51,17 +104,77 @@ export function parseKeysArgs(args) {
|
|
|
51
104
|
if (sub === 'list') {
|
|
52
105
|
return rest.length === 0 ? { sub } : null;
|
|
53
106
|
}
|
|
54
|
-
const [name,
|
|
55
|
-
if (typeof name !== 'string' || name.startsWith('-') ||
|
|
107
|
+
const [name, ...flags] = rest;
|
|
108
|
+
if (typeof name !== 'string' || name.startsWith('-') || flags.length % 2 !== 0) {
|
|
56
109
|
return null;
|
|
57
110
|
}
|
|
58
|
-
|
|
59
|
-
|
|
111
|
+
/** @type {Record<string, string>} */
|
|
112
|
+
const values = {};
|
|
113
|
+
for (let i = 0; i < flags.length; i += 2) {
|
|
114
|
+
const flagName = flags[i];
|
|
115
|
+
const value = flags[i + 1];
|
|
116
|
+
if (!FLAGS[sub].includes(flagName) || Object.hasOwn(values, flagName) || value.length === 0 || value.startsWith('-')) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
values[flagName] = value;
|
|
60
120
|
}
|
|
61
|
-
if (
|
|
121
|
+
if (values['--cache-hours'] !== undefined && values['--op'] === undefined) {
|
|
62
122
|
return null;
|
|
63
123
|
}
|
|
64
|
-
|
|
124
|
+
const flagArg = sub === 'set' ? values['--op'] : values['--ref'];
|
|
125
|
+
return {
|
|
126
|
+
sub,
|
|
127
|
+
name,
|
|
128
|
+
...(flagArg !== undefined ? { flagArg } : {}),
|
|
129
|
+
...(values['--cache-hours'] !== undefined ? { cacheHoursArg: values['--cache-hours'] } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The cache time for `keys set --op` / `keys test`: `--cache-hours`, else `keys.cache_hours` from
|
|
135
|
+
* the project config, else 8. An invalid flag fails closed; an invalid `keys.cache_hours` fails
|
|
136
|
+
* closed when `strict` (`set --op`) and otherwise (`test`) gives the default with one warning. A
|
|
137
|
+
* config that cannot be loaded gives the default with one warning (`keys` must keep working beside
|
|
138
|
+
* an unrelated config error).
|
|
139
|
+
* @param {string|undefined} cacheHoursArg
|
|
140
|
+
* @param {string} cwd
|
|
141
|
+
* @param {typeof loadConfigFile} loadConfig
|
|
142
|
+
* @param {boolean} strict - true for `set --op`: an invalid `keys.cache_hours` refuses.
|
|
143
|
+
* @returns {Promise<{ok: true, hours: number, from: string|null, warning: string|null} | {ok: false, message: string}>}
|
|
144
|
+
* `from` names where hours other than the default came from.
|
|
145
|
+
*/
|
|
146
|
+
async function cacheHoursFor(cacheHoursArg, cwd, loadConfig, strict) {
|
|
147
|
+
let fromFlag = null;
|
|
148
|
+
if (cacheHoursArg !== undefined) {
|
|
149
|
+
fromFlag = parseCacheHours(cacheHoursArg);
|
|
150
|
+
if (fromFlag === null) {
|
|
151
|
+
return { ok: false, message: `keys: --cache-hours must be ${CACHE_HOURS_RULE}\n` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const loaded = await loadConfig(path.join(projectRootFor(cwd), DEFAULT_CONFIG_FILENAME));
|
|
155
|
+
/** @type {string|null} */
|
|
156
|
+
let warning = null;
|
|
157
|
+
/** @type {Record<string, any>} */
|
|
158
|
+
let cfg = {};
|
|
159
|
+
if (loaded.ok) {
|
|
160
|
+
cfg = loaded.config ?? {};
|
|
161
|
+
} else if (loaded.error !== 'not-found') {
|
|
162
|
+
// the error class only, never the loader's message; the WARN names the hours really used
|
|
163
|
+
const used = fromFlag !== null ? `${fromFlag} h from --cache-hours` : `the default ${DEFAULT_CACHE_HOURS} h`;
|
|
164
|
+
warning = `WARN keys: ${DEFAULT_CONFIG_FILENAME} cannot be loaded (${loaded.error}); caching for ${used}\n`;
|
|
165
|
+
}
|
|
166
|
+
const fromConfig = cacheHoursFrom(cfg);
|
|
167
|
+
if (fromConfig.problem !== null) {
|
|
168
|
+
if (strict) {
|
|
169
|
+
return { ok: false, message: `keys: ${fromConfig.problem} (${DEFAULT_CONFIG_FILENAME})\n` };
|
|
170
|
+
}
|
|
171
|
+
// fromConfig.hours is already the default here
|
|
172
|
+
warning = `WARN keys: ${fromConfig.problem} (${DEFAULT_CONFIG_FILENAME}); caching for the default ${fromConfig.hours} h\n`;
|
|
173
|
+
}
|
|
174
|
+
if (fromFlag !== null) {
|
|
175
|
+
return { ok: true, hours: fromFlag, from: '--cache-hours', warning };
|
|
176
|
+
}
|
|
177
|
+
return { ok: true, hours: fromConfig.hours, from: fromConfig.source === 'config' ? 'keys.cache_hours' : null, warning };
|
|
65
178
|
}
|
|
66
179
|
|
|
67
180
|
/** @param {string} name */
|
|
@@ -82,9 +195,11 @@ async function askHidden(name) {
|
|
|
82
195
|
* @param {typeof import('../util/exec.mjs').exec} [deps.opExec] - runs `op item get` for an item ID or link.
|
|
83
196
|
* @param {NodeJS.ProcessEnv} [deps.env]
|
|
84
197
|
* @param {number} [deps.now]
|
|
198
|
+
* @param {string} [deps.cwd] - the project config is read at the project root of this directory.
|
|
199
|
+
* @param {typeof loadConfigFile} [deps.loadConfig]
|
|
85
200
|
* @returns {Promise<number>}
|
|
86
201
|
*/
|
|
87
|
-
export async function runKeys(args, { store, stdout = process.stdout, stderr = process.stderr, ask = askHidden, opRead, opExec, env = process.env, now = Date.now() }) {
|
|
202
|
+
export async function runKeys(args, { store, stdout = process.stdout, stderr = process.stderr, ask = askHidden, opRead, opExec, env = process.env, now = Date.now(), cwd = process.cwd(), loadConfig = loadConfigFile }) {
|
|
88
203
|
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
89
204
|
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
90
205
|
|
|
@@ -93,7 +208,7 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
|
|
|
93
208
|
err(USAGE);
|
|
94
209
|
return 2;
|
|
95
210
|
}
|
|
96
|
-
const { sub, name } = parsed;
|
|
211
|
+
const { sub, name, cacheHoursArg } = parsed;
|
|
97
212
|
let { flagArg } = parsed;
|
|
98
213
|
try {
|
|
99
214
|
if (name !== undefined) {
|
|
@@ -103,6 +218,21 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
|
|
|
103
218
|
err(`keys: ${e.message}\n`);
|
|
104
219
|
return 2;
|
|
105
220
|
}
|
|
221
|
+
// B84: the cache time is settled before any 1Password call (`set --op` and `test` may cache);
|
|
222
|
+
// only `set --op` refuses an invalid keys.cache_hours, `test` warns once and uses 8 h
|
|
223
|
+
/** @type {{hours: number, from: string|null}} */
|
|
224
|
+
let cache = { hours: DEFAULT_CACHE_HOURS, from: null };
|
|
225
|
+
if (sub === 'test' || (sub === 'set' && flagArg !== undefined)) {
|
|
226
|
+
const found = await cacheHoursFor(cacheHoursArg, cwd, loadConfig, sub === 'set');
|
|
227
|
+
if ('message' in found) {
|
|
228
|
+
err(found.message);
|
|
229
|
+
return 2;
|
|
230
|
+
}
|
|
231
|
+
if (found.warning !== null) {
|
|
232
|
+
err(found.warning);
|
|
233
|
+
}
|
|
234
|
+
cache = found;
|
|
235
|
+
}
|
|
106
236
|
const fullRef = flagArg !== undefined && isOpRef(flagArg);
|
|
107
237
|
const itemId = flagArg !== undefined && !fullRef ? opItemIdFromInput(flagArg) : null;
|
|
108
238
|
if (sub === 'set' && flagArg !== undefined && !fullRef && itemId === null) {
|
|
@@ -147,6 +277,10 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
|
|
|
147
277
|
for (const row of rows) {
|
|
148
278
|
out(`${row.name}\t${row.source}\t${row.backend}\t${formatExpiry(row.exp, now)}\n`);
|
|
149
279
|
}
|
|
280
|
+
for (const row of rows) {
|
|
281
|
+
const warning = expiryWarning(row, now);
|
|
282
|
+
if (warning !== null) err(warning);
|
|
283
|
+
}
|
|
150
284
|
return 0;
|
|
151
285
|
}
|
|
152
286
|
|
|
@@ -157,7 +291,7 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
|
|
|
157
291
|
}
|
|
158
292
|
|
|
159
293
|
if (sub === 'test') {
|
|
160
|
-
const res = await resolveKey(name, { store, ref: flagArg, env, opRead, now });
|
|
294
|
+
const res = await resolveKey(name, { store, ref: flagArg, env, opRead, now, cacheHours: cache.hours });
|
|
161
295
|
for (const e of res.errors) {
|
|
162
296
|
err(`${name}: ${e}\n`);
|
|
163
297
|
}
|
|
@@ -170,7 +304,7 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
|
|
|
170
304
|
}
|
|
171
305
|
|
|
172
306
|
if (flagArg !== undefined) {
|
|
173
|
-
const res = await readOpAndCache(name, flagArg, { store, opRead, now });
|
|
307
|
+
const res = await readOpAndCache(name, flagArg, { store, opRead, now, cacheHours: cache.hours });
|
|
174
308
|
for (const e of res.errors) {
|
|
175
309
|
err(`${name}: ${e}\n`);
|
|
176
310
|
}
|
|
@@ -178,7 +312,8 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
|
|
|
178
312
|
if (res.value === null) {
|
|
179
313
|
return 1;
|
|
180
314
|
}
|
|
181
|
-
|
|
315
|
+
const from = cache.from === null ? '' : ` (cache ${cache.hours} h from ${cache.from})`;
|
|
316
|
+
out(`stored ${name} in ${store.writer} (source=op, expires=${formatExpiry(res.exp, now)})${from}\n`);
|
|
182
317
|
return 0;
|
|
183
318
|
}
|
|
184
319
|
|
package/src/cli/proof.mjs
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* which this block must hold; exit 0 proven, 1 not proven or refused).
|
|
17
17
|
* The signed row carries `covers` (B20): the reverted sources, or
|
|
18
18
|
* the test file for `assertion-deletion`.
|
|
19
|
+
* B87: a test file a `proof.runners` entry of the run snapshot matches
|
|
20
|
+
* runs with that entry's argv and is read from its JUnit report; the
|
|
21
|
+
* row and the output then carry `runner`, `env_keys` and `failed_tests`.
|
|
19
22
|
*
|
|
20
23
|
* `export` reads `proof.export.{link_dirs,copy_untracked}` from the workspace's `.code-forge.yml`
|
|
21
24
|
* (defaults §1.3) and writes a signed `proof` row `isolation: export`; `lock`/`unlock` write
|
|
@@ -35,12 +38,14 @@ import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
|
35
38
|
import { validateConfig } from '../config/validate.mjs';
|
|
36
39
|
import { appendRow } from '../ledger/write.mjs';
|
|
37
40
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
41
|
+
import { proofRunners } from '../state/config-snapshot.mjs';
|
|
38
42
|
import { StateError } from '../state/paths.mjs';
|
|
39
43
|
import { readRun } from '../state/run.mjs';
|
|
40
44
|
import { redactJSON, writeSafe } from '../util/redact.mjs';
|
|
41
45
|
import { DEFAULT_COPY_UNTRACKED, DEFAULT_LINK_DIRS, buildExport, exportDirFor, recordProof, removeExport } from '../proof/export.mjs';
|
|
42
46
|
import { acquireProofLock, lockHolder, releaseProofLock } from '../proof/lock.mjs';
|
|
43
47
|
import { MECHANISMS, readJournal, restoreJournal, runRedGreen } from '../proof/red-green.mjs';
|
|
48
|
+
import { adapterPrecheck, runnerCovers, runnerHook, runnerTimeoutMs, selectRunner } from '../proof/runners.mjs';
|
|
44
49
|
import { tierFor } from '../proof/tiers.mjs';
|
|
45
50
|
import { blockTree } from '../proof/worktree.mjs';
|
|
46
51
|
import { blockFileSet, repoRelativePath } from '../review/gate-check.mjs';
|
|
@@ -83,13 +88,15 @@ async function assertTestFile(dir, file) {
|
|
|
83
88
|
|
|
84
89
|
/**
|
|
85
90
|
* The test command filtered to one test: `node --test [--test-name-pattern ^<case>$] <file>`, run
|
|
86
|
-
* with the Node that runs this CLI. Other stacks
|
|
91
|
+
* with the Node that runs this CLI. Other stacks need a `proof.runners` entry (B87).
|
|
87
92
|
* @param {string} file @param {string | undefined} testCase
|
|
88
93
|
* @returns {string[]}
|
|
89
94
|
* @throws {StateError} `unsupported-test`
|
|
90
95
|
*/
|
|
91
96
|
function filteredTestArgv(file, testCase) {
|
|
92
|
-
if (!NODE_TEST_FILE.test(file))
|
|
97
|
+
if (!NODE_TEST_FILE.test(file)) {
|
|
98
|
+
throw new StateError('unsupported-test', 'only node:test files (.mjs, .cjs, .js) have a filtered test command in this version — add a proof.runners entry for it (see proof.md §2)');
|
|
99
|
+
}
|
|
93
100
|
const pattern = testCase === undefined ? [] : ['--test-name-pattern', `^${testCase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`];
|
|
94
101
|
return [process.execPath, '--test', ...pattern, file];
|
|
95
102
|
}
|
|
@@ -178,11 +185,14 @@ async function exportAndRecord({ runId, record, blockId, block, place, config, w
|
|
|
178
185
|
|
|
179
186
|
/**
|
|
180
187
|
* @param {string[]} args
|
|
181
|
-
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}
|
|
188
|
+
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown},
|
|
189
|
+
* precheck?: (entry: import('../proof/runners.mjs').RunnerEntry) => Promise<void>,
|
|
190
|
+
* hook?: (entry: import('../proof/runners.mjs').RunnerEntry, test: {file: string, testCase?: string}) => import('../proof/runners.mjs').RunnerHook | null}} [deps] -
|
|
191
|
+
* `precheck` and `hook` are test-only seams for the runner adapter's precheck and hook (B87)
|
|
182
192
|
* @returns {Promise<number>}
|
|
183
193
|
*/
|
|
184
194
|
export async function runProof(args, deps = {}) {
|
|
185
|
-
const { stdout = process.stdout, stderr = process.stderr } = deps;
|
|
195
|
+
const { stdout = process.stdout, stderr = process.stderr, precheck = adapterPrecheck, hook = runnerHook } = deps;
|
|
186
196
|
const out = (/** @type {object} */ value) => writeSafe(stdout, `${redactJSON(value)}\n`);
|
|
187
197
|
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
188
198
|
const [sub, ...rest] = args;
|
|
@@ -270,9 +280,17 @@ export async function runProof(args, deps = {}) {
|
|
|
270
280
|
const testCase = sep < 0 ? undefined : flags.test.slice(sep + 2);
|
|
271
281
|
if (testCase === '') throw new StateError('usage', '--test <file>::<case> needs a case name after ::');
|
|
272
282
|
const file = normalizeTestPath(sep < 0 ? flags.test : flags.test.slice(0, sep));
|
|
273
|
-
const argv = filteredTestArgv(file, testCase);
|
|
274
283
|
const runId = flags.run;
|
|
275
284
|
const record = await readRun(runId);
|
|
285
|
+
// B87: the runner comes from the run snapshot only, never the live file; no entry matches ⇒
|
|
286
|
+
// the built-in node:test command, or the refusal
|
|
287
|
+
const runners = proofRunners(record);
|
|
288
|
+
const entry = selectRunner(runners, file);
|
|
289
|
+
const runner = entry === null ? null : (hook(entry, { file, testCase }) ?? null);
|
|
290
|
+
// a matched entry is run by its hook or not at all: never a node:test fallback (no row, no precheck)
|
|
291
|
+
if (entry !== null && runner === null) throw new StateError('usage', `the ${entry.adapter} runner for ${file} has no hook; no proof was run`);
|
|
292
|
+
// exactly one of the two: the built-in argv without a runner, the runner (its own argv) with one
|
|
293
|
+
const how = runner === null || entry === null ? { argv: filteredTestArgv(file, testCase) } : { runner, timeoutMs: runnerTimeoutMs(entry) };
|
|
276
294
|
const block = record.blocks?.[blockId];
|
|
277
295
|
if (block?.status !== 'open') throw new StateError('no-block', `block ${blockId} is not open in run ${runId}`);
|
|
278
296
|
const place = await proofPlace(runId, record, blockId, block);
|
|
@@ -292,17 +310,20 @@ export async function runProof(args, deps = {}) {
|
|
|
292
310
|
owned: block.owned_files,
|
|
293
311
|
matchOwned: (f) => ownsFile(block.owned_files, f, block.owned_kinds),
|
|
294
312
|
});
|
|
295
|
-
|
|
313
|
+
// B87: a file a configured runner matches is a test too, never a source
|
|
314
|
+
const sources = mechanism === 'assertion-deletion' ? [] : changed.map((c) => c.file).filter((f) => f !== file && !TEST_PATH.test(f) && !runnerCovers(runners, f));
|
|
296
315
|
if (mechanism === 'revert' && sources.length === 0) {
|
|
297
316
|
throw new StateError('usage', `block ${blockId} changed no non-test source file to revert — use --mechanism assertion-deletion`);
|
|
298
317
|
}
|
|
318
|
+
// B87: the adapter's precheck runs before the export is built and before any file is reverted
|
|
319
|
+
if (entry !== null) await precheck(entry);
|
|
299
320
|
const exp = isolation === 'export' ? await exportAndRecord({ runId, record, blockId, block, place, config, writeRow }) : null;
|
|
300
321
|
if (exp) await assertTestFile(exp.dir, file); // the test must also be where it runs (owned, or tracked at base)
|
|
301
322
|
const result = await runRedGreen({
|
|
302
323
|
workDir: exp ? exp.dir : place.dir,
|
|
303
324
|
repoDir: place.dir,
|
|
304
325
|
base: place.base,
|
|
305
|
-
|
|
326
|
+
...how,
|
|
306
327
|
test: { file, ...(testCase === undefined ? {} : { case: testCase }), characterization: mechanism === 'assertion-deletion' },
|
|
307
328
|
sources,
|
|
308
329
|
isolation,
|
|
@@ -317,6 +338,11 @@ export async function runProof(args, deps = {}) {
|
|
|
317
338
|
// still lands, `proven: false` and `red_kind: null`, and the verb exits 1.
|
|
318
339
|
const red = result.red ?? null;
|
|
319
340
|
const proven = result.proven === true && red !== null;
|
|
341
|
+
// B87: only when a runner really ran, names only — its adapter, the bounded failed test names
|
|
342
|
+
// and the env variable names (row and JSON alike); `failed_ids` stays in memory, and env
|
|
343
|
+
// values are never shown
|
|
344
|
+
const runnerFields =
|
|
345
|
+
runner === null ? {} : { runner: runner.adapter, failed_tests: red !== null && 'failed_tests' in red ? red.failed_tests : [], env_keys: [...runner.env_keys] };
|
|
320
346
|
await recordProof({
|
|
321
347
|
runId,
|
|
322
348
|
writeRow,
|
|
@@ -332,6 +358,7 @@ export async function runProof(args, deps = {}) {
|
|
|
332
358
|
proven,
|
|
333
359
|
failed: red?.failed ?? null,
|
|
334
360
|
covers,
|
|
361
|
+
...runnerFields,
|
|
335
362
|
},
|
|
336
363
|
});
|
|
337
364
|
out({
|
|
@@ -343,6 +370,7 @@ export async function runProof(args, deps = {}) {
|
|
|
343
370
|
red_kind: red?.red_kind ?? null,
|
|
344
371
|
green: result.green ?? null,
|
|
345
372
|
proven,
|
|
373
|
+
...runnerFields,
|
|
346
374
|
...(exp ? { export: { dir: exp.dir, restored: exp.restored, untracked: exp.untracked } } : {}),
|
|
347
375
|
});
|
|
348
376
|
return proven ? 0 : 1;
|