@codedology/code-forge 0.1.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 +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The measurement export (plan §4.7, `proof.isolation: export`, hardened per C5). A block's gates,
|
|
3
|
+
* red→green and test-count delta run in `.code-forge/export/<block>/`, never in the shared tree
|
|
4
|
+
* where other blocks are writing:
|
|
5
|
+
*
|
|
6
|
+
* (a) `git archive <base>` extracted with `tar -x`;
|
|
7
|
+
* (b) export-ignore restoration — every base path whose `export-ignore` attribute is set (the
|
|
8
|
+
* path itself or a parent directory; attributes read AT BASE with `git check-attr
|
|
9
|
+
* --source=<base>`, plus `.git/info/attributes`) is written back from the base tree;
|
|
10
|
+
* (c) `proof.export.copy_untracked` paths that exist in the main tree ride along through
|
|
11
|
+
* `git archive --add-file` (never anything under `.code-forge/`);
|
|
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.
|
|
14
|
+
*
|
|
15
|
+
* A gate that fails inside the export on a file the export lacks but the main tree has is
|
|
16
|
+
* `export.incomplete: <path>`, never a red test (`classifyExportFailure`).
|
|
17
|
+
*
|
|
18
|
+
* Git calls: `rev-parse` goes through B0's `git.mjs`; the verbs `git.mjs` does not model
|
|
19
|
+
* (`archive -o` with `--add-file`, `ls-tree`, `check-attr`, `read-tree`/`checkout-index` on a
|
|
20
|
+
* TEMPORARY index, `ls-files` with flags) run through `exec` with the same environment rule:
|
|
21
|
+
* every inherited `GIT_*` variable is stripped (a hook's `GIT_DIR` must not redirect us), the
|
|
22
|
+
* only one set is the temporary `GIT_INDEX_FILE`. The real index and working tree are never
|
|
23
|
+
* written. Blob contents are never decoded to strings (binary-safe: `archive -o` + `tar`,
|
|
24
|
+
* `checkout-index`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { copyFile, lstat, mkdir, mkdtemp, readlink, realpath, rm, stat, symlink } from 'node:fs/promises';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { runGates } from '../gates/run.mjs';
|
|
30
|
+
import { StateError } from '../state/paths.mjs';
|
|
31
|
+
import { ownsFile } from '../state/registry.mjs';
|
|
32
|
+
import { writeSigned } from '../state/run.mjs';
|
|
33
|
+
import { exec } from '../util/exec.mjs';
|
|
34
|
+
import { revParse } from '../util/git.mjs';
|
|
35
|
+
import { currentRunRoot } from '../util/tmp.mjs';
|
|
36
|
+
|
|
37
|
+
/** §1.3 defaults (C5). */
|
|
38
|
+
export const DEFAULT_LINK_DIRS = Object.freeze(['vendor', 'node_modules']);
|
|
39
|
+
export const DEFAULT_COPY_UNTRACKED = Object.freeze(['.env', '.env.testing']);
|
|
40
|
+
|
|
41
|
+
/** Where a proof row may say it measured (§6.1 `isolation`). */
|
|
42
|
+
export const ISOLATIONS = Object.freeze(/** @type {const} */ (['export', 'lock']));
|
|
43
|
+
|
|
44
|
+
const BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
|
|
45
|
+
const GIT_TIMEOUT_MS = 120000;
|
|
46
|
+
const STATE_DIR = '.code-forge';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `process.env` minus every `GIT_*`, plus `extra`.
|
|
50
|
+
* @param {Record<string, string>} [extra]
|
|
51
|
+
* @returns {NodeJS.ProcessEnv}
|
|
52
|
+
*/
|
|
53
|
+
function gitEnv(extra = {}) {
|
|
54
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
55
|
+
const env = {};
|
|
56
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
57
|
+
if (!key.startsWith('GIT_')) env[key] = value;
|
|
58
|
+
}
|
|
59
|
+
return { ...env, ...extra };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string[]} args @param {string} cwd
|
|
64
|
+
* @param {{input?: string, env?: Record<string, string>}} [opts]
|
|
65
|
+
* @returns {Promise<string>} stdout
|
|
66
|
+
*/
|
|
67
|
+
async function git(args, cwd, opts = {}) {
|
|
68
|
+
const res = await exec(['git', ...args], { cwd, env: gitEnv(opts.env), input: opts.input, timeoutMs: GIT_TIMEOUT_MS });
|
|
69
|
+
if (res.result !== 'ok') throw new StateError('git-failed', `git ${args[0]} failed (exit ${res.code})`);
|
|
70
|
+
return res.stdout;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A repo-relative POSIX path with no `.`/`..`/empty segment.
|
|
75
|
+
* @param {unknown} p @param {string} key - the config key or flag the value came from
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function assertRelPath(p, key) {
|
|
79
|
+
if (typeof p !== 'string' || p.length === 0 || path.isAbsolute(p) || p.includes('\\')) {
|
|
80
|
+
throw new StateError('bad-path', `${key}: every entry must be a repo-relative POSIX path`);
|
|
81
|
+
}
|
|
82
|
+
if (p.split('/').some((s) => s === '' || s === '.' || s === '..')) {
|
|
83
|
+
throw new StateError('bad-path', `${key}: no ".", ".." or empty segments`);
|
|
84
|
+
}
|
|
85
|
+
return p;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {string} p @returns {Promise<import('node:fs').Stats | null>} */
|
|
89
|
+
async function lstatOrNull(p) {
|
|
90
|
+
try {
|
|
91
|
+
return await lstat(p);
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} cwd @param {string} blockId @param {string} [exportRoot]
|
|
99
|
+
* @returns {string} `<exportRoot | <cwd>/.code-forge/export>/<block>`
|
|
100
|
+
*/
|
|
101
|
+
export function exportDirFor(cwd, blockId, exportRoot) {
|
|
102
|
+
if (typeof blockId !== 'string' || !BLOCK_ID.test(blockId)) throw new StateError('bad-block-id', `block id must match ${BLOCK_ID}`);
|
|
103
|
+
return path.join(exportRoot ?? path.join(cwd, STATE_DIR, 'export'), blockId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `git ls-tree -r -t -z <base>` parsed.
|
|
108
|
+
* @param {string} base @param {string} cwd
|
|
109
|
+
* @returns {Promise<{mode: string, type: string, path: string}[]>}
|
|
110
|
+
*/
|
|
111
|
+
async function lsTree(base, cwd) {
|
|
112
|
+
const out = await git(['ls-tree', '-r', '-t', '-z', '--full-tree', base], cwd);
|
|
113
|
+
return out
|
|
114
|
+
.split('\0')
|
|
115
|
+
.filter((line) => line.length > 0)
|
|
116
|
+
.map((line) => {
|
|
117
|
+
const tab = line.indexOf('\t');
|
|
118
|
+
const [mode, type] = line.slice(0, tab).split(' ');
|
|
119
|
+
return { mode, type, path: line.slice(tab + 1) };
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Base blobs the plain archive drops: `export-ignore` set on the path or on a parent directory,
|
|
125
|
+
* with the attributes as they are AT BASE.
|
|
126
|
+
* @param {{type: string, path: string}[]} entries @param {string} base @param {string} cwd
|
|
127
|
+
* @returns {Promise<string[]>}
|
|
128
|
+
*/
|
|
129
|
+
async function exportIgnoredBlobs(entries, base, cwd) {
|
|
130
|
+
const relevant = entries.filter((e) => e.type === 'tree' || e.type === 'blob');
|
|
131
|
+
if (relevant.length === 0) return [];
|
|
132
|
+
// A directory pattern (`docs/`) only matches a path spelled as a directory, so trees are asked
|
|
133
|
+
// with a trailing slash.
|
|
134
|
+
const input = `${relevant.map((e) => (e.type === 'tree' ? `${e.path}/` : e.path)).join('\0')}\0`;
|
|
135
|
+
const out = await git(['check-attr', `--source=${base}`, '-z', '--stdin', 'export-ignore'], cwd, { input });
|
|
136
|
+
const fields = out.split('\0');
|
|
137
|
+
const ignored = new Set();
|
|
138
|
+
for (let i = 0; i + 2 < fields.length; i += 3) {
|
|
139
|
+
if (fields[i + 2] === 'set') ignored.add(fields[i].replace(/\/$/, ''));
|
|
140
|
+
}
|
|
141
|
+
const underIgnored = (/** @type {string} */ p) => {
|
|
142
|
+
const segments = p.split('/');
|
|
143
|
+
for (let n = 1; n <= segments.length; n += 1) if (ignored.has(segments.slice(0, n).join('/'))) return true;
|
|
144
|
+
return false;
|
|
145
|
+
};
|
|
146
|
+
return relevant.filter((e) => e.type === 'blob' && underIgnored(e.path)).map((e) => e.path);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build (or rebuild) the export of one block.
|
|
151
|
+
* @param {{
|
|
152
|
+
* cwd: string, blockId: string, baseSha: string, owned: ReadonlyArray<string>,
|
|
153
|
+
* linkDirs?: ReadonlyArray<string>, copyUntracked?: ReadonlyArray<string>, exportRoot?: string,
|
|
154
|
+
* }} opts
|
|
155
|
+
* @returns {Promise<{dir: string, base: string, restored: string[], untracked: string[], owned: string[], removed: string[], linked: string[]}>}
|
|
156
|
+
* repo-relative lists, sorted.
|
|
157
|
+
*/
|
|
158
|
+
export async function buildExport(opts) {
|
|
159
|
+
const { blockId, baseSha, owned, linkDirs = DEFAULT_LINK_DIRS, copyUntracked = DEFAULT_COPY_UNTRACKED } = opts;
|
|
160
|
+
if (typeof opts.cwd !== 'string' || !path.isAbsolute(opts.cwd)) throw new StateError('bad-path', 'buildExport: cwd must be an absolute path');
|
|
161
|
+
const cwd = await realpath(opts.cwd);
|
|
162
|
+
const dir = exportDirFor(cwd, blockId, opts.exportRoot);
|
|
163
|
+
if (!Array.isArray(owned)) throw new StateError('bad-owned', 'buildExport: owned must be an array');
|
|
164
|
+
for (const p of linkDirs) assertRelPath(p, 'proof.export.link_dirs');
|
|
165
|
+
for (const p of copyUntracked) {
|
|
166
|
+
assertRelPath(p, 'proof.export.copy_untracked');
|
|
167
|
+
if (p === STATE_DIR || p.startsWith(`${STATE_DIR}/`)) {
|
|
168
|
+
throw new StateError('bad-path', `proof.export.copy_untracked: nothing under ${STATE_DIR}/ is ever exported`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const resolved = await revParse(`${baseSha}^{commit}`, cwd);
|
|
172
|
+
if (resolved.result !== 'ok') throw new StateError('bad-ref', 'buildExport: the base is not a commit in this repository');
|
|
173
|
+
const base = resolved.stdout.trim();
|
|
174
|
+
|
|
175
|
+
await rm(dir, { recursive: true, force: true });
|
|
176
|
+
await mkdir(dir, { recursive: true });
|
|
177
|
+
const scratch = await mkdtemp(path.join(currentRunRoot(), 'export-'));
|
|
178
|
+
try {
|
|
179
|
+
const entries = await lsTree(base, cwd);
|
|
180
|
+
const baseBlobs = new Set(entries.filter((e) => e.type === 'blob').map((e) => e.path));
|
|
181
|
+
|
|
182
|
+
// (a) + (c): one archive, untracked requirements added at their own directory.
|
|
183
|
+
const untracked = [];
|
|
184
|
+
const addArgs = [];
|
|
185
|
+
for (const rel of copyUntracked) {
|
|
186
|
+
if (baseBlobs.has(rel)) continue; // tracked at base — already in the archive (or restored below)
|
|
187
|
+
const st = await lstatOrNull(path.join(cwd, rel));
|
|
188
|
+
if (!st?.isFile()) continue;
|
|
189
|
+
const parent = path.posix.dirname(rel);
|
|
190
|
+
addArgs.push(`--prefix=${parent === '.' ? '' : `${parent}/`}`, `--add-file=${path.join(cwd, rel)}`);
|
|
191
|
+
untracked.push(rel);
|
|
192
|
+
}
|
|
193
|
+
const tar = path.join(scratch, 'base.tar');
|
|
194
|
+
// The rightmost --prefix applies to the tracked tree: reset it to the root.
|
|
195
|
+
await git(['archive', '--format=tar', '-o', tar, ...addArgs, '--prefix=', base], cwd);
|
|
196
|
+
const extracted = await exec(['tar', '-xf', tar, '-C', dir], { timeoutMs: GIT_TIMEOUT_MS });
|
|
197
|
+
if (extracted.result !== 'ok') throw new StateError('tar-failed', `tar -x failed (exit ${extracted.code})`);
|
|
198
|
+
|
|
199
|
+
// (b) export-ignore restoration from a temporary index (the real index is never touched).
|
|
200
|
+
const restored = await exportIgnoredBlobs(entries, base, cwd);
|
|
201
|
+
if (restored.length > 0) {
|
|
202
|
+
const env = { GIT_INDEX_FILE: path.join(scratch, 'index') };
|
|
203
|
+
await git(['read-tree', base], cwd, { env });
|
|
204
|
+
await git(['checkout-index', '-f', '-z', '--stdin', `--prefix=${dir}${path.sep}`], cwd, { env, input: `${restored.join('\0')}\0` });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// (d) owned files at current content; owned base files deleted in the tree leave the export.
|
|
208
|
+
const listed = (await git(['ls-files', '-z', '--cached', '--others', '--exclude-standard'], cwd)).split('\0').filter(Boolean);
|
|
209
|
+
const exact = owned.filter((o) => !/[*?{]/.test(o));
|
|
210
|
+
const candidates = [...new Set([...listed, ...exact, ...baseBlobs])].filter((f) => ownsFile(owned, f)).sort();
|
|
211
|
+
const ownedCopied = [];
|
|
212
|
+
const removed = [];
|
|
213
|
+
for (const rel of candidates) {
|
|
214
|
+
assertRelPath(rel, 'owned files');
|
|
215
|
+
const src = path.join(cwd, rel);
|
|
216
|
+
const dest = path.join(dir, rel);
|
|
217
|
+
const st = await lstatOrNull(src);
|
|
218
|
+
await rm(dest, { recursive: true, force: true });
|
|
219
|
+
if (st === null) {
|
|
220
|
+
if (baseBlobs.has(rel)) removed.push(rel);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (!st.isFile() && !st.isSymbolicLink()) continue;
|
|
224
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
225
|
+
if (st.isSymbolicLink()) await symlink(await readlink(src), dest);
|
|
226
|
+
else await copyFile(src, dest);
|
|
227
|
+
ownedCopied.push(rel);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// (e) dependency dirs, linked from the main tree.
|
|
231
|
+
const linked = [];
|
|
232
|
+
for (const rel of linkDirs) {
|
|
233
|
+
const src = path.join(cwd, rel);
|
|
234
|
+
const st = await stat(src).catch(() => null);
|
|
235
|
+
if (!st?.isDirectory()) continue;
|
|
236
|
+
const dest = path.join(dir, rel);
|
|
237
|
+
await rm(dest, { recursive: true, force: true });
|
|
238
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
239
|
+
await symlink(src, dest, 'dir');
|
|
240
|
+
linked.push(rel);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { dir, base, restored: [...restored].sort(), untracked: untracked.sort(), owned: ownedCopied, removed, linked: linked.sort() };
|
|
244
|
+
} finally {
|
|
245
|
+
await rm(scratch, { recursive: true, force: true });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Remove a block's export (block close).
|
|
251
|
+
* @param {{cwd: string, blockId: string, exportRoot?: string}} opts
|
|
252
|
+
*/
|
|
253
|
+
export async function removeExport({ cwd, blockId, exportRoot }) {
|
|
254
|
+
await rm(exportDirFor(cwd, blockId, exportRoot), { recursive: true, force: true });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Missing-file errors a gate prints (§4.7 refusal). Each captures one candidate path or class. */
|
|
258
|
+
const MISSING_FILE_PATTERNS = Object.freeze([
|
|
259
|
+
/ENOENT: no such file or directory, \w+ '([^']+)'/g,
|
|
260
|
+
/\(([^()\s]+)\): [Ff]ailed to open stream: No such file or directory/g,
|
|
261
|
+
/could not open (?:input file: )?['"]?([^'"\s]+)/gi,
|
|
262
|
+
/['"]?([^\s'"]+?)['"]?: No such file or directory/g,
|
|
263
|
+
]);
|
|
264
|
+
const CLASS_NOT_FOUND = /Class "?([A-Za-z_][\w\\]*)"? not found/g;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The repo-relative spelling of a path a gate printed, or null when it is outside both trees.
|
|
268
|
+
* @param {string} candidate @param {string[]} exportDirs @param {string[]} mainTrees
|
|
269
|
+
* @returns {string | null}
|
|
270
|
+
*/
|
|
271
|
+
function toRepoRelative(candidate, exportDirs, mainTrees) {
|
|
272
|
+
const inside = (/** @type {string} */ root, /** @type {string} */ p) => {
|
|
273
|
+
const rel = path.relative(root, p);
|
|
274
|
+
return rel.length > 0 && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel.split(path.sep).join('/') : null;
|
|
275
|
+
};
|
|
276
|
+
if (!path.isAbsolute(candidate)) return inside(exportDirs[0], path.join(exportDirs[0], candidate));
|
|
277
|
+
for (const root of [...exportDirs, ...mainTrees]) {
|
|
278
|
+
const rel = inside(root, candidate);
|
|
279
|
+
if (rel !== null) return rel;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Is a failed gate's output a missing-file error for a path the MAIN tree has and the export
|
|
286
|
+
* lacks? Then it is `export.incomplete`, not a red test.
|
|
287
|
+
* @param {{stdout?: string, stderr?: string, exportDir: string, mainTree: string}} opts
|
|
288
|
+
* @returns {Promise<string | null>} the repo-relative missing path, or null (a real red)
|
|
289
|
+
*/
|
|
290
|
+
export async function classifyExportFailure({ stdout = '', stderr = '', exportDir, mainTree }) {
|
|
291
|
+
const text = `${stderr}\n${stdout}`;
|
|
292
|
+
const exportDirs = [exportDir, await realpath(exportDir).catch(() => exportDir)];
|
|
293
|
+
const mainTrees = [mainTree, await realpath(mainTree).catch(() => mainTree)];
|
|
294
|
+
/** @type {string[]} */
|
|
295
|
+
const candidates = [];
|
|
296
|
+
for (const pattern of MISSING_FILE_PATTERNS) {
|
|
297
|
+
for (const m of text.matchAll(pattern)) candidates.push(m[1]);
|
|
298
|
+
}
|
|
299
|
+
/** @type {string[]} */
|
|
300
|
+
const relatives = candidates.map((c) => toRepoRelative(c, exportDirs, mainTrees)).filter((r) => r !== null);
|
|
301
|
+
for (const m of text.matchAll(CLASS_NOT_FOUND)) {
|
|
302
|
+
// PSR-4 guesses for an autoloaded class: `App\Models\X` ⇒ `App/Models/X.php`, `app/Models/X.php`.
|
|
303
|
+
const file = `${m[1].replace(/^\\/, '').split('\\').join('/')}.php`;
|
|
304
|
+
relatives.push(file, file.replace(/^[^/]+/, (s) => s.toLowerCase()));
|
|
305
|
+
}
|
|
306
|
+
for (const rel of relatives) {
|
|
307
|
+
if (rel.split('/').includes('..')) continue;
|
|
308
|
+
if ((await lstatOrNull(path.join(mainTree, rel))) !== null && (await lstatOrNull(path.join(exportDir, rel))) === null) return rel;
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** @param {string} missing @returns {string} */
|
|
314
|
+
export function incompleteMessage(missing) {
|
|
315
|
+
return `export.incomplete: ${missing} — add it to proof.export.copy_untracked or set proof.isolation: lock`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Run the block's gates (B5 `runGates`) inside the export; a red gate whose output is a
|
|
320
|
+
* missing-file error for a main-tree path is re-labelled `export.incomplete`.
|
|
321
|
+
* @param {{gates: Parameters<typeof runGates>[0], exportDir: string, mainTree: string, timeoutMs?: number}} opts
|
|
322
|
+
*/
|
|
323
|
+
export async function runGatesInExport({ gates, exportDir, mainTree, timeoutMs }) {
|
|
324
|
+
const { results } = await runGates(gates, { cwd: exportDir, ...(timeoutMs ? { timeoutMs } : {}) });
|
|
325
|
+
/** @type {{gate: string, path: string, message: string}[]} */
|
|
326
|
+
const incomplete = [];
|
|
327
|
+
const labelled = [];
|
|
328
|
+
for (const r of results) {
|
|
329
|
+
const missing = r.ok ? null : await classifyExportFailure({ stdout: r.stdout, stderr: r.stderr, exportDir, mainTree });
|
|
330
|
+
if (missing === null) {
|
|
331
|
+
labelled.push({ ...r, result: r.ok ? 'ok' : 'red' });
|
|
332
|
+
} else {
|
|
333
|
+
incomplete.push({ gate: r.gate, path: missing, message: incompleteMessage(missing) });
|
|
334
|
+
labelled.push({ ...r, result: 'export.incomplete', missing });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return { results: labelled, allOk: labelled.every((r) => r.ok), incomplete };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A ledger `proof` row (§6.1). `isolation` must be `export` or `lock`.
|
|
342
|
+
* @param {{block: string, isolation: string} & Record<string, any>} fields
|
|
343
|
+
* @returns {Record<string, any>}
|
|
344
|
+
*/
|
|
345
|
+
export function proofRow(fields) {
|
|
346
|
+
const { block, isolation, ...rest } = fields ?? /** @type {any} */ ({});
|
|
347
|
+
if (typeof block !== 'string' || !BLOCK_ID.test(block)) throw new StateError('bad-block-id', `proof row: block must match ${BLOCK_ID}`);
|
|
348
|
+
if (!ISOLATIONS.includes(/** @type {any} */ (isolation))) throw new StateError('bad-isolation', `proof row: isolation must be one of ${ISOLATIONS.join(', ')}`);
|
|
349
|
+
return { ...rest, event: 'proof', block, isolation };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Sign a `proof` row with the run key (B8) and hand it to the B6 writer (`writeRow`).
|
|
354
|
+
* @param {{runId: string, writeRow: import('../state/run.mjs').WriteRow, row: {block: string, isolation: string} & Record<string, any>}} opts
|
|
355
|
+
*/
|
|
356
|
+
export async function recordProof({ runId, writeRow, row }) {
|
|
357
|
+
return writeSigned(runId, writeRow, proofRow(row));
|
|
358
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proof lock (plan §4.7 `proof.isolation: lock`, §7.1 `proof.busy`). For stacks whose tests
|
|
3
|
+
* cannot run from an export, one block at a time measures in the SHARED tree: `proof lock <id>`
|
|
4
|
+
* takes a run-wide lock that pauses every other block's gate run, `proof unlock <id>` releases it.
|
|
5
|
+
*
|
|
6
|
+
* The lock lives in the authoritative run record (`record.proof_lock = {block, acquired_at}`),
|
|
7
|
+
* read and written only under B8's run lock (`withRunLock`), so taking it and starting a gate are
|
|
8
|
+
* atomic with respect to each other. While a gate runs, its block carries `gate_running: true`
|
|
9
|
+
* (+ `gate_pid`) in the registry (§4.7's registry shape):
|
|
10
|
+
*
|
|
11
|
+
* - a gate of block B while A holds the lock ⇒ `{ok: false, result: 'proof.busy'}` (not run);
|
|
12
|
+
* - `proof lock` for A while another block's gate is running (its pid alive) ⇒ `proof.busy`.
|
|
13
|
+
*
|
|
14
|
+
* Solo's `lock_acquire` is an MCP tool the orchestrator may use on top of this; the CLI cannot
|
|
15
|
+
* call it, so the run record IS the lock file here. Every lock/unlock writes a signed `proof` row
|
|
16
|
+
* with `isolation: lock` through the B6 writer.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { runGates } from '../gates/run.mjs';
|
|
20
|
+
import { StateError } from '../state/paths.mjs';
|
|
21
|
+
import { readRun, saveRun, withRunLock } from '../state/run.mjs';
|
|
22
|
+
import { isAlive } from '../util/reaper.mjs';
|
|
23
|
+
import { recordProof } from './export.mjs';
|
|
24
|
+
|
|
25
|
+
/** @typedef {import('../state/run.mjs').WriteRow} WriteRow */
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {Record<string, any>} record @param {string} blockId
|
|
29
|
+
* @returns {Record<string, any>} the open block
|
|
30
|
+
*/
|
|
31
|
+
function openBlockOf(record, blockId) {
|
|
32
|
+
if (record.status !== 'active') throw new StateError('run-ended', `run ${record.run_id} has ended`);
|
|
33
|
+
const block = record.blocks?.[blockId];
|
|
34
|
+
if (block?.status !== 'open') throw new StateError('no-block', `block ${blockId} is not open in run ${record.run_id}`);
|
|
35
|
+
return block;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {Record<string, any>} block @returns {boolean} a gate is running for it right now */
|
|
39
|
+
function gateLive(block) {
|
|
40
|
+
if (block.gate_running !== true) return false;
|
|
41
|
+
return !Number.isInteger(block.gate_pid) || isAlive(block.gate_pid);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The block holding the lock, or null.
|
|
46
|
+
* @param {string} runId
|
|
47
|
+
* @returns {Promise<string | null>}
|
|
48
|
+
*/
|
|
49
|
+
export async function lockHolder(runId) {
|
|
50
|
+
const record = await readRun(runId);
|
|
51
|
+
return record.proof_lock?.block ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `proof lock <id>`. Idempotent for the holder.
|
|
56
|
+
* @param {{runId: string, blockId: string, writeRow: WriteRow, now?: Date}} opts
|
|
57
|
+
* @returns {Promise<{block: string, acquired_at: string}>}
|
|
58
|
+
* @throws {StateError} `proof.busy` — another block holds the lock or has a gate running.
|
|
59
|
+
*/
|
|
60
|
+
export async function acquireProofLock({ runId, blockId, writeRow, now = new Date() }) {
|
|
61
|
+
return withRunLock(runId, async () => {
|
|
62
|
+
const record = await readRun(runId);
|
|
63
|
+
openBlockOf(record, blockId);
|
|
64
|
+
const held = record.proof_lock;
|
|
65
|
+
if (held && held.block === blockId) return held;
|
|
66
|
+
if (held) throw new StateError('proof.busy', `proof.busy: block ${held.block} holds the proof lock`);
|
|
67
|
+
const running = Object.entries(record.blocks)
|
|
68
|
+
.filter(([id, b]) => id !== blockId && b.status === 'open' && gateLive(b))
|
|
69
|
+
.map(([id]) => id);
|
|
70
|
+
if (running.length > 0) throw new StateError('proof.busy', `proof.busy: gate running for block ${running.join(', ')}`);
|
|
71
|
+
const lock = { block: blockId, acquired_at: now.toISOString() };
|
|
72
|
+
await recordProof({ runId, writeRow, row: { block: blockId, isolation: 'lock', step: 'lock' } });
|
|
73
|
+
record.proof_lock = lock;
|
|
74
|
+
await saveRun(record);
|
|
75
|
+
return lock;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `proof unlock <id>` — only the holder releases.
|
|
81
|
+
* @param {{runId: string, blockId: string, writeRow: WriteRow}} opts
|
|
82
|
+
* @throws {StateError} `not-locked`, `not-holder`
|
|
83
|
+
*/
|
|
84
|
+
export async function releaseProofLock({ runId, blockId, writeRow }) {
|
|
85
|
+
return withRunLock(runId, async () => {
|
|
86
|
+
const record = await readRun(runId);
|
|
87
|
+
const held = record.proof_lock;
|
|
88
|
+
if (!held) throw new StateError('not-locked', `run ${runId} has no proof lock`);
|
|
89
|
+
if (held.block !== blockId) throw new StateError('not-holder', `block ${blockId} does not hold the proof lock (${held.block} does)`);
|
|
90
|
+
await recordProof({ runId, writeRow, row: { block: blockId, isolation: 'lock', step: 'unlock' } });
|
|
91
|
+
delete record.proof_lock;
|
|
92
|
+
await saveRun(record);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Run `fn` as block `blockId`'s gate: refused with `proof.busy` while ANOTHER block holds the
|
|
98
|
+
* proof lock; otherwise `gate_running` is set for the duration and cleared afterwards (also on a
|
|
99
|
+
* throw).
|
|
100
|
+
* @template T
|
|
101
|
+
* @param {{runId: string, blockId: string, fn: () => Promise<T>, pid?: number}} opts
|
|
102
|
+
* @returns {Promise<{ok: true, result: 'ran', value: T} | {ok: false, result: 'proof.busy', holder: string}>}
|
|
103
|
+
*/
|
|
104
|
+
export async function withGateSlot({ runId, blockId, fn, pid = process.pid }) {
|
|
105
|
+
const holder = await withRunLock(runId, async () => {
|
|
106
|
+
const record = await readRun(runId);
|
|
107
|
+
const block = openBlockOf(record, blockId);
|
|
108
|
+
const held = record.proof_lock?.block;
|
|
109
|
+
if (held && held !== blockId) return held;
|
|
110
|
+
block.gate_running = true;
|
|
111
|
+
block.gate_pid = pid;
|
|
112
|
+
await saveRun(record);
|
|
113
|
+
return null;
|
|
114
|
+
});
|
|
115
|
+
if (holder !== null) return { ok: false, result: 'proof.busy', holder };
|
|
116
|
+
try {
|
|
117
|
+
return { ok: true, result: 'ran', value: await fn() };
|
|
118
|
+
} finally {
|
|
119
|
+
await withRunLock(runId, async () => {
|
|
120
|
+
const record = await readRun(runId);
|
|
121
|
+
const block = record.blocks?.[blockId];
|
|
122
|
+
if (block) {
|
|
123
|
+
delete block.gate_running;
|
|
124
|
+
delete block.gate_pid;
|
|
125
|
+
await saveRun(record);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
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
|
|
135
|
+
*/
|
|
136
|
+
export async function runGatesGuarded({ runId, blockId, gates, cwd, timeoutMs }) {
|
|
137
|
+
return withGateSlot({ runId, blockId, fn: () => runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) }) });
|
|
138
|
+
}
|