@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,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block state verbs (plan §4.9): `open`, `attempt`, `rebase`, `claim`, `scope`, `close`, `stop`,
|
|
3
|
+
* and the brief pointer. Every mutation runs under the run lock and writes its SIGNED ledger row
|
|
4
|
+
* through the `writeRow` parameter BEFORE saving the registry in the authoritative run record
|
|
5
|
+
* (a failed ledger write leaves the registry unchanged). `closeBlock` reads the pin and the rows
|
|
6
|
+
* outside the lock and re-checks status and orphans inside it before closing.
|
|
7
|
+
*
|
|
8
|
+
* `closeBlock` is B8's share of the block gate (§4.6): the worker pin (row 9/§4.8), the MAC of
|
|
9
|
+
* every gate-relevant row (row 10), and the orphan rule (row 1). The other rows (reviews, gates,
|
|
10
|
+
* acceptance, proof, forecast) arrive as `extraChecks` from the blocks that own them (B5, B10,
|
|
11
|
+
* B12) — this module never imports them (C17).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import { exec } from '../util/exec.mjs';
|
|
17
|
+
import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
|
|
18
|
+
import { StateError } from './paths.mjs';
|
|
19
|
+
import { assertOwned, findOverlap, scopeGate } from './registry.mjs';
|
|
20
|
+
import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
|
|
21
|
+
import { verifyRow, loadKey } from './signer.mjs';
|
|
22
|
+
|
|
23
|
+
/** @typedef {import('./run.mjs').WriteRow} WriteRow */
|
|
24
|
+
|
|
25
|
+
export const BRIEF_POINTER_MAX_BYTES = 200;
|
|
26
|
+
|
|
27
|
+
/** Ledger events the block gate requires to carry a valid MAC (§4.6 row 10). */
|
|
28
|
+
export const GATE_EVENTS = Object.freeze(['dispatch', 'block.attempt', 'block.rebase', 'block.claim', 'review.approved', 'proof']);
|
|
29
|
+
|
|
30
|
+
const BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
|
|
31
|
+
const LEVEL = /^L[0-3]$/;
|
|
32
|
+
|
|
33
|
+
/** `process.env` minus every inherited `GIT_*` (hooks export GIT_DIR…), with no system/global config. */
|
|
34
|
+
function gitEnv() {
|
|
35
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
36
|
+
const env = { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull };
|
|
37
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
38
|
+
if (!key.startsWith('GIT_')) env[key] = value;
|
|
39
|
+
}
|
|
40
|
+
return env;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {string[]} argv @param {string} cwd @param {number[]} [okExitCodes] */
|
|
44
|
+
async function git(argv, cwd, okExitCodes) {
|
|
45
|
+
const res = await exec(argv, { cwd, env: gitEnv(), okExitCodes, timeoutMs: 30000 });
|
|
46
|
+
if (res.result !== 'ok') throw new StateError('git-failed', `${argv.slice(0, 2).join(' ')} failed (exit ${res.code})`);
|
|
47
|
+
return res;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {string} ref @param {string} cwd @returns {Promise<string>} the full commit sha */
|
|
51
|
+
async function resolveCommit(ref, cwd) {
|
|
52
|
+
if (typeof ref !== 'string' || ref.length === 0 || ref.startsWith('-')) {
|
|
53
|
+
throw new StateError('bad-ref', `a git ref must be non-empty and not start with "-" (got ${JSON.stringify(ref)})`);
|
|
54
|
+
}
|
|
55
|
+
const res = await git(revParseArgv(`${ref}^{commit}`), cwd);
|
|
56
|
+
return res.stdout.trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>` — `sha8` is the first 8 hex of the brief's
|
|
61
|
+
* SHA-256, `lines` counts `\n` (plus a final unterminated line). The coder answers
|
|
62
|
+
* `ACK <sha8> lines=<n>` (§5.3, parsed by B4).
|
|
63
|
+
* @param {string} relPath @param {Buffer | string} content
|
|
64
|
+
* @returns {string}
|
|
65
|
+
* @throws {StateError} `pointer-too-long` over 200 bytes, `bad-brief-path` on whitespace.
|
|
66
|
+
*/
|
|
67
|
+
export function briefPointer(relPath, content) {
|
|
68
|
+
if (typeof relPath !== 'string' || relPath.length === 0 || /\s/.test(relPath)) {
|
|
69
|
+
throw new StateError('bad-brief-path', 'brief path must be non-empty and contain no whitespace');
|
|
70
|
+
}
|
|
71
|
+
const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
|
|
72
|
+
const sha8 = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
|
|
73
|
+
let lines = 0;
|
|
74
|
+
for (const byte of bytes) if (byte === 0x0a) lines += 1;
|
|
75
|
+
if (bytes.length > 0 && bytes[bytes.length - 1] !== 0x0a) lines += 1;
|
|
76
|
+
const pointer = `BRIEF ${relPath} lines=${lines} sha=${sha8} <<<EOM>>>`;
|
|
77
|
+
const size = Buffer.byteLength(pointer, 'utf8');
|
|
78
|
+
if (size > BRIEF_POINTER_MAX_BYTES) {
|
|
79
|
+
throw new StateError('pointer-too-long', `brief pointer is ${size} bytes (max ${BRIEF_POINTER_MAX_BYTES}); use a shorter brief path`);
|
|
80
|
+
}
|
|
81
|
+
return pointer;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {unknown} clauses
|
|
86
|
+
* @returns {{clause: string, tests: string[]}[]}
|
|
87
|
+
*/
|
|
88
|
+
function assertAcceptance(clauses) {
|
|
89
|
+
const ok =
|
|
90
|
+
Array.isArray(clauses) &&
|
|
91
|
+
clauses.length > 0 &&
|
|
92
|
+
clauses.every(
|
|
93
|
+
(c) => typeof c?.clause === 'string' && c.clause.length > 0 && Array.isArray(c.tests) && c.tests.length > 0 && c.tests.every((t) => typeof t === 'string' && t.length > 0),
|
|
94
|
+
);
|
|
95
|
+
if (!ok) throw new StateError('bad-acceptance', 'acceptance: a non-empty list of {clause, tests: [test ids…]} is required');
|
|
96
|
+
return clauses;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Under the run lock: load the run, check it is active and (when `id` is given) that the block
|
|
101
|
+
* is open, run `mutate`, write its signed `rows` through the ledger writer, THEN save the record —
|
|
102
|
+
* a ledger write that fails leaves the registry unchanged.
|
|
103
|
+
* @template T
|
|
104
|
+
* @param {string} runId @param {string | null} id @param {WriteRow} writeRow
|
|
105
|
+
* @param {(record: Record<string, any>, block: Record<string, any> | undefined) => Promise<{result: T, rows?: Record<string, any>[]}> | {result: T, rows?: Record<string, any>[]}} mutate
|
|
106
|
+
* @returns {Promise<T>}
|
|
107
|
+
*/
|
|
108
|
+
async function mutateRun(runId, id, writeRow, mutate) {
|
|
109
|
+
return withRunLock(runId, async () => {
|
|
110
|
+
const record = await readRun(runId);
|
|
111
|
+
if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
112
|
+
const block = id === null ? undefined : record.blocks[id];
|
|
113
|
+
if (id !== null && block?.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
114
|
+
const { result, rows = [] } = await mutate(record, block);
|
|
115
|
+
for (const row of rows) await writeSigned(runId, writeRow, row);
|
|
116
|
+
await saveRun(record);
|
|
117
|
+
return result;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @param {Record<string, any>} record @returns {Record<string, any>} the open blocks only */
|
|
122
|
+
function activeBlocks(record) {
|
|
123
|
+
return Object.fromEntries(Object.entries(record.blocks).filter(([, b]) => b.status === 'open'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `block open` — refuses a second open of the same id and any owned set that overlaps another
|
|
128
|
+
* ACTIVE block's; records base, owned files, level, attempt, acceptance, line forecast.
|
|
129
|
+
* @param {{
|
|
130
|
+
* runId: string, id: string, level: string, owned: string[], acceptance: unknown,
|
|
131
|
+
* attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
|
|
132
|
+
* writeRow: WriteRow, now?: Date,
|
|
133
|
+
* }} opts
|
|
134
|
+
* @returns {Promise<{block: Record<string, any>, pointer: string | null}>}
|
|
135
|
+
*/
|
|
136
|
+
export async function openBlock(opts) {
|
|
137
|
+
const { runId, id, level, attempt = 1, lines = null, writeRow, now = new Date() } = opts;
|
|
138
|
+
if (typeof id !== 'string' || !BLOCK_ID.test(id)) throw new StateError('bad-block-id', `block id must match ${BLOCK_ID}`);
|
|
139
|
+
if (typeof level !== 'string' || !LEVEL.test(level)) throw new StateError('bad-level', 'level must be L0..L3');
|
|
140
|
+
if (!Number.isInteger(attempt) || attempt < 1) throw new StateError('bad-attempt', 'attempt must be an integer >= 1');
|
|
141
|
+
if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
|
|
142
|
+
const owned = assertOwned(opts.owned);
|
|
143
|
+
const acceptance = assertAcceptance(opts.acceptance);
|
|
144
|
+
const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
|
|
145
|
+
|
|
146
|
+
const block = await mutateRun(runId, null, writeRow, async (record) => {
|
|
147
|
+
if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
|
|
148
|
+
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
149
|
+
const pair = findOverlap(owned, other.owned_files);
|
|
150
|
+
if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
|
|
151
|
+
}
|
|
152
|
+
const baseSha = await resolveCommit(opts.base ?? 'HEAD', record.workspace);
|
|
153
|
+
const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open' };
|
|
154
|
+
record.blocks[id] = entry;
|
|
155
|
+
return {
|
|
156
|
+
result: entry,
|
|
157
|
+
rows: [{ event: 'dispatch', block: id, level, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines }],
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
return { block, pointer };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** @param {{runId: string, id: string, writeRow: WriteRow}} opts */
|
|
164
|
+
export async function attemptBlock({ runId, id, writeRow }) {
|
|
165
|
+
return mutateRun(runId, id, writeRow, (_record, block) => {
|
|
166
|
+
block.attempt += 1;
|
|
167
|
+
return { result: block.attempt, rows: [{ event: 'block.attempt', block: id, attempt: block.attempt }] };
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `block rebase` — move the base to `head` only when the old base is its ancestor and no commit
|
|
173
|
+
* in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
|
|
174
|
+
* (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
|
|
175
|
+
* is a conflict too. Never silent.
|
|
176
|
+
* @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
|
|
177
|
+
* @returns {Promise<{old_base: string, new_base: string}>}
|
|
178
|
+
*/
|
|
179
|
+
export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
180
|
+
return mutateRun(runId, id, writeRow, async (record, block) => {
|
|
181
|
+
const cwd = record.workspace;
|
|
182
|
+
const newBase = await resolveCommit(head, cwd);
|
|
183
|
+
const ancestor = await git(isAncestorArgv(block.base_sha, newBase), cwd, [0, 1]);
|
|
184
|
+
if (ancestor.code !== 0) throw new StateError('base-not-ancestor', `block ${id}: base ${block.base_sha.slice(0, 12)} is not an ancestor of ${head}`);
|
|
185
|
+
const diff = await git(['git', 'diff', '--no-renames', '--name-only', block.base_sha, newBase], cwd);
|
|
186
|
+
const conflicts = diff.stdout.split('\n').filter((file) => file.length > 0 && findOverlap(block.owned_files, [file]) !== null);
|
|
187
|
+
if (conflicts.length > 0) throw new StateError('owned-conflict', `block ${id}: commits since its base changed owned files: ${conflicts.join(', ')}`);
|
|
188
|
+
const moved = { old_base: block.base_sha, new_base: newBase };
|
|
189
|
+
block.base_sha = newBase;
|
|
190
|
+
return { result: moved, rows: [{ event: 'block.rebase', block: id, ...moved }] };
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
|
|
196
|
+
* glob, and when another open block owns it.
|
|
197
|
+
* @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
|
|
198
|
+
*/
|
|
199
|
+
export async function claimPath({ runId, id, file, writeRow }) {
|
|
200
|
+
assertOwned([file]);
|
|
201
|
+
if (/[*?{]/.test(file)) throw new StateError('bad-owned', `block claim takes one exact path, not a glob (${JSON.stringify(file)})`);
|
|
202
|
+
await mutateRun(runId, id, writeRow, (record, block) => {
|
|
203
|
+
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
204
|
+
if (otherId !== id && findOverlap([file], other.owned_files)) throw new StateError('overlap', `${file} is owned by open block ${otherId}`);
|
|
205
|
+
}
|
|
206
|
+
if (!block.owned_files.includes(file)) block.owned_files.push(file);
|
|
207
|
+
record.orphans = record.orphans.filter((/** @type {string} */ o) => o !== file);
|
|
208
|
+
return { result: undefined, rows: [{ event: 'block.claim', block: id, path: file }] };
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The scope row for `id` against the tree's file set; NEW orphans are written to the registry
|
|
214
|
+
* and the ledger (`block.orphan`), so every active gate sees them.
|
|
215
|
+
* @param {{runId: string, id: string, files: string[], writeRow: WriteRow}} opts
|
|
216
|
+
* @returns {Promise<{ok: boolean, scope: string[], orphans: string[]}>}
|
|
217
|
+
*/
|
|
218
|
+
export async function checkScope({ runId, id, files, writeRow }) {
|
|
219
|
+
return mutateRun(runId, id, writeRow, (record) => {
|
|
220
|
+
const gate = scopeGate(activeBlocks(record), id, files);
|
|
221
|
+
const fresh = gate.orphans.filter((o) => !record.orphans.includes(o));
|
|
222
|
+
record.orphans = [...record.orphans, ...fresh];
|
|
223
|
+
return { result: gate, rows: fresh.map((orphan) => ({ event: 'block.orphan', path: orphan })) };
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** @param {{runId: string, id: string, reason: string, writeRow: WriteRow, event?: string}} opts */
|
|
228
|
+
export async function stopBlock({ runId, id, reason, writeRow, event = 'block.stop' }) {
|
|
229
|
+
await mutateRun(runId, id, writeRow, (_record, block) => {
|
|
230
|
+
block.status = 'stopped';
|
|
231
|
+
block.stop_reason = reason;
|
|
232
|
+
return { result: undefined, rows: [{ event, block: id, reason }] };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* B8's rows of the block gate, in order: worker pin ⇒ `worker.replaced`/`worker.down` (block
|
|
238
|
+
* stopped); the block's signed `dispatch` row must exist and verify, and every other
|
|
239
|
+
* gate-relevant row of the block must verify ⇒ else `ledger.tamper` (block stopped); then
|
|
240
|
+
* `extraChecks`; finally, UNDER the run lock, orphans in the registry ⇒ refused (block stays
|
|
241
|
+
* open), else closed.
|
|
242
|
+
* @param {{
|
|
243
|
+
* runId: string, id: string, rows: Record<string, any>[], writeRow: WriteRow,
|
|
244
|
+
* livePid?: number, probe?: import('./run.mjs').StartTimeProbe,
|
|
245
|
+
* extraChecks?: Array<() => Promise<{ok: boolean, reason?: string}>>,
|
|
246
|
+
* }} opts
|
|
247
|
+
* @returns {Promise<{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}>}
|
|
248
|
+
*/
|
|
249
|
+
export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [] }) {
|
|
250
|
+
const record = await readRun(runId);
|
|
251
|
+
if (record.blocks[id]?.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
252
|
+
|
|
253
|
+
const pin = await checkWorkerPin(record, { livePid, probe });
|
|
254
|
+
if (!pin.ok) {
|
|
255
|
+
await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
|
|
256
|
+
return { ok: false, status: 'stopped', event: pin.event, reason: pin.reason };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const key = await loadKey(runId);
|
|
260
|
+
const relevant = rows.filter((row) => row.block === id && GATE_EVENTS.includes(row.event) && (row.run === undefined || row.run === runId));
|
|
261
|
+
const checked = relevant.map((row) => ({ row, result: verifyRow(row, key) }));
|
|
262
|
+
const bad = checked.filter((x) => !x.result.ok);
|
|
263
|
+
const dispatchOk = checked.some((x) => x.row.event === 'dispatch' && x.result.ok);
|
|
264
|
+
if (bad.length > 0 || !dispatchOk) {
|
|
265
|
+
const reasons = bad.map((x) => `${x.row.event}: ${x.result.reason}`);
|
|
266
|
+
if (!dispatchOk) reasons.push('no verified dispatch row');
|
|
267
|
+
const reason = reasons.join('; ');
|
|
268
|
+
await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
|
|
269
|
+
return { ok: false, status: 'stopped', event: 'ledger.tamper', reason };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const check of extraChecks) {
|
|
273
|
+
const result = await check();
|
|
274
|
+
if (!result.ok) return { ok: false, status: 'open', reason: result.reason ?? 'check failed' };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** @typedef {{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
|
|
278
|
+
return mutateRun(runId, id, writeRow, (current, block) => {
|
|
279
|
+
if (current.orphans.length > 0) {
|
|
280
|
+
/** @type {CloseResult} */
|
|
281
|
+
const refused = { ok: false, status: 'open', reason: `orphans: ${current.orphans.join(', ')}` };
|
|
282
|
+
return { result: refused, rows: [] };
|
|
283
|
+
}
|
|
284
|
+
block.status = 'closed';
|
|
285
|
+
/** @type {CloseResult} */
|
|
286
|
+
const closed = { ok: true, status: 'closed' };
|
|
287
|
+
return { result: closed, rows: [{ event: 'block.close', block: id, status: 'complete' }] };
|
|
288
|
+
});
|
|
289
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flag parsing shared by the `run` and `block` verbs. Strict: every flag must be declared in the
|
|
3
|
+
* spec — `values` (`--flag value` / `--flag=value`, once), `booleans` (no value), or `multi`
|
|
4
|
+
* (every following token up to the next `--flag`, seeded by an inline `--flag=value`). An unknown
|
|
5
|
+
* flag, a bare `--`, a repeated single-value flag, or a value on a boolean is a usage error, so a
|
|
6
|
+
* typo never swallows the next token silently. Positionals are what is left.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { StateError } from './paths.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string[]} args
|
|
13
|
+
* @param {{values?: string[], booleans?: string[], multi?: string[]}} [spec]
|
|
14
|
+
* @returns {{flags: Record<string, string | string[] | true>, positionals: string[]}}
|
|
15
|
+
* @throws {StateError} `usage`
|
|
16
|
+
*/
|
|
17
|
+
export function parseFlags(args, { values = [], booleans = [], multi = [] } = {}) {
|
|
18
|
+
/** @type {Record<string, string | string[] | true>} */
|
|
19
|
+
const flags = {};
|
|
20
|
+
const positionals = [];
|
|
21
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
22
|
+
const arg = args[i];
|
|
23
|
+
if (!arg.startsWith('--')) {
|
|
24
|
+
positionals.push(arg);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const eq = arg.indexOf('=');
|
|
28
|
+
const name = arg.slice(2, eq < 0 ? undefined : eq);
|
|
29
|
+
const inline = eq < 0 ? undefined : arg.slice(eq + 1);
|
|
30
|
+
if (!booleans.includes(name) && !multi.includes(name) && !values.includes(name)) {
|
|
31
|
+
throw new StateError('usage', `unknown flag ${JSON.stringify(arg)}`);
|
|
32
|
+
}
|
|
33
|
+
if (Object.hasOwn(flags, name) && !multi.includes(name)) throw new StateError('usage', `--${name} given more than once`);
|
|
34
|
+
if (booleans.includes(name)) {
|
|
35
|
+
if (inline !== undefined) throw new StateError('usage', `--${name} takes no value`);
|
|
36
|
+
flags[name] = true;
|
|
37
|
+
} else if (multi.includes(name)) {
|
|
38
|
+
const collected = Array.isArray(flags[name]) ? /** @type {string[]} */ (flags[name]) : [];
|
|
39
|
+
if (inline !== undefined && inline.length > 0) collected.push(inline);
|
|
40
|
+
while (i + 1 < args.length && !args[i + 1].startsWith('--')) collected.push(args[(i += 1)]);
|
|
41
|
+
if (collected.length === 0) throw new StateError('usage', `--${name} needs at least one value`);
|
|
42
|
+
flags[name] = collected;
|
|
43
|
+
} else if (inline !== undefined) {
|
|
44
|
+
if (inline.length === 0) throw new StateError('usage', `--${name} needs a value`);
|
|
45
|
+
flags[name] = inline;
|
|
46
|
+
} else {
|
|
47
|
+
const next = args[i + 1];
|
|
48
|
+
if (next === undefined || next.startsWith('--')) throw new StateError('usage', `--${name} needs a value`);
|
|
49
|
+
flags[name] = next;
|
|
50
|
+
i += 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { flags, positionals };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A strictly positive safe integer written without sign or leading zeros.
|
|
58
|
+
* @param {string | string[] | true | undefined} raw @param {string} name
|
|
59
|
+
* @returns {number | undefined}
|
|
60
|
+
* @throws {StateError} `usage`
|
|
61
|
+
*/
|
|
62
|
+
export function intFlag(raw, name) {
|
|
63
|
+
if (raw === undefined) return undefined;
|
|
64
|
+
const value = typeof raw === 'string' && /^[1-9]\d*$/.test(raw) ? Number(raw) : Number.NaN;
|
|
65
|
+
if (!Number.isSafeInteger(value)) throw new StateError('usage', `--${name} must be a positive integer`);
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where run state lives (plan §1.2, §4.9, §8.6) and the one error type the state verbs throw.
|
|
3
|
+
*
|
|
4
|
+
* - `~/.code-forge/runs/<run>.json` — the AUTHORITATIVE run record (registry + worker pin),
|
|
5
|
+
* outside every workspace; `~/.code-forge/runs/<run>.key` — the per-run HMAC key, mode 0600.
|
|
6
|
+
* - `<workspace>/.code-forge/runs/<run>.json` — an informational mirror, minus the worker pin.
|
|
7
|
+
*
|
|
8
|
+
* `os.homedir()` re-reads `$HOME` on every call (POSIX), so a test that points `HOME` at a temp
|
|
9
|
+
* dir never touches a real `~/.code-forge/`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomBytes } from 'node:crypto';
|
|
13
|
+
import { realpathSync } from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
/** A refusal with a stable machine-readable `code` (the CLI prints the message, exits 1). */
|
|
18
|
+
export class StateError extends Error {
|
|
19
|
+
/** @param {string} code @param {string} message */
|
|
20
|
+
constructor(code, message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'StateError';
|
|
23
|
+
this.code = code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const RUN_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {unknown} runId
|
|
31
|
+
* @returns {string}
|
|
32
|
+
* @throws {StateError} `bad-run-id` — a run id must never resolve outside the runs directory.
|
|
33
|
+
*/
|
|
34
|
+
export function assertRunId(runId) {
|
|
35
|
+
if (typeof runId !== 'string' || !RUN_ID_PATTERN.test(runId)) {
|
|
36
|
+
throw new StateError('bad-run-id', `run id must match ${RUN_ID_PATTERN}`);
|
|
37
|
+
}
|
|
38
|
+
return runId;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {Date} [now] @returns {string} e.g. `r-20260924-201500-3fa9c1` */
|
|
42
|
+
export function newRunId(now = new Date()) {
|
|
43
|
+
const stamp = now.toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
|
|
44
|
+
return `r-${stamp}-${randomBytes(3).toString('hex')}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @returns {string} */
|
|
48
|
+
export function runsDir() {
|
|
49
|
+
return path.join(os.homedir(), '.code-forge', 'runs');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** @param {string} runId @returns {string} */
|
|
53
|
+
export function runRecordPath(runId) {
|
|
54
|
+
return path.join(runsDir(), `${assertRunId(runId)}.json`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {string} runId @returns {string} */
|
|
58
|
+
export function runKeyPath(runId) {
|
|
59
|
+
return path.join(runsDir(), `${assertRunId(runId)}.key`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {string} p @returns {string} the real path when it exists, else the resolved one */
|
|
63
|
+
function canonical(p) {
|
|
64
|
+
try {
|
|
65
|
+
return realpathSync(p);
|
|
66
|
+
} catch {
|
|
67
|
+
return path.resolve(p);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The run record must live OUTSIDE the workspace (C4). A workspace that is `$HOME` or one of its
|
|
73
|
+
* ancestors would hold `~/.code-forge/runs/` inside it — and with workspace === `$HOME` the
|
|
74
|
+
* mirror would overwrite the authoritative record (dropping the worker pin).
|
|
75
|
+
* @param {string} workspace
|
|
76
|
+
* @returns {string} the canonical workspace path
|
|
77
|
+
* @throws {StateError} `workspace-contains-runs-dir`
|
|
78
|
+
*/
|
|
79
|
+
export function assertWorkspaceOutsideRuns(workspace) {
|
|
80
|
+
const ws = canonical(workspace);
|
|
81
|
+
const rel = path.relative(ws, path.join(canonical(os.homedir()), '.code-forge', 'runs'));
|
|
82
|
+
if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
|
|
83
|
+
throw new StateError('workspace-contains-runs-dir', `the workspace ${ws} contains ${runsDir()} — run code-forge from a project directory, not $HOME or above`);
|
|
84
|
+
}
|
|
85
|
+
return ws;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {string} workspace @param {string} runId @returns {string} */
|
|
89
|
+
export function mirrorPath(workspace, runId) {
|
|
90
|
+
assertWorkspaceOutsideRuns(workspace);
|
|
91
|
+
return path.join(workspace, '.code-forge', 'runs', `${assertRunId(runId)}.json`);
|
|
92
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-tree scope maths for parallel blocks in one working tree (plan §4.7, O2). Pure — the
|
|
3
|
+
* run record (`run.mjs`) stores the registry, the caller supplies the file set (B5 computes it:
|
|
4
|
+
* tracked changes ∪ untracked).
|
|
5
|
+
*
|
|
6
|
+
* Owned entries are exact paths or globs in a deliberately SMALL syntax, repo-relative, POSIX:
|
|
7
|
+
* `*` (any run of characters inside one segment), `?` (one character inside a segment), `**` (a
|
|
8
|
+
* whole segment matching zero or more segments) and `{a,b}` (expanded before anything else).
|
|
9
|
+
* Inside a GLOB, character classes and extglobs (`[` `]` `(` `)` `!` `+` `@`) are REFUSED by
|
|
10
|
+
* `assertOwned`, so no two readers can disagree on what an entry covers; in an EXACT path
|
|
11
|
+
* (no `*`, `?`, `{`) they are ordinary characters (`pages/[id].vue`) matched literally. The matcher is this module's own
|
|
12
|
+
* (`globMatch`), not `path.matchesGlob`, which Node still marks experimental. Dot-files are
|
|
13
|
+
* matched like any other name (fail closed for overlap).
|
|
14
|
+
*
|
|
15
|
+
* Overlap between two GLOBS cannot be decided in general, so it FAILS CLOSED: two globs are
|
|
16
|
+
* treated as overlapping when one's static directory prefix (the segments before the first glob
|
|
17
|
+
* character) is a segment-prefix of the other's — `test/fixtures/repos/two-blocks/**` and
|
|
18
|
+
* `test/fixtures/repos/node/**` are disjoint, `src/**` and `src/cli/*.mjs` overlap, and so do
|
|
19
|
+
* `src/*.mjs` and `src/cli/*.mjs` (they cannot share a path, but overlap is not ruled out by
|
|
20
|
+
* the prefix rule — the owner resolves it by naming exact paths).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { StateError } from './paths.mjs';
|
|
24
|
+
|
|
25
|
+
const GLOB_CHARS = /[*?]/;
|
|
26
|
+
/** Glob syntax this matcher does NOT implement — refused inside a glob entry only. */
|
|
27
|
+
const UNSUPPORTED_GLOB_CHARS = /[[\]()!+@]/;
|
|
28
|
+
|
|
29
|
+
/** @param {string} segment @returns {RegExp} */
|
|
30
|
+
function segmentRegExp(segment) {
|
|
31
|
+
const body = segment
|
|
32
|
+
.split('')
|
|
33
|
+
.map((c) => (c === '*' ? '[^/]*' : c === '?' ? '[^/]' : c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
|
34
|
+
.join('');
|
|
35
|
+
return new RegExp(`^${body}$`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Does repo-relative `file` match brace-free `pattern` (`*`, `?`, `**` — see the module doc)?
|
|
40
|
+
* @param {string} file @param {string} pattern
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function globMatch(file, pattern) {
|
|
44
|
+
const f = file.split('/');
|
|
45
|
+
const p = pattern.split('/');
|
|
46
|
+
/** @type {(i: number, j: number) => boolean} */
|
|
47
|
+
const match = (i, j) => {
|
|
48
|
+
if (j === p.length) return i === f.length;
|
|
49
|
+
if (p[j] === '**') {
|
|
50
|
+
for (let k = i; k <= f.length; k += 1) if (match(k, j + 1)) return true;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return i < f.length && segmentRegExp(p[j]).test(f[i]) && match(i + 1, j + 1);
|
|
54
|
+
};
|
|
55
|
+
return match(0, 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} pattern
|
|
60
|
+
* @returns {string[]} every alternative of every `{a,b}` group (nested groups included).
|
|
61
|
+
*/
|
|
62
|
+
export function expandBraces(pattern) {
|
|
63
|
+
const open = pattern.indexOf('{');
|
|
64
|
+
if (open < 0) return [pattern];
|
|
65
|
+
let depth = 0;
|
|
66
|
+
let close = -1;
|
|
67
|
+
const commas = [];
|
|
68
|
+
for (let i = open; i < pattern.length; i += 1) {
|
|
69
|
+
if (pattern[i] === '{') depth += 1;
|
|
70
|
+
else if (pattern[i] === '}' && --depth === 0) {
|
|
71
|
+
close = i;
|
|
72
|
+
break;
|
|
73
|
+
} else if (pattern[i] === ',' && depth === 1) commas.push(i);
|
|
74
|
+
}
|
|
75
|
+
if (close < 0) return [pattern];
|
|
76
|
+
const bounds = [open, ...commas, close];
|
|
77
|
+
const head = pattern.slice(0, open);
|
|
78
|
+
const tail = pattern.slice(close + 1);
|
|
79
|
+
return bounds.slice(0, -1).flatMap((start, i) => expandBraces(head + pattern.slice(start + 1, bounds[i + 1]) + tail));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {unknown} owned
|
|
84
|
+
* @returns {string[]}
|
|
85
|
+
* An EXACT entry (no `*`, `?` or `{`) may contain `[ ] ( ) ! + @ }` — real routes such as
|
|
86
|
+
* `pages/[id].vue`, `app/(group)/page.tsx`, `@types/x.d.ts` — and is matched literally by
|
|
87
|
+
* string equality. A GLOB entry may not: there those characters would be character-class or
|
|
88
|
+
* extglob syntax this matcher does not implement, so it is refused with that reason rather than
|
|
89
|
+
* silently read one way by us and another way by git or a shell.
|
|
90
|
+
* @throws {StateError} `bad-owned` — empty, absolute, `.`/`..`/empty segments (checked on every
|
|
91
|
+
* brace alternative), backslashes, unbalanced braces, or unsupported glob syntax in a glob.
|
|
92
|
+
*/
|
|
93
|
+
export function assertOwned(owned) {
|
|
94
|
+
if (!Array.isArray(owned) || owned.length === 0) throw new StateError('bad-owned', 'owned files: at least one path is required');
|
|
95
|
+
for (const entry of owned) {
|
|
96
|
+
/** @param {string} why */
|
|
97
|
+
const refuse = (why) => {
|
|
98
|
+
throw new StateError('bad-owned', `owned files: invalid path ${JSON.stringify(entry)}: ${why}`);
|
|
99
|
+
};
|
|
100
|
+
if (typeof entry !== 'string' || entry.length === 0) refuse('must be a non-empty string');
|
|
101
|
+
if (entry.includes('\\')) refuse('backslashes are not allowed (POSIX paths only)');
|
|
102
|
+
const isGlob = /[*?{]/.test(entry);
|
|
103
|
+
if (isGlob && UNSUPPORTED_GLOB_CHARS.test(entry)) {
|
|
104
|
+
refuse('a glob may not contain [ ] ( ) ! + @ — character classes and extglobs are not supported; name the files exactly or use only *, ?, ** and {a,b}');
|
|
105
|
+
}
|
|
106
|
+
for (const alt of expandBraces(entry)) {
|
|
107
|
+
if (alt.startsWith('/')) refuse('must be repo-relative, not absolute');
|
|
108
|
+
if (isGlob && /[{}]/.test(alt)) refuse('unbalanced braces');
|
|
109
|
+
if (alt.split('/').some((segment) => segment === '..' || segment === '.' || segment === '')) refuse('no ".", ".." or empty segments');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return owned;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {string} p @returns {string[]} segments before the first glob character */
|
|
116
|
+
function staticSegments(p) {
|
|
117
|
+
const segments = p.split('/');
|
|
118
|
+
const firstGlob = segments.findIndex((s) => GLOB_CHARS.test(s));
|
|
119
|
+
return firstGlob < 0 ? segments : segments.slice(0, firstGlob);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @param {string[]} a @param {string[]} b */
|
|
123
|
+
const segmentPrefix = (a, b) => a.length <= b.length && a.every((s, i) => s === b[i]);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} a @param {string} b - brace-free patterns
|
|
127
|
+
* @returns {boolean}
|
|
128
|
+
*/
|
|
129
|
+
function patternsOverlap(a, b) {
|
|
130
|
+
const aGlob = GLOB_CHARS.test(a);
|
|
131
|
+
const bGlob = GLOB_CHARS.test(b);
|
|
132
|
+
if (!aGlob && !bGlob) return a === b;
|
|
133
|
+
if (!aGlob) return globMatch(a, b);
|
|
134
|
+
if (!bGlob) return globMatch(b, a);
|
|
135
|
+
const sa = staticSegments(a);
|
|
136
|
+
const sb = staticSegments(b);
|
|
137
|
+
return segmentPrefix(sa, sb) || segmentPrefix(sb, sa);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {ReadonlyArray<string>} ownedA @param {ReadonlyArray<string>} ownedB
|
|
142
|
+
* @returns {[string, string] | null} the first overlapping pair (original spellings), or null.
|
|
143
|
+
*/
|
|
144
|
+
export function findOverlap(ownedA, ownedB) {
|
|
145
|
+
for (const a of ownedA) {
|
|
146
|
+
for (const b of ownedB) {
|
|
147
|
+
if (expandBraces(a).some((x) => expandBraces(b).some((y) => patternsOverlap(x, y)))) return [a, b];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {ReadonlyArray<string>} owned @param {string} file
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
export function ownsFile(owned, file) {
|
|
158
|
+
return owned.some((entry) => expandBraces(entry).some((p) => p === file || (GLOB_CHARS.test(p) && globMatch(file, p))));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @typedef {{owned_files: string[], status?: string}} RegistryBlock
|
|
163
|
+
* @param {Record<string, RegistryBlock>} blocks - the ACTIVE blocks only (status `open`)
|
|
164
|
+
* @param {ReadonlyArray<string>} files - the shared tree's changed + untracked files
|
|
165
|
+
* @returns {{scopes: Record<string, string[]>, orphans: string[]}}
|
|
166
|
+
*/
|
|
167
|
+
export function computeScopes(blocks, files) {
|
|
168
|
+
/** @type {Record<string, string[]>} */
|
|
169
|
+
const scopes = Object.fromEntries(Object.keys(blocks).map((id) => [id, []]));
|
|
170
|
+
const orphans = [];
|
|
171
|
+
for (const file of [...new Set(files)].sort()) {
|
|
172
|
+
const owners = Object.keys(blocks).filter((id) => ownsFile(blocks[id].owned_files, file));
|
|
173
|
+
if (owners.length === 0) orphans.push(file);
|
|
174
|
+
for (const id of owners) scopes[id].push(file);
|
|
175
|
+
}
|
|
176
|
+
return { scopes, orphans };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The scope row of block `blockId`'s gate: its files are what it owns; ANY orphan in the tree
|
|
181
|
+
* refuses EVERY active block's gate until it is claimed (`block claim`) or deleted.
|
|
182
|
+
* @param {Record<string, RegistryBlock>} blocks @param {string} blockId @param {ReadonlyArray<string>} files
|
|
183
|
+
* @returns {{ok: boolean, scope: string[], orphans: string[]}}
|
|
184
|
+
*/
|
|
185
|
+
export function scopeGate(blocks, blockId, files) {
|
|
186
|
+
if (!blocks[blockId]) throw new StateError('no-block', `block ${blockId} is not active`);
|
|
187
|
+
const { scopes, orphans } = computeScopes(blocks, files);
|
|
188
|
+
return { ok: orphans.length === 0, scope: scopes[blockId], orphans };
|
|
189
|
+
}
|