@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.
- package/CHANGELOG.md +51 -0
- package/README.md +57 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +171 -1
- package/docs/reference/config.md +26 -8
- package/package.json +1 -1
- package/schema/code-forge.schema.json +81 -4
- package/skill/SKILL.md +6 -6
- package/skill/references/code.md +33 -4
- package/skill/references/ledger.md +8 -4
- package/skill/references/plan.md +12 -0
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +4 -2
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +18 -1
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +337 -37
- package/src/cli/context.mjs +175 -0
- package/src/cli/gates.mjs +125 -15
- package/src/cli/ledger.mjs +46 -2
- package/src/cli/proof.mjs +62 -16
- package/src/cli/report.mjs +21 -4
- package/src/cli/review-file.mjs +61 -6
- package/src/cli/run.mjs +16 -6
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/decide/lane-facts.mjs +41 -0
- package/src/doctor/local.mjs +2 -1
- package/src/engines/sentinel.mjs +27 -6
- package/src/gates/detect.mjs +21 -8
- package/src/gates/expand.mjs +202 -0
- package/src/gates/owned-count.mjs +66 -0
- package/src/gates/run.mjs +49 -12
- package/src/install/wizard/answers.mjs +34 -5
- package/src/install/wizard/flags.mjs +8 -1
- package/src/install/wizard/project-file.mjs +2 -0
- package/src/install/wizard/run.mjs +6 -0
- package/src/install/wizard/steps.mjs +24 -4
- package/src/install/wizard/summary.mjs +13 -5
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/outside-review.mjs +164 -0
- package/src/ledger/report.mjs +32 -6
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/engine.mjs +3 -2
- package/src/review/gate-check.mjs +9 -5
- package/src/review/open-findings.mjs +59 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +111 -37
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +68 -8
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +21 -2
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +36 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/worker/engine.mjs +157 -17
- package/src/worker/loop.mjs +26 -7
- package/src/worker/queue.mjs +36 -10
- 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
|
+
}
|
package/src/ledger/report.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `code-forge report` (plan §6.2, R4): builds the
|
|
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
|
|
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
|
|
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:
|
|
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 = {};
|
package/src/proof/export.mjs
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
*
|
|
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 = [];
|
package/src/proof/lock.mjs
CHANGED
|
@@ -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
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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({
|
|
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
|
}
|