@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.
- package/CHANGELOG.md +97 -0
- package/README.md +68 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +390 -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 +45 -7
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +25 -5
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +19 -2
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +427 -41
- 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 +7 -4
- package/src/cli/review-file.mjs +324 -18
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +91 -3
- 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 +17 -5
- package/src/ledger/write.mjs +7 -1
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +36 -2
- package/src/review/fixloop.mjs +25 -15
- package/src/review/gate-check.mjs +384 -83
- package/src/review/open-findings.mjs +59 -0
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +103 -39
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +51 -0
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +10 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +46 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +230 -24
- package/src/worker/loop.mjs +29 -8
- package/src/worker/queue.mjs +296 -15
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +46 -6
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
|
}
|