@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,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code-forge proof tier|export|lock|unlock|baseline|restore` (plan §7, §4.7). Prints one JSON
|
|
3
|
+
* object (redacted) per call. Exit codes: 0 done, 1 refused (`proof.busy`, not the holder, bad
|
|
4
|
+
* ref…), 2 usage or a subcommand that has not landed.
|
|
5
|
+
*
|
|
6
|
+
* proof tier --file <path> --risk <0-3> [--security] [--cwd <dir>]
|
|
7
|
+
* proof export <block> --run <r> [--remove]
|
|
8
|
+
* proof lock <block> --run <r>
|
|
9
|
+
* proof unlock <block> --run <r>
|
|
10
|
+
* proof baseline (held on Q16 — block B10c)
|
|
11
|
+
* proof restore <block> --run <r> (the red→green journal, B10b: restores the block's export
|
|
12
|
+
* and the workspace; refuses with exit 1 when neither holds a journal)
|
|
13
|
+
* proof red-green <block> --run <r> --test <file[::case]> [--mechanism revert|assertion-deletion]
|
|
14
|
+
* (B17: one red→green proof through `runRedGreen`, measured in a
|
|
15
|
+
* freshly built export — or in the workspace under `proof lock`,
|
|
16
|
+
* which this block must hold; exit 0 proven, 1 not proven or refused).
|
|
17
|
+
* The signed row carries `covers` (B20): the reverted sources, or
|
|
18
|
+
* the test file for `assertion-deletion`.
|
|
19
|
+
*
|
|
20
|
+
* `export` reads `proof.export.{link_dirs,copy_untracked}` from the workspace's `.code-forge.yml`
|
|
21
|
+
* (defaults §1.3) and writes a signed `proof` row `isolation: export`; `lock`/`unlock` write
|
|
22
|
+
* `isolation: lock` rows (B6 writer, B8 signer).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { lstat } from 'node:fs/promises';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
28
|
+
import { validateConfig } from '../config/validate.mjs';
|
|
29
|
+
import { appendRow } from '../ledger/write.mjs';
|
|
30
|
+
import { parseFlags } from '../state/cli-args.mjs';
|
|
31
|
+
import { StateError } from '../state/paths.mjs';
|
|
32
|
+
import { readRun } from '../state/run.mjs';
|
|
33
|
+
import { redactJSON, writeSafe } from '../util/redact.mjs';
|
|
34
|
+
import { DEFAULT_COPY_UNTRACKED, DEFAULT_LINK_DIRS, buildExport, exportDirFor, recordProof, removeExport } from '../proof/export.mjs';
|
|
35
|
+
import { acquireProofLock, lockHolder, releaseProofLock } from '../proof/lock.mjs';
|
|
36
|
+
import { MECHANISMS, readJournal, restoreJournal, runRedGreen } from '../proof/red-green.mjs';
|
|
37
|
+
import { tierFor } from '../proof/tiers.mjs';
|
|
38
|
+
import { blockFileSet, repoRelativePath } from '../review/gate-check.mjs';
|
|
39
|
+
import { findOverlap } from '../state/registry.mjs';
|
|
40
|
+
|
|
41
|
+
const USAGE =
|
|
42
|
+
'usage: code-forge proof tier --file <path> --risk <0-3> [--security] [--cwd <dir>]\n' +
|
|
43
|
+
' code-forge proof export <block> --run <r> [--remove] · proof lock|unlock <block> --run <r>\n' +
|
|
44
|
+
' code-forge proof restore <block> --run <r>\n' +
|
|
45
|
+
' code-forge proof red-green <block> --run <r> --test <file[::case]> [--mechanism revert|assertion-deletion]\n';
|
|
46
|
+
|
|
47
|
+
/** A test file by path convention: never reverted as a "source" by the `revert` mechanism. */
|
|
48
|
+
const TEST_PATH = /(?:^|\/)(?:test|tests|__tests__|spec)\/|\.(?:test|spec)\.[cm]?[jt]sx?$|Test\.php$/;
|
|
49
|
+
|
|
50
|
+
/** The JavaScript-family test files the filtered command below knows how to run. */
|
|
51
|
+
const NODE_TEST_FILE = /\.(?:mjs|cjs|js)$/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `--test` as a repo-relative POSIX path: `./` segments dropped, never absolute, never escaping
|
|
55
|
+
* the repository with `..`, nothing under `.code-forge/`.
|
|
56
|
+
* @param {string} raw @returns {string}
|
|
57
|
+
* @throws {StateError} `usage`
|
|
58
|
+
*/
|
|
59
|
+
function normalizeTestPath(raw) {
|
|
60
|
+
if (raw.length === 0 || path.isAbsolute(raw) || raw.includes('\\') || raw.includes('\0')) throw new StateError('usage', '--test must be a repo-relative path');
|
|
61
|
+
const normalized = path.posix.normalize(raw);
|
|
62
|
+
if (normalized === '.' || normalized === '..' || normalized.startsWith('../')) throw new StateError('usage', '--test must stay inside the repository (no .. escape)');
|
|
63
|
+
if (normalized === '.code-forge' || normalized.startsWith('.code-forge/')) throw new StateError('usage', '--test cannot be under .code-forge/');
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} dir - the work dir the test runs in @param {string} file - normalized, repo-relative
|
|
69
|
+
* @throws {StateError} `usage` when `file` is not a regular file under `dir`
|
|
70
|
+
*/
|
|
71
|
+
async function assertTestFile(dir, file) {
|
|
72
|
+
const st = await lstat(path.join(dir, file)).catch(() => null);
|
|
73
|
+
if (!st?.isFile()) throw new StateError('usage', `--test ${file} is not a file in ${dir}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The test command filtered to one test: `node --test [--test-name-pattern ^<case>$] <file>`, run
|
|
78
|
+
* with the Node that runs this CLI. Other stacks (Pest, Vitest…) have no filtered command here yet.
|
|
79
|
+
* @param {string} file @param {string | undefined} testCase
|
|
80
|
+
* @returns {string[]}
|
|
81
|
+
* @throws {StateError} `unsupported-test`
|
|
82
|
+
*/
|
|
83
|
+
function filteredTestArgv(file, testCase) {
|
|
84
|
+
if (!NODE_TEST_FILE.test(file)) throw new StateError('unsupported-test', 'only node:test files (.mjs, .cjs, .js) have a filtered test command in this version');
|
|
85
|
+
const pattern = testCase === undefined ? [] : ['--test-name-pattern', `^${testCase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`];
|
|
86
|
+
return [process.execPath, '--test', ...pattern, file];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const NOT_LANDED = {
|
|
90
|
+
baseline: 'proof baseline: held on Q16 (block B10c) — the gate enforces red→green only',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** @param {Record<string, any>} config @returns {string[]} */
|
|
94
|
+
const highPathsOf = (config) => (Array.isArray(config?.proof?.tiers?.high?.paths) ? config.proof.tiers.high.paths : []);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The project config. `{}` (the §1.3 defaults apply) ONLY when no `.code-forge.yml` exists; a file
|
|
98
|
+
* that exists but does not load or validate is refused — a typo must never silently drop
|
|
99
|
+
* `proof.tiers.high.paths` (a tier downgrade), the `proof.isolation: lock` guard or the export
|
|
100
|
+
* lists. The message names the problem kind and key paths only, never values.
|
|
101
|
+
* @param {string} cwd
|
|
102
|
+
* @returns {Promise<Record<string, any>>}
|
|
103
|
+
* @throws {StateError} `bad-config`
|
|
104
|
+
*/
|
|
105
|
+
async function configAt(cwd) {
|
|
106
|
+
const loaded = await loadProjectConfig(cwd);
|
|
107
|
+
if (!loaded.ok && loaded.error === 'not-found') return {};
|
|
108
|
+
if (!loaded.ok || !loaded.config) {
|
|
109
|
+
throw new StateError('bad-config', `${DEFAULT_CONFIG_FILENAME} does not load (${loaded.error ?? 'no config'}) — run code-forge validate`);
|
|
110
|
+
}
|
|
111
|
+
const result = validateConfig(loaded.config);
|
|
112
|
+
if (!result.valid) {
|
|
113
|
+
const where = [...new Set(result.errors.map((e) => (typeof e.path === 'string' ? e.path : e.rule)))].join(', ');
|
|
114
|
+
throw new StateError('bad-config', `${DEFAULT_CONFIG_FILENAME} is invalid (${result.errors.length} error(s) at ${where}) — run code-forge validate`);
|
|
115
|
+
}
|
|
116
|
+
return loaded.config;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build the block's measurement export and write its signed `proof` row (`step: export`).
|
|
121
|
+
* @param {{runId: string, record: Record<string, any>, blockId: string, block: Record<string, any>, config: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
|
|
122
|
+
*/
|
|
123
|
+
async function exportAndRecord({ runId, record, blockId, block, config, writeRow }) {
|
|
124
|
+
const exp = await buildExport({
|
|
125
|
+
cwd: record.workspace,
|
|
126
|
+
blockId,
|
|
127
|
+
baseSha: block.base_sha,
|
|
128
|
+
owned: block.owned_files,
|
|
129
|
+
linkDirs: config?.proof?.export?.link_dirs ?? DEFAULT_LINK_DIRS,
|
|
130
|
+
copyUntracked: config?.proof?.export?.copy_untracked ?? DEFAULT_COPY_UNTRACKED,
|
|
131
|
+
});
|
|
132
|
+
await recordProof({
|
|
133
|
+
runId,
|
|
134
|
+
writeRow,
|
|
135
|
+
row: { block: blockId, isolation: 'export', step: 'export', base_sha: exp.base, restored: exp.restored, untracked: exp.untracked },
|
|
136
|
+
});
|
|
137
|
+
return exp;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {string[]} args
|
|
142
|
+
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [deps]
|
|
143
|
+
* @returns {Promise<number>}
|
|
144
|
+
*/
|
|
145
|
+
export async function runProof(args, deps = {}) {
|
|
146
|
+
const { stdout = process.stdout, stderr = process.stderr } = deps;
|
|
147
|
+
const out = (/** @type {object} */ value) => writeSafe(stdout, `${redactJSON(value)}\n`);
|
|
148
|
+
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
149
|
+
const [sub, ...rest] = args;
|
|
150
|
+
|
|
151
|
+
if (typeof sub === 'string' && Object.hasOwn(NOT_LANDED, sub)) {
|
|
152
|
+
err(`${NOT_LANDED[/** @type {'baseline'} */ (sub)]}\n`);
|
|
153
|
+
return 2;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
if (sub === 'tier') {
|
|
157
|
+
const { flags, positionals } = parseFlags(rest, { values: ['file', 'risk', 'cwd'], booleans: ['security'] });
|
|
158
|
+
if (positionals.length > 0 || typeof flags.file !== 'string' || typeof flags.risk !== 'string') {
|
|
159
|
+
throw new StateError('usage', 'proof tier needs --file and --risk');
|
|
160
|
+
}
|
|
161
|
+
if (!/^[0-3]$/.test(flags.risk)) throw new StateError('usage', '--risk must be 0, 1, 2 or 3');
|
|
162
|
+
const config = await configAt(typeof flags.cwd === 'string' ? flags.cwd : process.cwd());
|
|
163
|
+
const result = tierFor({ file: flags.file, risk: Number(flags.risk), securitySensitive: flags.security === true, highPaths: highPathsOf(config) });
|
|
164
|
+
out({ file: flags.file, ...result });
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (sub === 'restore') {
|
|
169
|
+
const { flags, positionals } = parseFlags(rest, { values: ['run'] });
|
|
170
|
+
const [blockId, extra] = positionals;
|
|
171
|
+
if (!blockId || extra !== undefined || typeof flags.run !== 'string') throw new StateError('usage', 'proof restore needs one block id and --run');
|
|
172
|
+
const record = await readRun(flags.run);
|
|
173
|
+
// The runner journals in the directory it measured in: the block's export, or the
|
|
174
|
+
// workspace itself under the proof lock.
|
|
175
|
+
const dirs = [exportDirFor(record.workspace, blockId), record.workspace];
|
|
176
|
+
const restored = [];
|
|
177
|
+
for (const workDir of dirs) {
|
|
178
|
+
if ((await readJournal(workDir)) === null) continue;
|
|
179
|
+
restored.push({ dir: workDir, files: (await restoreJournal({ workDir })).restored });
|
|
180
|
+
}
|
|
181
|
+
if (restored.length === 0) throw new StateError('no-journal', `no red→green journal for block ${blockId} — nothing to restore`);
|
|
182
|
+
out({ restored });
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (sub === 'export' || sub === 'lock' || sub === 'unlock') {
|
|
187
|
+
const { flags, positionals } = parseFlags(rest, { values: ['run'], booleans: sub === 'export' ? ['remove'] : [] });
|
|
188
|
+
const [blockId, extra] = positionals;
|
|
189
|
+
if (!blockId || extra !== undefined || typeof flags.run !== 'string') throw new StateError('usage', `proof ${sub} needs one block id and --run`);
|
|
190
|
+
const runId = flags.run;
|
|
191
|
+
const record = await readRun(runId);
|
|
192
|
+
const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
193
|
+
|
|
194
|
+
if (sub === 'lock') {
|
|
195
|
+
out({ locked: await acquireProofLock({ runId, blockId, writeRow }) });
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
if (sub === 'unlock') {
|
|
199
|
+
await releaseProofLock({ runId, blockId, writeRow });
|
|
200
|
+
out({ unlocked: blockId });
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const block = record.blocks?.[blockId];
|
|
205
|
+
if (block?.status !== 'open') throw new StateError('no-block', `block ${blockId} is not open in run ${runId}`);
|
|
206
|
+
if (flags.remove === true) {
|
|
207
|
+
await removeExport({ cwd: record.workspace, blockId });
|
|
208
|
+
out({ removed: blockId });
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
const config = await configAt(record.workspace);
|
|
212
|
+
if (config?.proof?.isolation === 'lock') throw new StateError('isolation-lock', 'proof.isolation is lock — use proof lock instead of an export');
|
|
213
|
+
out(await exportAndRecord({ runId, record, blockId, block, config, writeRow }));
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (sub === 'red-green') {
|
|
218
|
+
const { flags, positionals } = parseFlags(rest, { values: ['run', 'test', 'mechanism'] });
|
|
219
|
+
const [blockId, extra] = positionals;
|
|
220
|
+
if (!blockId || extra !== undefined || typeof flags.run !== 'string' || typeof flags.test !== 'string') {
|
|
221
|
+
throw new StateError('usage', 'proof red-green needs one block id, --run and --test <file[::case]>');
|
|
222
|
+
}
|
|
223
|
+
const mechanism = flags.mechanism;
|
|
224
|
+
if (mechanism !== undefined && !MECHANISMS.includes(/** @type {any} */ (mechanism))) throw new StateError('usage', `--mechanism must be one of ${MECHANISMS.join(', ')}`);
|
|
225
|
+
const sep = flags.test.indexOf('::');
|
|
226
|
+
const testCase = sep < 0 ? undefined : flags.test.slice(sep + 2);
|
|
227
|
+
if (testCase === '') throw new StateError('usage', '--test <file>::<case> needs a case name after ::');
|
|
228
|
+
const file = normalizeTestPath(sep < 0 ? flags.test : flags.test.slice(0, sep));
|
|
229
|
+
const argv = filteredTestArgv(file, testCase);
|
|
230
|
+
const runId = flags.run;
|
|
231
|
+
const record = await readRun(runId);
|
|
232
|
+
const block = record.blocks?.[blockId];
|
|
233
|
+
if (block?.status !== 'open') throw new StateError('no-block', `block ${blockId} is not open in run ${runId}`);
|
|
234
|
+
await assertTestFile(record.workspace, file);
|
|
235
|
+
const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
236
|
+
const config = await configAt(record.workspace);
|
|
237
|
+
const isolation = config?.proof?.isolation === 'lock' ? 'lock' : 'export';
|
|
238
|
+
if (isolation === 'lock' && (await lockHolder(runId)) !== blockId) {
|
|
239
|
+
throw new StateError('proof.busy', `proof.isolation is lock — run code-forge proof lock ${blockId} --run ${runId} first`);
|
|
240
|
+
}
|
|
241
|
+
// `revert` puts every changed non-test owned file back to base; `assertion-deletion` touches only the test.
|
|
242
|
+
const changed = await blockFileSet({
|
|
243
|
+
repoRoot: record.workspace,
|
|
244
|
+
base: block.base_sha,
|
|
245
|
+
owned: block.owned_files,
|
|
246
|
+
matchOwned: (f) => findOverlap(block.owned_files, [f]) !== null,
|
|
247
|
+
});
|
|
248
|
+
const sources = mechanism === 'assertion-deletion' ? [] : changed.map((c) => c.file).filter((f) => f !== file && !TEST_PATH.test(f));
|
|
249
|
+
if (mechanism === 'revert' && sources.length === 0) {
|
|
250
|
+
throw new StateError('usage', `block ${blockId} changed no non-test source file to revert — use --mechanism assertion-deletion`);
|
|
251
|
+
}
|
|
252
|
+
const exp = isolation === 'export' ? await exportAndRecord({ runId, record, blockId, block, config, writeRow }) : null;
|
|
253
|
+
if (exp) await assertTestFile(exp.dir, file); // the test must also be where it runs (owned, or tracked at base)
|
|
254
|
+
const result = await runRedGreen({
|
|
255
|
+
workDir: exp ? exp.dir : record.workspace,
|
|
256
|
+
repoDir: record.workspace,
|
|
257
|
+
base: block.base_sha,
|
|
258
|
+
argv,
|
|
259
|
+
test: { file, ...(testCase === undefined ? {} : { case: testCase }), characterization: mechanism === 'assertion-deletion' },
|
|
260
|
+
sources,
|
|
261
|
+
isolation,
|
|
262
|
+
print: (line) => err(`${line}\n`),
|
|
263
|
+
});
|
|
264
|
+
// B20: the signed row names what the proof covers — the block gate reads `covers` per
|
|
265
|
+
// high-tier file. `revert` covers the sources it put back to base; `assertion-deletion`
|
|
266
|
+
// covers only the test file itself (its `::case` label is never part of a path). Every entry
|
|
267
|
+
// is normalized exactly as the gate normalizes its changed files (`repoRelativePath`).
|
|
268
|
+
const covers = [...new Set((result.mechanism === 'revert' ? sources : [file]).map((f) => repoRelativePath(f)))].sort();
|
|
269
|
+
// A result with no red phase (refused before the tree was touched) is never proven: the row
|
|
270
|
+
// still lands, `proven: false` and `red_kind: null`, and the verb exits 1.
|
|
271
|
+
const red = result.red ?? null;
|
|
272
|
+
const proven = result.proven === true && red !== null;
|
|
273
|
+
await recordProof({
|
|
274
|
+
runId,
|
|
275
|
+
writeRow,
|
|
276
|
+
row: {
|
|
277
|
+
block: blockId,
|
|
278
|
+
isolation,
|
|
279
|
+
step: 'red-green',
|
|
280
|
+
test: result.label,
|
|
281
|
+
mechanism: result.mechanism,
|
|
282
|
+
red_kind: red?.red_kind ?? null,
|
|
283
|
+
red: red?.verdict ?? null,
|
|
284
|
+
green: result.green ?? null,
|
|
285
|
+
proven,
|
|
286
|
+
failed: red?.failed ?? null,
|
|
287
|
+
covers,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
out({
|
|
291
|
+
block: blockId,
|
|
292
|
+
test: result.label,
|
|
293
|
+
mechanism: result.mechanism,
|
|
294
|
+
isolation,
|
|
295
|
+
red: red?.verdict ?? null,
|
|
296
|
+
red_kind: red?.red_kind ?? null,
|
|
297
|
+
green: result.green ?? null,
|
|
298
|
+
proven,
|
|
299
|
+
...(exp ? { export: { dir: exp.dir, restored: exp.restored, untracked: exp.untracked } } : {}),
|
|
300
|
+
});
|
|
301
|
+
return proven ? 0 : 1;
|
|
302
|
+
}
|
|
303
|
+
} catch (thrown) {
|
|
304
|
+
const code = thrown instanceof StateError ? thrown.code : 'error';
|
|
305
|
+
err(`proof ${sub}: ${thrown?.message ?? String(thrown)}\n`);
|
|
306
|
+
return code === 'usage' ? 2 : 1;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
err(USAGE);
|
|
310
|
+
return 2;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** @param {string[]} args @returns {Promise<number>} */
|
|
314
|
+
export default async function proof(args) {
|
|
315
|
+
return runProof(args);
|
|
316
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code-forge remove [<harness>] [--scope project|global]` — deletes ONLY paths recorded in
|
|
3
|
+
* `~/.code-forge/installs.json` (plan §10.3 acceptance: "`remove` deletes only the 3 recorded
|
|
4
|
+
* paths and leaves a 4th unrecorded file"). With no arguments, every recorded install is removed;
|
|
5
|
+
* `<harness>` narrows to one harness id; `--scope` further narrows to `project` or `global`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
10
|
+
import { installsPath, removeRecords } from '../install/link.mjs';
|
|
11
|
+
|
|
12
|
+
const USAGE = 'usage: code-forge remove [<harness>] [--scope project|global]\n';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Strict parse: `[<harness>] [--scope <project|global>]`, in that order. An unknown flag, a
|
|
16
|
+
* missing/invalid `--scope` value, a harness name that looks like a flag, or an extra argument is
|
|
17
|
+
* a usage error — never silently reinterpreted.
|
|
18
|
+
* @param {string[]} args
|
|
19
|
+
* @returns {{harness?: string, scope?: 'project'|'global'} | null}
|
|
20
|
+
*/
|
|
21
|
+
export function parseRemoveArgs(args) {
|
|
22
|
+
if (args.length === 0) {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
let harness;
|
|
26
|
+
let rest = args;
|
|
27
|
+
if (!rest[0].startsWith('-')) {
|
|
28
|
+
[harness, ...rest] = rest;
|
|
29
|
+
}
|
|
30
|
+
if (rest.length === 0) {
|
|
31
|
+
return { harness };
|
|
32
|
+
}
|
|
33
|
+
const [flag, value, ...extra] = rest;
|
|
34
|
+
if (flag !== '--scope' || (value !== 'project' && value !== 'global') || extra.length > 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
// `harness` is only included when it was actually parsed — an object with an explicit
|
|
38
|
+
// `harness: undefined` key is NOT deep-equal to one that omits the key altogether.
|
|
39
|
+
return harness === undefined ? { scope: value } : { harness, scope: value };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string[]} args
|
|
44
|
+
* @param {object} deps
|
|
45
|
+
* @param {{write: (s: string) => unknown}} [deps.stdout]
|
|
46
|
+
* @param {{write: (s: string) => unknown}} [deps.stderr]
|
|
47
|
+
* @param {string} [deps.home]
|
|
48
|
+
* @returns {Promise<number>}
|
|
49
|
+
*/
|
|
50
|
+
export async function runRemove(args, { stdout = process.stdout, stderr = process.stderr, home = os.homedir() } = {}) {
|
|
51
|
+
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
52
|
+
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
53
|
+
|
|
54
|
+
const parsed = parseRemoveArgs(args);
|
|
55
|
+
if (!parsed) {
|
|
56
|
+
err(USAGE);
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const removed = await removeRecords({ installsFile: installsPath(home), harness: parsed.harness, scope: parsed.scope });
|
|
61
|
+
for (const record of removed) {
|
|
62
|
+
out(`removed ${record.harness} (${record.scope}) ${record.target}\n`);
|
|
63
|
+
}
|
|
64
|
+
if (removed.length === 0) {
|
|
65
|
+
out('nothing to remove\n');
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string[]} args
|
|
72
|
+
* @returns {Promise<number>}
|
|
73
|
+
*/
|
|
74
|
+
export default async function remove(args) {
|
|
75
|
+
return runRemove(args, {});
|
|
76
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code-forge report [--slug <slug>] [--json] [--export <dir>]` (plan §6.2, R4). Terminal output
|
|
3
|
+
* only, plus an optional `--export <dir>` that writes the same 13 sections as JSON files.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
7
|
+
import { buildReport } from '../ledger/report.mjs';
|
|
8
|
+
import { exportReportSections } from '../ledger/export.mjs';
|
|
9
|
+
import { readAllRows } from '../ledger/write.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string[]} args @param {string} flag @returns {string|undefined}
|
|
13
|
+
* `undefined` (not the next token) when the flag is absent, is the LAST token, or the "next
|
|
14
|
+
* token" starts with `--` — otherwise `report --slug --json` would read `--json` as the slug, and
|
|
15
|
+
* `report --slug x --export` (missing a directory) would silently print instead of exporting.
|
|
16
|
+
*/
|
|
17
|
+
function flagValue(args, flag) {
|
|
18
|
+
const i = args.indexOf(flag);
|
|
19
|
+
if (i < 0) return undefined;
|
|
20
|
+
const next = args[i + 1];
|
|
21
|
+
return next !== undefined && !next.startsWith('--') ? next : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string[]} args
|
|
26
|
+
* @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}}} [deps]
|
|
27
|
+
* @returns {Promise<number>}
|
|
28
|
+
*/
|
|
29
|
+
export async function runReport(args, deps = {}) {
|
|
30
|
+
const { stdout = process.stdout, stderr = process.stderr } = deps;
|
|
31
|
+
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
32
|
+
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
33
|
+
|
|
34
|
+
const slug = flagValue(args, '--slug');
|
|
35
|
+
if (!slug) {
|
|
36
|
+
err('usage: code-forge report --slug <slug> [--json] [--export <dir>]\n');
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// `readAllRows`/`ledgerPath` (src/ledger/paths.mjs) already refuse a slug shaped like a path
|
|
41
|
+
// (anything outside `/^[a-z0-9][a-z0-9-]*$/` — including `..` and `/`) with a TypeError; the
|
|
42
|
+
// try/catch below turns that, and any other I/O failure, into a clean CLI message instead of a
|
|
43
|
+
// raw stack trace that skips the `writeSafe` redaction path.
|
|
44
|
+
try {
|
|
45
|
+
const rows = await readAllRows(slug);
|
|
46
|
+
const { sections } = buildReport(rows);
|
|
47
|
+
|
|
48
|
+
const exportDir = flagValue(args, '--export');
|
|
49
|
+
if (exportDir) {
|
|
50
|
+
const paths = await exportReportSections(exportDir, sections);
|
|
51
|
+
out(`exported ${paths.length} section file(s) to ${exportDir}\n`);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (args.includes('--json')) {
|
|
56
|
+
out(`${JSON.stringify(sections, null, 2)}\n`);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const [name, data] of Object.entries(sections)) {
|
|
61
|
+
out(`== ${name} ==\n${JSON.stringify(data, null, 2)}\n`);
|
|
62
|
+
}
|
|
63
|
+
return 0;
|
|
64
|
+
} catch (err_) {
|
|
65
|
+
err(`report: ${err_?.message ?? String(err_)}\n`);
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {string[]} args @returns {Promise<number>} */
|
|
71
|
+
export default async function report(args) {
|
|
72
|
+
return runReport(args);
|
|
73
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code-forge resolve <L0|L1|L2|L3>` — prints what a level actually resolves to right now
|
|
3
|
+
* (plan §5.2, §10.3 B4 acceptance): `{provider, model, effort, fallback, cli}`, via B1's
|
|
4
|
+
* `resolveLevel` (the per-level `provider` override wins over the top-level `provider`, R5) plus
|
|
5
|
+
* this block's own `cliNameForProvider` (the CLI binary `engine: subprocess` would spawn).
|
|
6
|
+
*
|
|
7
|
+
* **Fix round 1 (isolated per-file review, all MINOR):**
|
|
8
|
+
* - The result is now written with `redact.writeSafe(process.stdout, ...)` — B0's documented
|
|
9
|
+
* "stdout path" for a machine-readable value that must NOT go through `log.info` (which prefixes
|
|
10
|
+
* every line with `[code-forge:info] `, defeating `jq`/pipe consumers of this verb's output).
|
|
11
|
+
* - `cli: cliNameForProvider(...) ?? null` used to still exit 0 for a provider with no CLI mapping
|
|
12
|
+
* (possible: `resolve` does not itself call `validateConfig`, unlike `validate.mjs`, so a config
|
|
13
|
+
* with an out-of-enum `provider` string can reach here). It now logs an error and exits 1
|
|
14
|
+
* instead of silently printing `"cli": null`.
|
|
15
|
+
* - `resolved.fallback` is never `undefined` — `resolveLevel` (B1, `known-ids.mjs`) always returns
|
|
16
|
+
* a (possibly empty) frozen array, so no `?? null`/`?? []` default was ever needed here; the
|
|
17
|
+
* test suite now locks in the exact 5-key contract for BOTH the with-fallback and
|
|
18
|
+
* without-fallback cases, closing the test gap the review actually found.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { loadProjectConfig } from '../config/load.mjs';
|
|
22
|
+
import { resolveLevel } from '../config/known-ids.mjs';
|
|
23
|
+
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
24
|
+
import { maskSecretTokens } from '../config/secret-patterns.mjs';
|
|
25
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
26
|
+
import * as log from '../util/log.mjs';
|
|
27
|
+
|
|
28
|
+
const VALID_LEVELS = Object.freeze(['L0', 'L1', 'L2', 'L3']);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string[]} args
|
|
32
|
+
* @returns {{level: "L0"|"L1"|"L2"|"L3"} | {error: string}}
|
|
33
|
+
*/
|
|
34
|
+
function parseArgs(args) {
|
|
35
|
+
if (args.length !== 1) {
|
|
36
|
+
return { error: `usage: resolve <${VALID_LEVELS.join('|')}>` };
|
|
37
|
+
}
|
|
38
|
+
const [level] = args;
|
|
39
|
+
if (!VALID_LEVELS.includes(level)) {
|
|
40
|
+
return { error: `unknown level "${level}" — expected one of ${VALID_LEVELS.join(', ')}` };
|
|
41
|
+
}
|
|
42
|
+
return { level: /** @type {"L0"|"L1"|"L2"|"L3"} */ (level) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string[]} args
|
|
47
|
+
* @returns {Promise<number>}
|
|
48
|
+
*/
|
|
49
|
+
export default async function resolve(args) {
|
|
50
|
+
const parsed = parseArgs(args);
|
|
51
|
+
if ('error' in parsed) {
|
|
52
|
+
log.error(maskSecretTokens(parsed.error));
|
|
53
|
+
return 2;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const loaded = await loadProjectConfig();
|
|
57
|
+
if (!loaded.ok) {
|
|
58
|
+
log.error(maskSecretTokens(`${loaded.error}: ${loaded.message}`));
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let resolved;
|
|
63
|
+
try {
|
|
64
|
+
resolved = resolveLevel(loaded.config, parsed.level);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
log.error(maskSecretTokens(/** @type {Error} */ (err).message));
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cli = cliNameForProvider(resolved.provider);
|
|
71
|
+
if (cli === undefined) {
|
|
72
|
+
log.error(maskSecretTokens(`resolve: provider "${resolved.provider}" (from levels.${parsed.level}) has no known CLI mapping`));
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const output = { provider: resolved.provider, model: resolved.model, effort: resolved.effort ?? null, fallback: resolved.fallback, cli };
|
|
77
|
+
writeSafe(process.stdout, `${JSON.stringify(output)}\n`);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|