@codedology/code-forge 0.7.7 → 0.9.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +68 -2
  3. package/docs/README.md +2 -1
  4. package/docs/reference/blocks.json +390 -1
  5. package/docs/reference/config.md +26 -8
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +81 -4
  8. package/skill/SKILL.md +6 -6
  9. package/skill/references/code.md +45 -7
  10. package/skill/references/ledger.md +8 -4
  11. package/skill/references/proof.md +44 -3
  12. package/skill/references/review.md +25 -5
  13. package/skill/references/security.md +1 -1
  14. package/skill/scripts/forge +1 -1
  15. package/skill/templates/coder-brief.md +19 -2
  16. package/src/autopilot/binnacle.mjs +21 -3
  17. package/src/cli/block.mjs +427 -41
  18. package/src/cli/context.mjs +175 -0
  19. package/src/cli/gates.mjs +125 -15
  20. package/src/cli/ledger.mjs +46 -2
  21. package/src/cli/proof.mjs +62 -16
  22. package/src/cli/report.mjs +7 -4
  23. package/src/cli/review-file.mjs +324 -18
  24. package/src/cli/status.mjs +212 -0
  25. package/src/config/validate.mjs +45 -11
  26. package/src/context/pack.mjs +319 -0
  27. package/src/doctor/index.mjs +5 -4
  28. package/src/doctor/local.mjs +91 -3
  29. package/src/engines/sentinel.mjs +27 -6
  30. package/src/gates/detect.mjs +21 -8
  31. package/src/gates/expand.mjs +202 -0
  32. package/src/gates/owned-count.mjs +66 -0
  33. package/src/gates/run.mjs +49 -12
  34. package/src/install/wizard/answers.mjs +34 -5
  35. package/src/install/wizard/flags.mjs +8 -1
  36. package/src/install/wizard/project-file.mjs +2 -0
  37. package/src/install/wizard/run.mjs +6 -0
  38. package/src/install/wizard/steps.mjs +24 -4
  39. package/src/install/wizard/summary.mjs +13 -5
  40. package/src/ledger/export.mjs +1 -1
  41. package/src/ledger/outside-review.mjs +164 -0
  42. package/src/ledger/report.mjs +17 -5
  43. package/src/ledger/write.mjs +7 -1
  44. package/src/proof/export.mjs +93 -10
  45. package/src/proof/lock.mjs +28 -5
  46. package/src/proof/worktree.mjs +482 -0
  47. package/src/review/answers.mjs +445 -0
  48. package/src/review/deletion.mjs +106 -0
  49. package/src/review/engine.mjs +36 -2
  50. package/src/review/fixloop.mjs +25 -15
  51. package/src/review/gate-check.mjs +384 -83
  52. package/src/review/open-findings.mjs +59 -0
  53. package/src/review/reopen.mjs +258 -0
  54. package/src/review/stop-exits.mjs +15 -0
  55. package/src/state/block-end.mjs +290 -0
  56. package/src/state/block.mjs +103 -39
  57. package/src/state/brief-status.mjs +131 -0
  58. package/src/state/config-snapshot.mjs +51 -0
  59. package/src/state/registry.mjs +35 -4
  60. package/src/state/run.mjs +10 -1
  61. package/src/util/breadcrumbs.mjs +1 -0
  62. package/src/util/forbidden.mjs +46 -1
  63. package/src/util/gate-token.mjs +8 -0
  64. package/src/util/tmp.mjs +16 -4
  65. package/src/worker/engine.mjs +230 -24
  66. package/src/worker/loop.mjs +29 -8
  67. package/src/worker/queue.mjs +296 -15
  68. package/src/worker/review-state.mjs +346 -51
  69. package/src/worker/ticket.mjs +46 -6
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Doctor rows that need no provider CLI (plan §2.3; block B13b): config, harness links, Jev key
3
3
  * resolution (the three `--quick` rows), `code-forge` on PATH, Solo, gate commands, the live Jev
4
- * call, ledger writability and the `tmp` row. Every check returns rows; none throws.
4
+ * call, ledger writability, the `tmp` row and (B69) the `review state` row. Every check returns
5
+ * rows; none throws.
5
6
  */
6
7
 
7
8
  import { constants, readdirSync } from 'node:fs';
@@ -17,10 +18,16 @@ import { missingTools } from '../install/tools.mjs';
17
18
  import { OP_MESSAGES } from '../keys/onepassword.mjs';
18
19
  import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
19
20
  import { ledgerDir } from '../ledger/paths.mjs';
21
+ import { readAllRows } from '../ledger/write.mjs';
20
22
  import { DEFAULT_PROVIDER_CONCURRENCY, providerLimit } from '../session/spawn.mjs';
23
+ import { snapshotFor } from '../state/config-snapshot.mjs';
24
+ import { assertRunId, runsDir } from '../state/paths.mjs';
25
+ import { readRun } from '../state/run.mjs';
26
+ import { loadKey } from '../state/signer.mjs';
21
27
  import { listEntries } from '../util/reaper.mjs';
22
- import { ownerState, tmpBase } from '../util/tmp.mjs';
28
+ import { ownerState, runRootPath, tmpBase } from '../util/tmp.mjs';
23
29
  import { parallelTickets } from '../worker/loop.mjs';
