@codedology/code-forge 0.7.6 → 0.8.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 (63) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +57 -2
  3. package/docs/README.md +2 -1
  4. package/docs/reference/blocks.json +171 -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 +33 -4
  10. package/skill/references/ledger.md +8 -4
  11. package/skill/references/plan.md +12 -0
  12. package/skill/references/proof.md +44 -3
  13. package/skill/references/review.md +4 -2
  14. package/skill/references/security.md +1 -1
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +18 -1
  17. package/src/autopilot/binnacle.mjs +21 -3
  18. package/src/cli/block.mjs +337 -37
  19. package/src/cli/context.mjs +175 -0
  20. package/src/cli/gates.mjs +125 -15
  21. package/src/cli/ledger.mjs +46 -2
  22. package/src/cli/proof.mjs +62 -16
  23. package/src/cli/report.mjs +21 -4
  24. package/src/cli/review-file.mjs +61 -6
  25. package/src/cli/run.mjs +16 -6
  26. package/src/cli/status.mjs +212 -0
  27. package/src/config/validate.mjs +45 -11
  28. package/src/context/pack.mjs +319 -0
  29. package/src/decide/lane-facts.mjs +41 -0
  30. package/src/doctor/local.mjs +2 -1
  31. package/src/engines/sentinel.mjs +27 -6
  32. package/src/gates/detect.mjs +21 -8
  33. package/src/gates/expand.mjs +202 -0
  34. package/src/gates/owned-count.mjs +66 -0
  35. package/src/gates/run.mjs +49 -12
  36. package/src/install/wizard/answers.mjs +34 -5
  37. package/src/install/wizard/flags.mjs +8 -1
  38. package/src/install/wizard/project-file.mjs +2 -0
  39. package/src/install/wizard/run.mjs +6 -0
  40. package/src/install/wizard/steps.mjs +24 -4
  41. package/src/install/wizard/summary.mjs +13 -5
  42. package/src/ledger/export.mjs +1 -1
  43. package/src/ledger/outside-review.mjs +164 -0
  44. package/src/ledger/report.mjs +32 -6
  45. package/src/proof/export.mjs +93 -10
  46. package/src/proof/lock.mjs +28 -5
  47. package/src/proof/worktree.mjs +482 -0
  48. package/src/review/engine.mjs +3 -2
  49. package/src/review/gate-check.mjs +9 -5
  50. package/src/review/open-findings.mjs +59 -0
  51. package/src/state/block-end.mjs +290 -0
  52. package/src/state/block.mjs +111 -37
  53. package/src/state/brief-status.mjs +131 -0
  54. package/src/state/config-snapshot.mjs +68 -8
  55. package/src/state/registry.mjs +35 -4
  56. package/src/state/run.mjs +21 -2
  57. package/src/util/breadcrumbs.mjs +1 -0
  58. package/src/util/forbidden.mjs +36 -1
  59. package/src/util/gate-token.mjs +8 -0
  60. package/src/worker/engine.mjs +157 -17
  61. package/src/worker/loop.mjs +26 -7
  62. package/src/worker/queue.mjs +36 -10
  63. package/src/worker/ticket.mjs +46 -6
@@ -0,0 +1,164 @@
1
+ /**
2
+ * B66 (issue #13 item 8): outside review rounds per PR, next to what code-forge spent on the PR's
3
+ * blocks. Pure helpers, no I/O:
4
+ *
5
+ * - `parseOutsideReviewArgs` reads `ledger outside-review --run <r> --pr <n> --rounds <n>
6
+ * --findings <n> [--link <url>] [--block <id>]…` (the verb in `src/cli/ledger.mjs` does the
7
+ * run lookup, the block check, the redaction and the signed write);
8
+ * - `buildOutsideReview` builds the `outside_review` report section from the ledger rows and the
9
+ * `cost_per_block` entries (`src/ledger/report.mjs` passes them in, so the PR cost is computed
10
+ * by the SAME function the `cost_per_block` section uses);
11
+ * - `outsideReviewLines` renders the section as terminal text.
12
+ *
13
+ * Row: `outside_review {run, pr, rounds, findings, link (or null), blocks: [ids]}`. A later row for
14
+ * the same PR replaces the earlier one in every reader (the latest row in ledger order wins); the
15
+ * ledger itself stays append-only.
16
+ */
17
+
18
+ import { roundUsd } from './spend.mjs';
19
+
20
+ export const OUTSIDE_REVIEW_EVENT = 'outside_review';
21
+
22
+ export const OUTSIDE_REVIEW_USAGE =
23
+ 'usage: code-forge ledger outside-review --run <r> --pr <n> --rounds <n> --findings <n> [--link <url>] [--block <id>]...';
24
+
25
+ const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
26
+ const POSITIVE_INT = /^[1-9]\d*$/;
27
+ const NON_NEGATIVE_INT = /^(0|[1-9]\d*)$/;
28
+ const LINK = /^https?:\/\/\S+$/;
29
+ const MAX_LINK_CHARS = 2048;
30
+
31
+ /** @param {unknown} n @returns {boolean} */
32
+ const isCount = (n) => Number.isSafeInteger(n) && /** @type {number} */ (n) >= 0;
33
+
34
+ const SINGLE_FLAGS = new Set(['--run', '--pr', '--rounds', '--findings', '--link']);
35
+
36
+ /**
37
+ * @typedef {{run: string, pr: number, rounds: number, findings: number, link: string | null, blocks: string[]}} OutsideReviewArgs
38
+ */
39
+
40
+ /**
41
+ * @param {string} raw @param {RegExp} shape @returns {number | null}
42
+ */
43
+ function parseShapedInt(raw, shape) {
44
+ if (!shape.test(raw)) return null;
45
+ const n = Number(raw);
46
+ return Number.isSafeInteger(n) ? n : null;
47
+ }
48
+
49
+ /**
50
+ * Parse the verb's flags (everything after `outside-review`). Every flag but `--block` is given at
51
+ * most once; `--block` may repeat (duplicates collapsed, first-seen order kept). Any other token,
52
+ * a flag with no value, a missing required flag or a value of the wrong shape is a usage error.
53
+ * The link is returned as given — the caller redacts it once the run key is registered.
54
+ * @param {string[]} args
55
+ * @returns {{ok: true, value: OutsideReviewArgs} | {ok: false, message: string}}
56
+ */
57
+ export function parseOutsideReviewArgs(args) {
58
+ /** @type {Record<string, string>} */
59
+ const single = {};
60
+ /** @type {string[]} */
61
+ const blocks = [];
62
+ for (let i = 0; i < args.length; i += 2) {
63
+ const flag = args[i];
64
+ const value = args[i + 1];
65
+ // the position only, never the token: a stray value (a loose URL) may carry a secret
66
+ if (flag !== '--block' && !SINGLE_FLAGS.has(flag)) return { ok: false, message: `unknown argument at position ${i + 1}` };
67
+ if (value === undefined || value.startsWith('--')) return { ok: false, message: `${flag} requires a value` };
68
+ if (flag === '--block') {
69
+ if (!blocks.includes(value)) blocks.push(value);
70
+ continue;
71
+ }
72
+ if (Object.hasOwn(single, flag)) return { ok: false, message: `${flag} is given more than once` };
73
+ single[flag] = value;
74
+ }
75
+ for (const flag of ['--run', '--pr', '--rounds', '--findings']) {
76
+ if (!Object.hasOwn(single, flag)) return { ok: false, message: `${flag} is required` };
77
+ }
78
+ const run = single['--run'];
79
+ if (!RUN_ID.test(run)) return { ok: false, message: '--run must be a run id (letters, digits, ".", "_", "-")' };
80
+ const pr = parseShapedInt(single['--pr'], POSITIVE_INT);
81
+ if (pr === null) return { ok: false, message: '--pr must be a positive integer' };
82
+ const rounds = parseShapedInt(single['--rounds'], NON_NEGATIVE_INT);
83
+ if (rounds === null) return { ok: false, message: '--rounds must be a non-negative integer' };
84
+ const findings = parseShapedInt(single['--findings'], NON_NEGATIVE_INT);
85
+ if (findings === null) return { ok: false, message: '--findings must be a non-negative integer' };
86
+ let link = null;
87
+ if (Object.hasOwn(single, '--link')) {
88
+ link = single['--link'];
89
+ if (link.length > MAX_LINK_CHARS) return { ok: false, message: `--link is longer than ${MAX_LINK_CHARS} characters` };
90
+ if (!LINK.test(link)) return { ok: false, message: '--link must be an https:// or http:// URL with no whitespace' };
91
+ }
92
+ return { ok: true, value: { run, pr, rounds, findings, link, blocks } };
93
+ }
94
+
95
+ /** @param {string[]} blocks @returns {string} */
96
+ const blockList = (blocks) => (blocks.length > 0 ? blocks.join(', ') : '-');
97
+
98
+ /**
99
+ * The verb's one stdout line.
100
+ * @param {{pr: number, rounds: number, findings: number, blocks: string[]}} row @returns {string}
101
+ */
102
+ export function outsideReviewRecordedLine({ pr, rounds, findings, blocks }) {
103
+ return `outside_review pr ${pr} · rounds ${rounds} · findings ${findings} · blocks ${blockList(blocks)}\n`;
104
+ }
105
+
106
+ /**
107
+ * @typedef {{pr: number, rounds: number, findings: number, link: string | null, blocks: string[], code_forge_usd: number, unknown_usd_sessions: number}} OutsideReviewEntry
108
+ */
109
+
110
+ /**
111
+ * The `outside_review` report section: one entry per PR (the latest `outside_review` row for it,
112
+ * by ledger order), sorted by PR number ascending. `code_forge_usd` sums `totalUsd` of the
113
+ * `cost_per_block` entries of the row's run whose block the row names; `unknown_usd_sessions` sums
114
+ * their `unknownUsdSessions` (those sessions count 0 in the dollars). `[]` with no rows.
115
+ * @param {Array<Record<string, any>>} rows - shadow rows already removed.
116
+ * @param {Array<{run: string | null, block: string, totalUsd: number, unknownUsdSessions: number}>} costEntries - the `cost_per_block` section.
117
+ * @returns {OutsideReviewEntry[]}
118
+ */
119
+ export function buildOutsideReview(rows, costEntries) {
120
+ /** @type {Map<number, Record<string, any>>} */
121
+ const latest = new Map();
122
+ for (const row of rows) {
123
+ if (row.event !== OUTSIDE_REVIEW_EVENT || !Number.isSafeInteger(row.pr) || row.pr <= 0) continue;
124
+ // a row whose counts are not 0-or-more safe integers is skipped (the verb never writes one)
125
+ if (!isCount(row.rounds) || !isCount(row.findings)) continue;
126
+ latest.set(row.pr, row);
127
+ }
128
+ return [...latest.values()]
129
+ .sort((a, b) => a.pr - b.pr)
130
+ .map((row) => {
131
+ const blocks = Array.isArray(row.blocks) ? row.blocks.filter((b) => typeof b === 'string') : [];
132
+ const run = typeof row.run === 'string' ? row.run : null;
133
+ // a row with no string run matches no cost entry (never the null-run entries): 0 and 0 unknown
134
+ const mine = run === null ? [] : costEntries.filter((e) => e.run === run && blocks.includes(e.block));
135
+ const usd = mine.reduce((sum, e) => sum + e.totalUsd, 0);
136
+ return {
137
+ pr: row.pr,
138
+ rounds: row.rounds,
139
+ findings: row.findings,
140
+ link: typeof row.link === 'string' ? row.link : null,
141
+ blocks,
142
+ // rounded as `cost_per_block` rounds its dollars (4 decimals)
143
+ code_forge_usd: roundUsd(usd),
144
+ unknown_usd_sessions: mine.reduce((sum, e) => sum + e.unknownUsdSessions, 0),
145
+ };
146
+ });
147
+ }
148
+
149
+ /** @param {number} n @param {string} word @returns {string} */
150
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
151
+
152
+ /**
153
+ * Terminal text of the section: `outside review: none` with no entry, else one line per PR.
154
+ * @param {OutsideReviewEntry[]} section @returns {string}
155
+ */
156
+ export function outsideReviewLines(section) {
157
+ if (section.length === 0) return 'outside review: none\n';
158
+ return section
159
+ .map((e) => {
160
+ const unknown = e.unknown_usd_sessions > 0 ? ` (+${e.unknown_usd_sessions} unknown)` : '';
161
+ return `PR #${e.pr} · ${plural(e.rounds, 'outside round')} · ${plural(e.findings, 'finding')} · code-forge $${e.code_forge_usd.toFixed(2)}${unknown} · ${blockList(e.blocks)}\n`;
162
+ })
163
+ .join('');
164
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `code-forge report` (plan §6.2, R4): builds the 13 report sections from a run's ledger rows.
2
+ * `code-forge report` (plan §6.2, R4): builds the 14 report sections from a run's ledger rows.
3
3
  * Pure data, no I/O — terminal rendering + `--export` live in `src/cli/report.mjs`.
4
4
  *
5
5
  * Shadow rows (`row.source === 'shadow'`) are excluded everywhere here (plan §6.3: shadow-sampled
@@ -12,10 +12,15 @@
12
12
  * coder/reviewer/judge/s2/author/facts spend (plus `ledger add coder` manual rows and
13
13
  * `session.background` rows, whose price is unknown); its dollars come from `./spend.mjs`
14
14
  * (`usd`, else `cost_usd`, else ESTIMATED from its tokens × `./prices.mjs`); `review.done` {block,file,context_mode,findings_by_severity,round}; `decision`
15
- * {block,question,source}; `outcome` {block,missed_after_degrade}; `escalation` {block,trigger};
16
- * `review.unavailable` {block,reason}; `proof` {block,duration_ms}; `run.stop` {reason}.
15
+ * {block,question,source}; `run.start` {run}; `run.mode` {run,mode,plan} (B59, `buildRunModes`); `outcome` {block,missed_after_degrade}; `escalation` {block,trigger};
16
+ * `review.unavailable` {block,reason}; `proof` {block,duration_ms}; `run.stop` {reason};
17
+ * `outside_review` {run,pr,rounds,findings,link,blocks} (B66, `./outside-review.mjs`).
18
+ *
19
+ * Signatures are not verified here: like every other section, `outside_review` reads the rows as
20
+ * the ledger holds them (an unsigned or tampered row is counted, not dropped).
17
21
  */
18
22
 
23
+ import { buildOutsideReview } from './outside-review.mjs';
19
24
  import { isSpendRow, rowUsd, spendByRun, sumSpend } from './spend.mjs';
20
25
 
21
26
  const ROLE_BUCKET = Object.freeze({
@@ -51,7 +56,7 @@ export function sessionCostUsd(row) {
51
56
 
52
57
  /**
53
58
  * B33: spend per run (running totals, open blocks included) and over the whole ledger — kept
54
- * beside the 13 sections, not as a 14th (`SECTION_NAMES` stays pinned at 13).
59
+ * beside the sections, never one of them (text only, not in `SECTION_NAMES`).
55
60
  * @param {Array<Record<string, any>>} rows
56
61
  * @returns {{runs: Array<{run: string | null, usd: number, unknown: number, sessions: number}>, total: {usd: number, unknown: number, sessions: number}}}
57
62
  */
@@ -60,6 +65,20 @@ export function buildSpend(rows) {
60
65
  return { runs: spendByRun(acted), total: sumSpend(acted) };
61
66
  }
62
67
 
68
+ /**
69
+ * B59: the mode of every run with a `run.start` row, in ledger order — `brief` when the run has a
70
+ * `run.mode {mode: brief}` row, else `plan` (a run with no `run.mode` row is a plan run). Like the
71
+ * spend, it is terminal text only (`src/cli/report.mjs`): it is not in `--json` or `--export`, so
72
+ * the pinned `SECTION_NAMES` sections stay exactly as they are.
73
+ * @param {Array<Record<string, any>>} rows
74
+ * @returns {Array<{run: string, mode: 'brief' | 'plan'}>}
75
+ */
76
+ export function buildRunModes(rows) {
77
+ const brief = new Set(rows.filter((r) => r.event === 'run.mode' && r.mode === 'brief' && typeof r.run === 'string').map((r) => r.run));
78
+ const runs = [...new Set(rows.filter((r) => r.event === 'run.start' && typeof r.run === 'string').map((r) => r.run))];
79
+ return runs.map((run) => ({ run, mode: brief.has(run) ? 'brief' : 'plan' }));
80
+ }
81
+
63
82
  /**
64
83
  * Cost per block — completed AND still open (B33: a running total while the block is open):
65
84
  * review $ / coder $ / S1+S2 $ / facts $ / proof ms, `completed` (any `block.close` row) and
@@ -243,7 +262,8 @@ const runStopCounts = (rows) => ({
243
262
  noEngineStops: rows.filter((r) => r.event === 'run.stop' && r.reason === 'no_engine').length,
244
263
  });
245
264
 
246
- /** The exact, ordered set of section keys `buildReport` produces (plan §6.2: "13 sections") —
265
+ /** The exact, ordered set of section keys `buildReport` produces (plan §6.2; B66 appended the
266
+ * 14th, `outside_review` — append new sections, never reorder) —
247
267
  * exported so tests (and `--export`) can assert against ONE source of truth instead of a copy. */
248
268
  export const SECTION_NAMES = Object.freeze([
249
269
  'cost_per_block',
@@ -259,6 +279,7 @@ export const SECTION_NAMES = Object.freeze([
259
279
  'forecast_vs_actual_lines',
260
280
  'blocks_stopped_at_l3',
261
281
  'run_stop_counts',
282
+ 'outside_review',
262
283
  ]);
263
284
 
264
285
  /**
@@ -267,8 +288,10 @@ export const SECTION_NAMES = Object.freeze([
267
288
  */
268
289
  export function buildReport(rows) {
269
290
  const acted = rows.filter((r) => r.source !== 'shadow');
291
+ // computed once: `cost_per_block` and `outside_review` share the same entries
292
+ const costs = costPerBlock(acted);
270
293
  const builders = {
271
- cost_per_block: costPerBlock,
294
+ cost_per_block: () => costs,
272
295
  lane_distribution: laneDistribution,
273
296
  escalations: escalationsPerBlock,
274
297
  s1_calls_per_block: s1CallsPerBlock,
@@ -281,6 +304,9 @@ export function buildReport(rows) {
281
304
  forecast_vs_actual_lines: forecastVsActualLines,
282
305
  blocks_stopped_at_l3: blocksStoppedAtL3,
283
306
  run_stop_counts: runStopCounts,
307
+ // B66: per PR, the outside review rounds next to the cost of its blocks — the SAME
308
+ // `costPerBlock` entries the `cost_per_block` section shows
309
+ outside_review: (/** @type {any[]} */ r) => buildOutsideReview(r, costs),
284
310
  };
285
311
  /** @type {Record<string, any>} */
286
312
  const sections = {};
@@ -10,7 +10,12 @@
10
10
  * (c) `proof.export.copy_untracked` paths that exist in the main tree ride along through
11
11
  * `git archive --add-file` (never anything under `.code-forge/`);
12
12
  * (d) the block's owned files at their CURRENT content (new files included, deleted ones removed);
13
- * (e) `proof.export.link_dirs` symlinked from the main tree.
13
+ * (e) `proof.export.link_dirs` symlinked from the main tree — except a Composer vendor directory
14
+ * (B61: it holds `composer/autoload_real.php`), which is COPIED. PHP resolves `__DIR__`
15
+ * through symlinks, so a linked vendor makes Composer's autoloader load the app's own classes
16
+ * (`App\…`, `Tests\…`) from the MAIN tree, not the export: another block's half-written file
17
+ * would leak into the measurement. Composer's generated paths are relative to the vendor
18
+ * directory, so the copy points at the export's own `app/`, `tests/` and `database/`.
14
19
  *
15
20
  * A gate that fails inside the export on a file the export lacks but the main tree has is
16
21
  * `export.incomplete: <path>`, never a red test (`classifyExportFailure`).
@@ -24,7 +29,8 @@
24
29
  * `checkout-index`).
25
30
  */
26
31
 
27
- import { copyFile, lstat, mkdir, mkdtemp, readlink, realpath, rm, stat, symlink } from 'node:fs/promises';
32
+ import { constants } from 'node:fs';
33
+ import { copyFile, cp, lstat, mkdir, mkdtemp, readdir, readlink, realpath, rm, stat, symlink } from 'node:fs/promises';
28
34
  import path from 'node:path';
29
35
  import { runGates } from '../gates/run.mjs';
30
36
  import { StateError } from '../state/paths.mjs';
@@ -44,6 +50,8 @@ export const ISOLATIONS = Object.freeze(/** @type {const} */ (['export', 'lock']
44
50
  const BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
45
51
  const GIT_TIMEOUT_MS = 120000;
46
52
  const STATE_DIR = '.code-forge';
53
+ /** B61: the file that makes a `link_dirs` entry a Composer vendor directory (copied, not linked). */
54
+ const COMPOSER_AUTOLOADER = path.join('composer', 'autoload_real.php');
47
55
 
48
56
  /**
49
57
  * `process.env` minus every `GIT_*`, plus `extra`.
@@ -85,6 +93,58 @@ function assertRelPath(p, key) {
85
93
  return p;
86
94
  }
87
95
 
96
+ /**
97
+ * B61: a Composer vendor directory (it holds `composer/autoload_real.php`) — copied into the export,
98
+ * never linked: PHP resolves `__DIR__` through a symlink, so a linked vendor would autoload the
99
+ * app's classes from the main tree.
100
+ * @param {string} dir @returns {Promise<boolean>}
101
+ */
102
+ async function isComposerVendor(dir) {
103
+ return (await stat(path.join(dir, COMPOSER_AUTOLOADER)).catch(() => null))?.isFile() ?? false;
104
+ }
105
+
106
+ /**
107
+ * B61: inside a copied dependency dir, every absolute symlink whose target is in the main tree
108
+ * (a Composer path repository: `vendor/acme/pkg -> <main>/packages/pkg`) is pointed at the same
109
+ * place in the export; a link to the main-tree root points at the export root. Relative links,
110
+ * links outside the main tree and links into the main tree's `.code-forge/` (the export has none)
111
+ * stay as they are.
112
+ *
113
+ * `copy` must be a real directory (refused otherwise, before anything changes), and the walk never
114
+ * goes through a symlink: a `Dirent` from `readdir` describes the entry itself (lstat semantics), so
115
+ * only real directories are entered — a link is only ever rewritten, never followed, and nothing
116
+ * outside the copy is touched.
117
+ * @param {string} copy - the copied dir in the export @param {string[]} mainRoots - the main tree, as given and real
118
+ * @param {string} dir - the export root
119
+ */
120
+ async function retargetMainTreeLinks(copy, mainRoots, dir) {
121
+ const at = await lstatOrNull(copy);
122
+ if (!at?.isDirectory()) throw new StateError('export-not-copied', `buildExport: ${copy} is not a real directory; its links are left unchanged`);
123
+ /** @type {string[]} */
124
+ const pending = [copy];
125
+ while (pending.length > 0) {
126
+ const current = /** @type {string} */ (pending.pop());
127
+ for (const entry of await readdir(current, { withFileTypes: true })) {
128
+ const entryPath = path.join(current, entry.name);
129
+ if (entry.isDirectory()) {
130
+ pending.push(entryPath);
131
+ continue;
132
+ }
133
+ if (!entry.isSymbolicLink()) continue;
134
+ const target = await readlink(entryPath);
135
+ if (!path.isAbsolute(target)) continue;
136
+ for (const root of mainRoots) {
137
+ const rel = path.relative(root, target);
138
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) continue;
139
+ if (rel === STATE_DIR || rel.startsWith(`${STATE_DIR}${path.sep}`)) break;
140
+ await rm(entryPath, { force: true });
141
+ await symlink(rel === '' ? dir : path.join(dir, rel), entryPath);
142
+ break;
143
+ }
144
+ }
145
+ }
146
+ }
147
+
88
148
  /** @param {string} p @returns {Promise<import('node:fs').Stats | null>} */
89
149
  async function lstatOrNull(p) {
90
150
  try {
@@ -160,8 +220,8 @@ async function exportIgnoredBlobs(entries, base, cwd) {
160
220
  * cwd: string, blockId: string, baseSha: string, owned: ReadonlyArray<string>, ownedKinds?: Readonly<Record<string, string>>,
161
221
  * linkDirs?: ReadonlyArray<string>, copyUntracked?: ReadonlyArray<string>, exportRoot?: string,
162
222
  * }} opts
163
- * @returns {Promise<{dir: string, base: string, restored: string[], untracked: string[], owned: string[], removed: string[], linked: string[]}>}
164
- * repo-relative lists, sorted.
223
+ * @returns {Promise<{dir: string, base: string, restored: string[], untracked: string[], owned: string[], removed: string[], linked: string[], copied: string[]}>}
224
+ * repo-relative lists, sorted; `copied` is the `linkDirs` entries copied as Composer vendor dirs.
165
225
  */
166
226
  export async function buildExport(opts) {
167
227
  const { blockId, baseSha, owned, linkDirs = DEFAULT_LINK_DIRS, copyUntracked = DEFAULT_COPY_UNTRACKED } = opts;
@@ -268,8 +328,9 @@ export async function buildExport(opts) {
268
328
  ownedCopied.push(rel);
269
329
  }
270
330
 
271
- // (e) dependency dirs, linked from the main tree.
331
+ // (e) dependency dirs, linked from the main tree; a Composer vendor dir is copied (B61).
272
332
  const linked = [];
333
+ const copied = [];
273
334
  for (const rel of linkDirs) {
274
335
  const src = path.join(cwd, rel);
275
336
  const st = await stat(src).catch(() => null);
@@ -277,11 +338,31 @@ export async function buildExport(opts) {
277
338
  const dest = path.join(dir, rel);
278
339
  await rm(dest, { recursive: true, force: true });
279
340
  await mkdir(path.dirname(dest), { recursive: true });
341
+ const real = await realpath(src);
342
+ if (await isComposerVendor(real)) {
343
+ // Copied from its real directory, so the copy is a real directory even when the main tree's
344
+ // entry is a symlink. Relative symlinks inside (vendor/bin entries) resolve in the copy;
345
+ // absolute ones into the main tree are pointed at the export. A copy-on-write clone where
346
+ // the file system has one.
347
+ await cp(real, dest, { recursive: true, verbatimSymlinks: true, mode: constants.COPYFILE_FICLONE });
348
+ await retargetMainTreeLinks(dest, [...new Set([path.resolve(opts.cwd), cwd])], dir);
349
+ copied.push(rel);
350
+ continue;
351
+ }
280
352
  await symlink(src, dest, 'dir');
281
353
  linked.push(rel);
282
354
  }
283
355
 
284
- return { dir, base, restored: [...restored].sort(), untracked: untracked.sort(), owned: ownedCopied, removed: [...removedSet].sort(), linked: linked.sort() };
356
+ return {
357
+ dir,
358
+ base,
359
+ restored: [...restored].sort(),
360
+ untracked: untracked.sort(),
361
+ owned: ownedCopied,
362
+ removed: [...removedSet].sort(),
363
+ linked: linked.sort(),
364
+ copied: copied.sort(),
365
+ };
285
366
  } finally {
286
367
  await rm(scratch, { recursive: true, force: true });
287
368
  }
@@ -358,11 +439,13 @@ export function incompleteMessage(missing) {
358
439
 
359
440
  /**
360
441
  * Run the block's gates (B5 `runGates`) inside the export; a red gate whose output is a
361
- * missing-file error for a main-tree path is re-labelled `export.incomplete`.
362
- * @param {{gates: Parameters<typeof runGates>[0], exportDir: string, mainTree: string, timeoutMs?: number}} opts
442
+ * missing-file error for a main-tree path is re-labelled `export.incomplete`. B60: `scope` (the
443
+ * block's `testScope`) is handed to `runGates` for a `{test_files}` gate; the test paths are
444
+ * repo-relative, so they name the same files inside the export.
445
+ * @param {{gates: Parameters<typeof runGates>[0], exportDir: string, mainTree: string, timeoutMs?: number, scope?: import('../gates/expand.mjs').TestScope}} opts
363
446
  */
364
- export async function runGatesInExport({ gates, exportDir, mainTree, timeoutMs }) {
365
- const { results } = await runGates(gates, { cwd: exportDir, ...(timeoutMs ? { timeoutMs } : {}) });
447
+ export async function runGatesInExport({ gates, exportDir, mainTree, timeoutMs, scope }) {
448
+ const { results } = await runGates(gates, { cwd: exportDir, ...(timeoutMs ? { timeoutMs } : {}), ...(scope ? { scope } : {}) });
366
449
  /** @type {{gate: string, path: string, message: string}[]} */
367
450
  const incomplete = [];
368
451
  const labelled = [];
@@ -16,6 +16,8 @@
16
16
  * with `isolation: lock` through the B6 writer.
17
17
  */
18
18
 
19
+ import { hasTestFilesItem, testScope } from '../gates/expand.mjs';
20
+ import { ownedFileCount } from '../gates/owned-count.mjs';
19
21
  import { runGates } from '../gates/run.mjs';
20
22
  import { StateError } from '../state/paths.mjs';
21
23
  import { readRun, saveRun, withRunLock } from '../state/run.mjs';
@@ -129,10 +131,31 @@ export async function withGateSlot({ runId, blockId, fn, pid = process.pid }) {
129
131
  }
130
132
 
131
133
  /**
132
- * B5's gate runner behind the lock — the entry point a gate caller (`gates run --run --block`,
133
- * the B12 block gate) uses.
134
- * @param {{runId: string, blockId: string, gates: Parameters<typeof runGates>[0], cwd: string, timeoutMs?: number}} opts
134
+ * B5's gate runner behind the lock. A gate caller (`gates run --run --block`, the B12 block gate)
135
+ * uses it.
136
+ *
137
+ * B60: a `{test_files}` gate runs the block's related tests. These are its `test_files` (from
138
+ * `block open --tests`), else its owned entries that look like tests (`testScope`, read from the
139
+ * run record once the slot is taken). The full suite runs instead when the block owns more than
140
+ * `threshold` (`gates.full_suite_threshold_files`) files, or when no test file is known. The
141
+ * owned files are counted by `ownedFileCount` over the tracked and new (not ignored) files at
142
+ * `cwd`. Gates with no `{test_files}` item run exactly as before B60: the run record is not read
143
+ * and nothing is counted.
144
+ * @param {{runId: string, blockId: string, gates: Parameters<typeof runGates>[0], cwd: string, timeoutMs?: number, threshold?: number}} opts
135
145
  */
136
- export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs }) {
137
- return withGateSlot({ runId, blockId, fn: () => runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) }) });
146
+ export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs, threshold }) {
147
+ return withGateSlot({
148
+ runId,
149
+ blockId,
150
+ fn: async () => {
151
+ // plain gates (no `{test_files}` item) run exactly as before B60: no scope is worked out
152
+ if (!hasTestFilesItem(gates)) return runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
153
+ // the slot checked the block is open; it may still be gone by the time it is read again
154
+ const block = (await readRun(runId)).blocks?.[blockId];
155
+ if (!block) throw new StateError('no-block', `block ${blockId} is not in run ${runId}`);
156
+ const ownedFiles = await ownedFileCount(cwd, block.owned_files, block.owned_kinds);
157
+ const scope = testScope({ testFiles: block.test_files, owned: block.owned_files, ownedFiles, ...(threshold === undefined ? {} : { threshold }) });
158
+ return runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}), scope });
159
+ },
160
+ });
138
161
  }