30
+ import { missingStateFiles } from '../worker/review-state.mjs';
24
31
  import { row } from './rows.mjs';
25
32
 
26
33
  /** @typedef {import('./rows.mjs').Row} Row */
@@ -104,7 +111,8 @@ export async function checkCommands(cfg, pathEnv) {
104
111
  rows.push(row('solo', 'INFO', 'solo', 'UNVERIFIED (no Solo probe in this version)'));
105
112
  const gates = cfg?.gates && typeof cfg.gates === 'object' ? cfg.gates : {};
106
113
  let any = false;
107
- for (const [name, argv] of Object.entries(gates)) {
114
+ for (const [name, command] of Object.entries(gates)) {
115
+ const argv = command && !Array.isArray(command) && typeof command === 'object' ? command.argv : command; // B60: {argv, env}
108
116
  if (!Array.isArray(argv) || argv.length === 0) continue;
109
117
  any = true;
110
118
  const found = await commandOnPath(String(argv[0]), { pathEnv });
@@ -191,6 +199,86 @@ export function checkTmp(cfg) {
191
199
  return [row('tmp', roots + pids > 0 ? 'WARN' : 'OK', 'tmp', roots + pids > 0 ? `${detail} (the next code-forge run start removes them)` : detail)];
192
200
  }
193
201
 
202
+ /** B69: the most missing state files the `review state` row names. */
203
+ export const LISTED_MAX = 10;
204
+
205
+ /**
206
+ * B69: one WARN row per ACTIVE run (its record's `status` is `active`, as `run end` and the worker
207
+ * decide it) whose ledger has `review.state` anchors whose latest state file is missing — neither
208
+ * at `<code-forge home>/runs/<run>/review-state/` nor at the old temp-root path. The row counts the
209
+ * files the ledger can rebuild (the latest anchor carries `state_body`) and the lost ones (no body:
210
+ * the next ticket is refused `review-state-lost`; the owner needs `code-forge block reopen`), and
211
+ * names the first {@link LISTED_MAX} blocks and files (then `… and K more`). No row when nothing
212
+ * is missing. When the old temp location cannot be named (`tmp.root` not absolute), nothing is
213
+ * counted and the row says so; when an anchor of the run does not verify, the row says the review
214
+ * state was not checked. Each project's ledger is read once. Never throws: a run whose record, key
215
+ * or ledger cannot be read is skipped. No file content is read or printed.
216
+ * @param {Record<string, any> | null} cfg
217
+ * @returns {Promise<Row[]>}
218
+ */
219
+ export async function checkReviewState(cfg) {
220
+ let names = [];
221
+ try {
222
+ names = readdirSync(runsDir()).filter((n) => n.endsWith('.json'));
223
+ } catch {
224
+ return [];
225
+ }
226
+ /** @type {Row[]} */
227
+ const rows = [];
228
+ /** @type {Map<string, Array<Record<string, any>>>} the ledger rows per project, read once */
229
+ const ledgers = new Map();
230
+ for (const name of names.sort()) {
231
+ const runId = name.slice(0, -'.json'.length);
232
+ try {
233
+ assertRunId(runId);
234
+ const record = await readRun(runId);
235
+ if (record?.status !== 'active' || typeof record.project !== 'string') continue;
236
+ const key = await loadKey(runId);
237
+ let ledger = ledgers.get(record.project);
238
+ if (ledger === undefined) {
239
+ ledger = await readAllRows(record.project);
240
+ ledgers.set(record.project, ledger);
241
+ }
242
+ const atNew = missingStateFiles({ runId, key, rows: ledger });
243
+ if (atNew === null) {
244
+ rows.push(row('review-state', 'WARN', 'review state', `run ${runId}: review.state anchors do not verify; review state not checked`));
245
+ continue;
246
+ }
247
+ let root;
248
+ try {
249
+ root = snapshotFor(record, '')?.config?.tmp?.root;
250
+ } catch {
251
+ root = undefined;
252
+ }
253
+ if (typeof root !== 'string') root = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
254
+ let runRootDir;
255
+ try {
256
+ runRootDir = runRootPath(runId, root === undefined ? {} : { root });
257
+ } catch {
258
+ runRootDir = undefined;
259
+ }
260
+ if (runRootDir === undefined) {
261
+ // the old place cannot be named: nothing is counted as missing, the row says why
262
+ if (atNew.length > 0) {
263
+ rows.push(row('review-state', 'WARN', 'review state', `run ${runId}: the old temp location could not be checked (tmp.root is not an absolute path); review state files are not counted`));
264
+ }
265
+ continue;
266
+ }
267
+ const missing = missingStateFiles({ runId, key, rows: ledger, runRootDir });
268
+ if (missing === null || missing.length === 0) continue;
269
+ const rebuildable = missing.filter((m) => m.rebuildable).length;
270
+ const lost = missing.length - rebuildable;
271
+ const shown = missing.slice(0, LISTED_MAX).map((m) => `${m.block} ${m.file} (${m.rebuildable ? 'rebuildable' : 'lost'})`).join(', ');
272
+ const more = missing.length > LISTED_MAX ? ` … and ${missing.length - LISTED_MAX} more` : '';
273
+ rows.push(row('review-state', 'WARN', 'review state',
274
+ `run ${runId}: ${missing.length} state file(s) missing — ${rebuildable} rebuildable from the ledger, ${lost} lost (needs code-forge block reopen): ${shown}${more}`));
275
+ } catch {
276
+ // an unreadable record, key or ledger: not this row's business
277
+ }
278
+ }
279
+ return rows;
280
+ }
281
+
194
282
  /**
195
283
  * B44: the `review concurrency` INFO row — how many review tickets run at once and each provider's
196
284
  * session slots, from the effective config (defaults when a key is unset). Never throws.
@@ -11,6 +11,16 @@
11
11
  * trimmed line (`^...$`), so a coder quoting the sentinel text inside ordinary prose ("I will not
12
12
  * print ===BLOCK B4 COMPLETE=== yet") never matches — only a line that IS exactly the sentinel does.
13
13
  *
14
+ * **TESTS.** B63b: a coder proves a green test run with a line that IS exactly `TESTS <command>
15
+ * exit=<n>` (matched after trimming; `<command>` non-empty free text, `<n>` a decimal 0–255 with no
16
+ * leading zeros — a non-numeric, signed or out-of-range value does not count) printed before its
17
+ * sentinel. On a returned sentinel, `tests: {command, exit}` is the LAST such line in that
18
+ * sentinel's own segment: after the previous sentinel line of ANY block (COMPLETE or FAILED) and
19
+ * before this one. A TESTS line before an earlier attempt's sentinel, or before another block's
20
+ * sentinel in a shared log, never carries over, and one after the sentinel is ignored; with none
21
+ * the result has no `tests` key at all. Like the sentinel, the pattern is anchored to the whole
22
+ * trimmed line, so a TESTS line quoted in prose never matches.
23
+ *
14
24
  * **ACK.** Before any tool call, a coder replies `ACK <sha8> lines=<n>` to the wire receipt it was
15
25
  * sent — the sha of the brief file, truncated to 8 hex characters (matched case-insensitively;
16
26
  * compared lowercased), and the brief's line count. `parseAck` returns the LAST matching line (fix
@@ -23,17 +33,21 @@
23
33
  * look like an ACK (an echoed instruction, a quoted example) being mistaken for the real one.
24
34
  */
25
35
 
26
- /** @typedef {{status: "complete", blockId: string} | {status: "failed", blockId: string, reason: string}} SentinelResult */
36
+ /** @typedef {{command: string, exit: number}} TestsLine */
37
+ /** @typedef {({status: "complete", blockId: string} | {status: "failed", blockId: string, reason: string}) & {tests?: TestsLine}} SentinelResult */
27
38
 
28
39
  const COMPLETE_RE = /^===BLOCK\s+(\S+)\s+COMPLETE===$/;
29
40
  const FAILED_RE = /^===BLOCK\s+(\S+)\s+FAILED:\s*(.*?)\s*===$/;
41
+ /** B63b: `TESTS <command> exit=<n>` as a whole trimmed line (no leading zeros); 0–255 is checked after the match. */
42
+ const TESTS_RE = /^TESTS\s+(\S.*?)\s+exit=(0|[1-9]\d{0,2})$/;
30
43
 
31
44
  /**
32
45
  * @param {string} output - the full captured transcript (subprocess stdout, or a Solo
33
46
  * `get_process_output` join).
34
47
  * @param {string} [blockId] - when given, only sentinel lines for THIS block id count — a shared
35
48
  * pane/log can interleave another block's lines.
36
- * @returns {SentinelResult | null} the LAST matching sentinel line, or `null` if none is present.
49
+ * @returns {SentinelResult | null} the LAST matching sentinel line, or `null` if none is present;
50
+ * with `tests` when a `TESTS` line comes before it in its segment (B63b).
37
51
  */
38
52
  export function parseSentinel(output, blockId) {
39
53
  if (typeof output !== 'string') {
@@ -41,17 +55,24 @@ export function parseSentinel(output, blockId) {
41
55
  }
42
56
  /** @type {SentinelResult | null} */
43
57
  let last = null;
58
+ /** @type {TestsLine | null} */
59
+ let tests = null;
44
60
  for (const rawLine of output.split('\n')) {
45
61
  const line = rawLine.trim();
46
62
  const complete = COMPLETE_RE.exec(line);
47
- if (complete && (!blockId || complete[1] === blockId)) {
48
- last = { status: 'complete', blockId: complete[1] };
63
+ if (complete) {
64
+ if (!blockId || complete[1] === blockId) last = { status: 'complete', blockId: complete[1], ...(tests ? { tests } : {}) };
65
+ tests = null; // B63b: any sentinel line, of any block, ends the segment
49
66
  continue;
50
67
  }
51
68
  const failed = FAILED_RE.exec(line);
52
- if (failed && (!blockId || failed[1] === blockId)) {
53
- last = { status: 'failed', blockId: failed[1], reason: failed[2] };
69
+ if (failed) {
70
+ if (!blockId || failed[1] === blockId) last = { status: 'failed', blockId: failed[1], reason: failed[2], ...(tests ? { tests } : {}) };
71
+ tests = null;
72
+ continue;
54
73
  }
74
+ const run = TESTS_RE.exec(line);
75
+ if (run && Number(run[2]) <= 255) tests = { command: run[1], exit: Number(run[2]) };
55
76
  }
56
77
  return last;
57
78
  }
@@ -4,7 +4,7 @@
4
4
  * arrays, or `null` when the stack has no tool for that gate. Detection is pure filesystem
5
5
  * evidence (`fs.existsSync`/`readFileSync`) — no `git`, no child process, no network.
6
6
  *
7
- * Evidence is checked in the table's own order (PHP/Pest, then `package.json` scripts, Cargo,
7
+ * Evidence is checked in the table's own order (PHP/Pest or PHPUnit, then `package.json` scripts, Cargo,
8
8
  * pyproject, go.mod); the first stack whose marker file exists wins. A project with no marker
9
9
  * file at all is `unknown`: every gate is `null`, and `code` (a later block) refuses to run a
10
10
  * block whose `gates.test` is null — a block without a test gate cannot produce evidence.
@@ -18,10 +18,15 @@ import path from 'node:path';
18
18
  * (`gofmt -l`) exit 0 regardless of whether anything is unformatted and instead print the
19
19
  * offending file names to stdout — for those, the descriptor carries `failOnStdout: true` so
20
20
  * `run.mjs` treats non-empty stdout as red too, since a bare exit-code check can never fail.
21
- * @typedef {string[] | {argv: string[], failOnStdout: true}} GateCommand
21
+ * B60: a configured gate may be `{argv, env}` — `env` adds variables to the gate's environment
22
+ * (the values are never printed, `run.mjs`).
23
+ * @typedef {string[] | {argv: string[], failOnStdout?: true, env?: Record<string, string>}} GateCommand
22
24
  * @typedef {GateCommand | null} Gate
23
25
  */
24
- /** @typedef {{stack: string, test: Gate, lint: Gate, types: Gate, format: Gate}} DetectedGates */
26
+ /**
27
+ * `stack` is one of `php-pest`, `php-phpunit`, `node`, `rust`, `python`, `go` or `unknown`.
28
+ * @typedef {{stack: string, test: Gate, lint: Gate, types: Gate, format: Gate}} DetectedGates
29
+ */
25
30
 
26
31
  /** @param {string} root @param {string} rel @returns {boolean} */
27
32
  function has(root, rel) {
@@ -49,15 +54,23 @@ function readText(root, rel) {
49
54
  }
50
55
  }
51
56
 
52
- // ── PHP / Pest ───────────────────────────────────────────────────────────────
57
+ // ── PHP (Pest, PHPUnit) ─────────────────────────────────────────────────────
53
58
 
54
- /** @param {string} root @returns {DetectedGates | null} */
59
+ /**
60
+ * `composer.json` plus an installed runner: `vendor/bin/pest` is `php-pest` (checked first, so a
61
+ * project with both binaries is Pest), else `vendor/bin/phpunit` is `php-phpunit` (B61). A Laravel
62
+ * app (`artisan` present) runs either through `php artisan test --compact`; without `artisan` the
63
+ * runner's own binary runs. `types` and `format` do not depend on the runner.
64
+ * @param {string} root @returns {DetectedGates | null}
65
+ */
55
66
  function detectPhp(root) {
56
- if (!has(root, 'composer.json') || !has(root, 'vendor/bin/pest')) return null;
57
- const test = has(root, 'artisan') ? ['php', 'artisan', 'test', '--compact'] : ['vendor/bin/pest'];
67
+ if (!has(root, 'composer.json')) return null;
68
+ const runner = has(root, 'vendor/bin/pest') ? 'pest' : has(root, 'vendor/bin/phpunit') ? 'phpunit' : null;
69
+ if (runner === null) return null;
70
+ const test = has(root, 'artisan') ? ['php', 'artisan', 'test', '--compact'] : [`vendor/bin/${runner}`];
58
71
  const types = has(root, 'phpstan.neon') || has(root, 'phpstan.neon.dist') ? ['vendor/bin/phpstan'] : null;
59
72
  const format = has(root, 'pint.json') || has(root, 'vendor/bin/pint') ? ['vendor/bin/pint', '--test'] : null;
60
- return { stack: 'php-pest', test, lint: null, types, format };
73
+ return { stack: `php-${runner}`, test, lint: null, types, format };
61
74
  }
62
75
 
63
76
  // ── Node (package.json scripts) ────────────────────────────────────────────
@@ -0,0 +1,202 @@
1
+ /**
2
+ * B60 (issue #13 item 3): run only the block's related tests, and give a gate its own environment.
3
+ * Pure — no filesystem, no child process. A gate argv item that is EXACTLY {@link TEST_FILES_TOKEN}
4
+ * (one whole item, never a substring, never a shell string) stands for the block's test files:
5
+ *
6
+ * - the block's `test_files` (`block open --tests`), else its owned entries that are concrete
7
+ * files (no glob, no trailing `/`) and look like tests ({@link isTestPath});
8
+ * - when the block owns more FILES than `gates.full_suite_threshold_files` (default 10; globs and
9
+ * directories count the tracked and new (not ignored) files they cover — the caller counts them, gates/owned-count.mjs),
10
+ * or no test file is known (also: a gate run with no block), the item is DROPPED and the gate
11
+ * runs the full suite.
12
+ *
13
+ * {@link testScope} decides which, once per gate run; {@link expandArgv} applies it to one argv.
14
+ * A gate without the token is never touched.
15
+ *
16
+ * A gate given as `{argv, env}` runs with `env` added to the inherited environment. Gate env is
17
+ * NOT a secret store: code-forge prints and records only the variable NAMES ({@link envKeys},
18
+ * {@link printableGates}), never a value. The child's own output is masked only for values of
19
+ * {@link MASK_MIN_LENGTH} or more characters ({@link maskableEnvValues}) — a short value such as
20
+ * `CI=1` or `MODE=test` would otherwise mask unrelated text (`npm test`).
21
+ */
22
+
23
+ import { isExactEntry } from '../state/registry.mjs';
24
+ import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
25
+
26
+ export { TEST_FILES_TOKEN };
27
+
28
+ /** `gates.full_suite_threshold_files` when the config leaves it out (the schema default). */
29
+ export const DEFAULT_FULL_SUITE_THRESHOLD = 10;
30
+
31
+ /** The shortest env value masked in a gate's own output. */
32
+ export const MASK_MIN_LENGTH = 8;
33
+
34
+ /**
35
+ * The threshold a configured value gives: the value itself when it is an integer >= 0, else the
36
+ * default.
37
+ * @param {unknown} value - `gates.full_suite_threshold_files` as the config holds it.
38
+ * @returns {number}
39
+ */
40
+ export function thresholdOf(value) {
41
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : DEFAULT_FULL_SUITE_THRESHOLD;
42
+ }
43
+
44
+ /** Directory names that hold tests. */
45
+ const TEST_DIRS = new Set(['test', 'tests', '__tests__', 'spec']);
46
+
47
+ /**
48
+ * The shared test-path predicate: a directory segment named `test`, `tests`, `__tests__` or
49
+ * `spec`, or a file name ending `.test.<ext>` / `.spec.<ext>` (`a.test.mjs`, `a.spec.ts`),
50
+ * `Test.php` (`UserTest.php`), `_test.go`, or a Python `test_*.py`.
51
+ * @param {string} p - a repo-relative POSIX path (an owned entry; a glob is judged by its text).
52
+ * @returns {boolean}
53
+ */
54
+ export function isTestPath(p) {
55
+ if (typeof p !== 'string' || p.length === 0) return false;
56
+ const segments = p.split('/');
57
+ const name = segments[segments.length - 1];
58
+ if (segments.slice(0, -1).some((s) => TEST_DIRS.has(s))) return true;
59
+ return /\.(test|spec)\.[^/]+$/.test(name) || name.endsWith('Test.php') || name.endsWith('_test.go') || /^test_.*\.py$/.test(name);
60
+ }
61
+
62
+ /**
63
+ * An owned entry that names one file: no glob character (`*`, `?`, `{`) and no trailing `/`.
64
+ * @param {string} p @returns {boolean}
65
+ */
66
+ const isConcreteFile = (p) => typeof p === 'string' && p.length > 0 && isExactEntry(p) && !p.endsWith('/');
67
+
68
+ /**
69
+ * @typedef {{scope: 'related', files: string[]} | {scope: 'full', reason: 'threshold' | 'no_tests'}} TestScope
70
+ */
71
+
72
+ /** A gate run with no block: no test file is known. */
73
+ export const NO_BLOCK_SCOPE = Object.freeze(/** @type {TestScope} */ ({ scope: 'full', reason: 'no_tests' }));
74
+
75
+ /**
76
+ * Which tests a `{test_files}` gate runs for one block.
77
+ * @param {{testFiles?: ReadonlyArray<string> | null, owned: ReadonlyArray<string>, ownedFiles?: number, threshold?: number}} opts
78
+ * `testFiles` - the block's `test_files` (`block open --tests`), or none; `owned` - its owned
79
+ * entries; `ownedFiles` - how many files those entries cover (`ownedFileCount`,
80
+ * gates/owned-count.mjs; when not given, one per entry); `threshold` -
81
+ * `gates.full_suite_threshold_files`, compared with `ownedFiles`.
82
+ * @returns {TestScope}
83
+ */
84
+ export function testScope({ testFiles, owned, ownedFiles = owned.length, threshold = DEFAULT_FULL_SUITE_THRESHOLD }) {
85
+ if (ownedFiles > threshold) return { scope: 'full', reason: 'threshold' };
86
+ const files = testFiles && testFiles.length > 0 ? [...testFiles] : owned.filter((p) => isConcreteFile(p) && isTestPath(p));
87
+ if (files.length === 0) return { scope: 'full', reason: 'no_tests' };
88
+ return { scope: 'related', files };
89
+ }
90
+
91
+ /**
92
+ * The argv of one gate command (an argv array or `{argv, ...}`), or null.
93
+ * @param {unknown} command @returns {ReadonlyArray<unknown> | null}
94
+ */
95
+ function argvOf(command) {
96
+ if (Array.isArray(command)) return command;
97
+ if (command && typeof command === 'object' && Array.isArray(/** @type {{argv?: unknown}} */ (command).argv)) return /** @type {{argv: unknown[]}} */ (command).argv;
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Does any gate in `gates` have an argv item that is exactly {@link TEST_FILES_TOKEN} (a whole
103
+ * item — the same test {@link expandArgv} applies)? Only then is a block's test scope worked out;
104
+ * a run of plain gates never reads it.
105
+ * @param {Record<string, unknown> | null | undefined} gates @returns {boolean}
106
+ */
107
+ export function hasTestFilesItem(gates) {
108
+ return Object.values(gates ?? {}).some((command) => argvOf(command)?.includes(TEST_FILES_TOKEN) === true);
109
+ }
110
+
111
+ /**
112
+ * One gate argv with every item that is exactly {@link TEST_FILES_TOKEN} replaced by the related
113
+ * test files, or dropped for a full run. Always a new array; `scoped` is false (and the items
114
+ * unchanged) when the gate has no such item.
115
+ * @param {ReadonlyArray<string>} argv @param {TestScope} scope
116
+ * @returns {{argv: string[], scoped: boolean}}
117
+ * @throws {TypeError} a test path that starts with `-` (it would be read as an option; `block
118
+ * open --tests` refuses one too).
119
+ */
120
+ export function expandArgv(argv, scope) {
121
+ if (!argv.includes(TEST_FILES_TOKEN)) return { argv: [...argv], scoped: false };
122
+ const files = scope.scope === 'related' ? scope.files : [];
123
+ const dashed = files.find((f) => f.startsWith('-'));
124
+ if (dashed !== undefined) throw new TypeError(`expandArgv: test path ${JSON.stringify(dashed)} starts with "-"`);
125
+ return { argv: argv.flatMap((item) => (item === TEST_FILES_TOKEN ? files : [item])), scoped: true };
126
+ }
127
+
128
+ /**
129
+ * The fields a scoped gate's result and ledger row carry: `scope`, plus `test_count` when related
130
+ * or `reason` when full.
131
+ * @param {TestScope} scope
132
+ * @returns {{scope: 'related', test_count: number} | {scope: 'full', reason: 'threshold' | 'no_tests'}}
133
+ */
134
+ export function scopeFields(scope) {
135
+ return scope.scope === 'related' ? { scope: 'related', test_count: scope.files.length } : { scope: 'full', reason: scope.reason };
136
+ }
137
+
138
+ /**
139
+ * A gate command's env as a plain `NAME: value` map, or null when it has none: an argv array, an
140
+ * object without `env`, or an empty `env: {}` (treated as no env at all).
141
+ * @param {unknown} command
142
+ * @returns {Record<string, string> | null}
143
+ */
144
+ export function envOf(command) {
145
+ if (!command || typeof command !== 'object' || Array.isArray(command)) return null;
146
+ const env = /** @type {{env?: unknown}} */ (command).env;
147
+ if (!env || typeof env !== 'object' || Array.isArray(env) || Object.keys(env).length === 0) return null;
148
+ return /** @type {Record<string, string>} */ (env);
149
+ }
150
+
151
+ /**
152
+ * The sorted variable NAMES of a gate command's env, or null when it has none.
153
+ * @param {unknown} command @returns {string[] | null}
154
+ */
155
+ export function envKeys(command) {
156
+ const env = envOf(command);
157
+ return env ? Object.keys(env).sort() : null;
158
+ }
159
+
160
+ /**
161
+ * Every non-empty string value of every gate's `env`, across every entry of `gates`.
162
+ * @param {Record<string, unknown> | null | undefined} gates
163
+ * @returns {string[]}
164
+ */
165
+ export function envValues(gates) {
166
+ /** @type {string[]} */
167
+ const values = [];
168
+ for (const command of Object.values(gates ?? {})) {
169
+ for (const value of Object.values(envOf(command) ?? {})) if (typeof value === 'string' && value.length > 0) values.push(value);
170
+ }
171
+ return values;
172
+ }
173
+
174
+ /**
175
+ * The env values a gate's own output is masked for: those of {@link MASK_MIN_LENGTH} or more
176
+ * characters. Shorter ones are not masked (they would hide unrelated text).
177
+ * @param {Record<string, unknown> | null | undefined} gates @returns {string[]}
178
+ */
179
+ export function maskableEnvValues(gates) {
180
+ return envValues(gates).filter((value) => value.length >= MASK_MIN_LENGTH);
181
+ }
182
+
183
+ /**
184
+ * A copy of `gates` safe to print: each command given with `env` keeps its other fields and shows
185
+ * `env_keys` (the names) in place of `env`; an empty env is left out. Nothing else changes.
186
+ * @template {Record<string, any>} T
187
+ * @param {T} gates @returns {T}
188
+ */
189
+ export function printableGates(gates) {
190
+ /** @type {Record<string, unknown>} */
191
+ const out = {};
192
+ for (const [key, command] of Object.entries(gates ?? {})) {
193
+ if (!command || typeof command !== 'object' || Array.isArray(command) || !Object.hasOwn(command, 'env')) {
194
+ out[key] = command;
195
+ continue;
196
+ }
197
+ const { env: _env, ...rest } = /** @type {Record<string, unknown>} */ (command);
198
+ const keys = envKeys(command);
199
+ out[key] = keys ? { ...rest, env_keys: keys } : rest;
200
+ }
201
+ return /** @type {T} */ (out);
202
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * B60: how many FILES a block owns, for `gates.full_suite_threshold_files`.
3
+ *
4
+ * The owned entries are resolved against the workspace's tracked and new (not ignored) files
5
+ * (`git ls-files -z --cached --others --exclude-standard`, argv only) with the B57
6
+ * ownership rule (`ownsFile`, state/registry.mjs): a directory entry counts every such file below it, a glob counts its matches, a file counts once. Each file is counted
7
+ * once however many entries cover it. An entry that covers none of them (a path the block will
8
+ * create) counts as 1. Outside a git repository every entry counts as 1; any other git failure
9
+ * (a timeout, a spawn error, git failing inside a repository) gives `Infinity`, so the full suite
10
+ * runs (`reason: threshold`) rather than a guess.
11
+ *
12
+ * {@link countOwnedFiles} is the pure rule; {@link ownedFileCount} reads the files from git.
13
+ */
14
+ import os from 'node:os';
15
+ import { ownsFile } from '../state/registry.mjs';
16
+ import { exec } from '../util/exec.mjs';
17
+
18
+ const GIT_TIMEOUT_MS = 30000;
19
+
20
+ /**
21
+ * The number of files `owned` covers among `files`, each counted once, plus 1 for every entry
22
+ * that covers none of them.
23
+ * @param {ReadonlyArray<string>} owned
24
+ * @param {ReadonlyArray<string>} tracked - the workspace's files
25
+ * @param {Record<string, string>} [kinds] - the block's `owned_kinds`
26
+ * @returns {number}
27
+ */
28
+ export function countOwnedFiles(owned, tracked, kinds) {
29
+ const covered = new Set();
30
+ let notThere = 0;
31
+ for (const entry of owned) {
32
+ const hits = tracked.filter((file) => ownsFile([entry], file, kinds));
33
+ if (hits.length === 0) notThere += 1;
34
+ for (const file of hits) covered.add(file);
35
+ }
36
+ return covered.size + notThere;
37
+ }
38
+
39
+ /** `process.env` minus every inherited `GIT_*`, with no system/global config, messages in English. */
40
+ function gitEnv() {
41
+ /** @type {NodeJS.ProcessEnv} */
42
+ const env = { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull };
43
+ for (const [key, value] of Object.entries(process.env)) {
44
+ if (!key.startsWith('GIT_')) env[key] = value;
45
+ }
46
+ env.LC_ALL = 'C'; // the "not a git repository" message is matched below
47
+ return env;
48
+ }
49
+
50
+ /** The files `git ls-files` lists: tracked (`--cached`) and new, not ignored (`--others --exclude-standard`). */
51
+ const LS_FILES_ARGV = Object.freeze(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard']);
52
+
53
+ /**
54
+ * {@link countOwnedFiles} over the tracked and new (not ignored) files of the workspace at `cwd`.
55
+ * Not a git repository: every entry counts 1. Any other failure: `Infinity` (the full suite runs).
56
+ * @param {string} cwd
57
+ * @param {ReadonlyArray<string>} owned
58
+ * @param {Record<string, string>} [kinds]
59
+ * @returns {Promise<number>}
60
+ */
61
+ export async function ownedFileCount(cwd, owned, kinds) {
62
+ const res = await exec([...LS_FILES_ARGV], { cwd, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
63
+ if (res.result === 'ok') return countOwnedFiles(owned, res.stdout.split('\0').filter((f) => f.length > 0), kinds);
64
+ if (!res.timedOut && res.code === 128 && /not a git repository/i.test(res.stderr)) return countOwnedFiles(owned, [], kinds);
65
+ return Number.POSITIVE_INFINITY;
66
+ }
package/src/gates/run.mjs CHANGED
@@ -7,10 +7,13 @@
7
7
  */
8
8
 
9
9
  import { exec } from '../util/exec.mjs';
10
+ import { registerSecret } from '../util/redact.mjs';
11
+ import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } from './expand.mjs';
10
12
 
11
13
  /** @typedef {import('./detect.mjs').Gate} Gate */
12
14
  /** @typedef {import('./detect.mjs').GateCommand} GateCommand */
13
15
  /** @typedef {import('./detect.mjs').DetectedGates} DetectedGates */
16
+ /** @typedef {import('./expand.mjs').TestScope} TestScope */
14
17
 
15
18
  /**
16
19
  * @typedef {object} GateResult
@@ -22,26 +25,35 @@ import { exec } from '../util/exec.mjs';
22
25
  * @property {number | null} code
23
26
  * @property {string} stdout
24
27
  * @property {string} stderr
28
+ * @property {'related'|'full'} [scope] - B60: only on a gate whose argv has the `{test_files}` item.
29
+ * @property {number} [test_count] - B60: with `scope: related`, how many test files it ran.
30
+ * @property {'threshold'|'no_tests'} [reason] - B60: with `scope: full`, why the item was dropped.
31
+ * @property {string[]} [env_keys] - B60: only on a gate given with a non-empty `env`: the variable
32
+ * NAMES, sorted (the values are never in a result).
25
33
  */
26
34
 
27
35
  /** Fixed run order — always all four, regardless of an earlier one's result. */
28
36
  const GATE_ORDER = /** @type {const} */ (['test', 'lint', 'types', 'format']);
29
37
 
30
38
  /**
31
- * A gate command is either a plain argv array or `{argv, failOnStdout: true}` (detect.mjs's
32
- * `GateCommand`, e.g. `gofmt -l` which always exits 0). Normalized here so the run loop below has
33
- * one shape to deal with.
39
+ * A gate command is either a plain argv array or `{argv, failOnStdout?, env?}` (detect.mjs's
40
+ * `GateCommand`: `failOnStdout` for `gofmt -l`, which always exits 0; B60 `env` from the config).
41
+ * Normalized here so the run loop below has one shape to deal with.
34
42
  * @param {GateCommand} command
35
- * @returns {{argv: string[], failOnStdout: boolean}}
43
+ * @returns {{argv: string[], failOnStdout: boolean, env: Record<string, string> | null}}
36
44
  */
37
45
  function normalizeCommand(command) {
38
- if (Array.isArray(command)) return { argv: command, failOnStdout: false };
39
- return { argv: command.argv, failOnStdout: command.failOnStdout === true };
46
+ if (Array.isArray(command)) return { argv: command, failOnStdout: false, env: null };
47
+ return { argv: command.argv, failOnStdout: command.failOnStdout === true, env: envOf(command) }; // `env: {}` is no env
40
48
  }
41
49
 
42
50
  /**
51
+ * B60: `opts.scope` is the block's {@link TestScope} (`testScope`, gates/expand.mjs); a gate argv
52
+ * item that is exactly `{test_files}` expands to its files, or is dropped for a full run. Left out,
53
+ * no test file is known (`scope: full, reason: no_tests`). A gate with `env` runs with those
54
+ * variables added to the inherited environment; a gate with neither runs exactly as before B60.
43
55
  * @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate}} gates
44
- * @param {{cwd: string, timeoutMs?: number}} opts
56
+ * @param {{cwd: string, timeoutMs?: number, scope?: TestScope}} opts
45
57
  * @returns {Promise<{results: GateResult[], allOk: boolean}>}
46
58
  */
47
59
  export async function runGates(gates, opts) {
@@ -49,24 +61,49 @@ export async function runGates(gates, opts) {
49
61
  throw new TypeError('runGates: gates must be an object with test/lint/types/format');
50
62
  }
51
63
  const { cwd, timeoutMs = 300000 } = opts ?? {};
64
+ const scope = opts?.scope ?? NO_BLOCK_SCOPE; // null falls back too
52
65
  if (typeof cwd !== 'string' || cwd.length === 0) {
53
66
  throw new TypeError('runGates: opts.cwd must be a non-empty string');
54
67
  }
55
68
 
69
+ // B60: gate env is not a secret store. Its values are never put in a result (only `env_keys`);
70
+ // a value of 8+ characters is also masked in the child's own output (a shorter one would mask
71
+ // unrelated text — `MODE=test` vs `npm test`)
72
+ for (const value of maskableEnvValues(gates)) registerSecret(value);
73
+ // Every gate is normalized and expanded BEFORE any runs, so a bad gate throws with nothing run.
74
+ // B60: config validation refuses a gate whose only item is {test_files}; the empty-argv check
75
+ // is the guard for one that reaches here anyway: it names the gate, not an opaque spawn error.
76
+ const planned = GATE_ORDER.map((gate) => {
77
+ const command = gates[gate] ?? null;
78
+ if (command === null) return { gate, command: null };
79
+ const normalized = normalizeCommand(command);
80
+ const { argv, scoped } = expandArgv(normalized.argv, scope);
81
+ if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once '{test_files}' is dropped for the full suite — put the test command before it`);
82
+ return { gate, command: { argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } };
83
+ });
56
84
  /** @type {GateResult[]} */
57
85
  const results = [];
58
- for (const gate of GATE_ORDER) {
59
- const command = gates[gate] ?? null;
86
+ for (const { gate, command } of planned) {
60
87
  if (command === null) {
61
88
  results.push({ gate, argv: null, skipped: true, ok: true, code: null, stdout: '', stderr: '' });
62
89
  continue;
63
90
  }
64
- const { argv, failOnStdout } = normalizeCommand(command);
91
+ const { argv, scoped, failOnStdout, env } = command;
65
92
  // Each command is run and its exit status read on its own — never `&&`-chained with the
66
93
  // next, so a red gate is reported red without swallowing the gates that follow it.
67
- const res = await exec(argv, { cwd, timeoutMs });
94
+ const res = await exec(argv, env ? { cwd, timeoutMs, env: { ...process.env, ...env } } : { cwd, timeoutMs });
68
95
  const ok = res.result === 'ok' && (!failOnStdout || res.stdout.trim().length === 0);
69
- results.push({ gate, argv, skipped: false, ok, code: res.code, stdout: res.stdout, stderr: res.stderr });
96
+ results.push({
97
+ gate,
98
+ argv,
99
+ skipped: false,
100
+ ok,
101
+ code: res.code,
102
+ stdout: res.stdout,
103
+ stderr: res.stderr,
104
+ ...(scoped ? scopeFields(scope) : {}),
105
+ ...(env ? { env_keys: Object.keys(env).sort() } : {}),
106
+ });
70
107
  }
71
108
  return { results, allOk: results.every((r) => r.ok) };
72
109
  }