@codedology/code-forge 0.1.0 → 0.2.1
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 +43 -0
- package/README.md +29 -6
- package/docs/README.md +4 -1
- package/docs/reference/blocks.json +13 -2
- package/package.json +6 -2
- package/skill/references/review.md +4 -0
- package/skill/scripts/forge +1 -1
- package/src/cli/review.mjs +610 -0
- package/src/install/wizard/answers.mjs +102 -46
- package/src/install/wizard/run.mjs +37 -7
- package/src/install/wizard/steps.mjs +134 -34
- package/src/install/wizard/summary.mjs +168 -0
- package/src/worker/engine.mjs +50 -8
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Project settings (detected)" block `code-forge init` prints instead of asking for gates and
|
|
3
|
+
* proof settings (B24). Every gate and proof value is listed with where it came from; a blank value
|
|
4
|
+
* says so and why, and the block ends with the one line that says how to fill the blanks later.
|
|
5
|
+
* Agent/JSON mode gets the same facts as `settings` + `blank`. Pure apart from `existsSync` reads
|
|
6
|
+
* that name the file a detected gate came from.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { LINK_DIR_MANIFESTS, copyCandidates, linkDirsFromManifests } from './answers.mjs';
|
|
12
|
+
import { GATE_NAMES } from './flags.mjs';
|
|
13
|
+
|
|
14
|
+
export const SUMMARY_TITLE = 'Project settings (detected)';
|
|
15
|
+
|
|
16
|
+
/** The config key each summarised value is written to. */
|
|
17
|
+
export const SETTING_KEYS = Object.freeze({
|
|
18
|
+
test: 'gates.test',
|
|
19
|
+
lint: 'gates.lint',
|
|
20
|
+
types: 'gates.types',
|
|
21
|
+
format: 'gates.format',
|
|
22
|
+
high: 'proof.tiers.high.paths',
|
|
23
|
+
isolation: 'proof.isolation',
|
|
24
|
+
link_dirs: 'proof.export.link_dirs',
|
|
25
|
+
copy_untracked: 'proof.export.copy_untracked',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** What a blank gate means, in the words the summary prints. */
|
|
29
|
+
const GATE_MISSING = Object.freeze({
|
|
30
|
+
test: 'no test command found',
|
|
31
|
+
lint: 'no linter found',
|
|
32
|
+
types: 'no type checker found',
|
|
33
|
+
format: 'no formatter found',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** The summary's label for each proof value. */
|
|
37
|
+
const PROOF_LABELS = Object.freeze({
|
|
38
|
+
high: 'high-risk paths',
|
|
39
|
+
isolation: 'isolation',
|
|
40
|
+
link_dirs: 'link dirs',
|
|
41
|
+
copy_untracked: 'copy untracked',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** The marker file that decides each non-Node stack in `detectGates`. */
|
|
45
|
+
const STACK_MARKER = Object.freeze({ rust: 'Cargo.toml', python: 'pyproject.toml', go: 'go.mod' });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} Setting
|
|
49
|
+
* @property {unknown} value - what is written to the config.
|
|
50
|
+
* @property {'flag'|'existing'|'detected'|'default'} source
|
|
51
|
+
* @property {string} note - where it came from, or why it is blank, in the summary's words.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {object} SettingsSummary
|
|
56
|
+
* @property {Record<string, Setting>} settings - keyed by config path (`gates.test`, …).
|
|
57
|
+
* @property {string[]} blank - the config paths left blank, in summary order.
|
|
58
|
+
* @property {string[]} lines - the printed block, title first, the "set later" line last.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** @param {string[]} argv @returns {string} */
|
|
62
|
+
function shown(argv) {
|
|
63
|
+
return argv.map((w) => (/[\s"'\\]/.test(w) || w === '' ? JSON.stringify(w) : w)).join(' ');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {string} cwd @param {string[]} names @returns {string|undefined} */
|
|
67
|
+
function firstPresent(cwd, names) {
|
|
68
|
+
return names.find((n) => existsSync(path.join(cwd, n)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The file a detected gate came from (the evidence `detectGates` read).
|
|
73
|
+
* @param {string} stack @param {string} name @param {string[]} argv @param {string} cwd
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function gateEvidence(stack, name, argv, cwd) {
|
|
77
|
+
if (stack.startsWith('php')) {
|
|
78
|
+
const tool = path.basename(argv[0] ?? '');
|
|
79
|
+
if (tool === 'phpstan') return firstPresent(cwd, ['phpstan.neon', 'phpstan.neon.dist']) ?? 'vendor/bin/phpstan';
|
|
80
|
+
if (tool === 'pint') return firstPresent(cwd, ['pint.json']) ?? 'vendor/bin/pint';
|
|
81
|
+
if (tool === 'php' && argv[1] === 'artisan') return 'artisan';
|
|
82
|
+
if (tool === 'pest') return 'vendor/bin/pest';
|
|
83
|
+
return 'composer.json';
|
|
84
|
+
}
|
|
85
|
+
if (stack === 'node') {
|
|
86
|
+
if (argv[0] === 'npx' && argv[1] === 'tsc') return 'tsconfig.json';
|
|
87
|
+
if (argv[0] === 'npx' && argv[1] === 'prettier') return firstPresent(cwd, ['.prettierrc', '.prettierrc.json', '.prettierrc.js']) ?? 'package.json';
|
|
88
|
+
return 'package.json';
|
|
89
|
+
}
|
|
90
|
+
return STACK_MARKER[/** @type {keyof typeof STACK_MARKER} */ (stack)] ?? 'the project';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {unknown} value @returns {boolean} an empty proof value: null, undefined, '' or [] */
|
|
94
|
+
function isBlankValue(value) {
|
|
95
|
+
return value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {'flag'|'existing'|'detected'|'default'} source @param {string} flag @returns {string} */
|
|
99
|
+
function keptFrom(source, flag) {
|
|
100
|
+
return source === 'flag' ? flag : source === 'existing' ? '.code-forge.yml' : 'default';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {import('./answers.mjs').Answers} values
|
|
105
|
+
* @param {import('./answers.mjs').Origins} origins
|
|
106
|
+
* @param {{cwd: string, profile: string, detected: {stack: string}}} ctx
|
|
107
|
+
* @returns {SettingsSummary}
|
|
108
|
+
*/
|
|
109
|
+
export function summarizeSettings(values, origins, ctx) {
|
|
110
|
+
/** @type {Record<string, Setting>} */
|
|
111
|
+
const settings = {};
|
|
112
|
+
/** @type {string[]} */
|
|
113
|
+
const blank = [];
|
|
114
|
+
const lines = [SUMMARY_TITLE];
|
|
115
|
+
|
|
116
|
+
for (const name of GATE_NAMES) {
|
|
117
|
+
const key = SETTING_KEYS[/** @type {keyof typeof SETTING_KEYS} */ (name)];
|
|
118
|
+
const argv = values.gates[/** @type {keyof typeof values.gates} */ (name)];
|
|
119
|
+
const source = origins.gates[name] ?? 'detected';
|
|
120
|
+
let note;
|
|
121
|
+
if (argv === null || argv === undefined || argv.length === 0) {
|
|
122
|
+
if (source === 'flag') note = 'set blank by --gate';
|
|
123
|
+
else if (source === 'existing') note = 'empty in .code-forge.yml';
|
|
124
|
+
else note = GATE_MISSING[/** @type {keyof typeof GATE_MISSING} */ (name)];
|
|
125
|
+
blank.push(key);
|
|
126
|
+
lines.push(` ${name}: blank — ${note}`);
|
|
127
|
+
} else {
|
|
128
|
+
note = source === 'detected' ? gateEvidence(ctx.detected.stack, name, argv, ctx.cwd) : keptFrom(source, '--gate');
|
|
129
|
+
lines.push(` ${name}: ${shown(argv)} (from ${note})`);
|
|
130
|
+
}
|
|
131
|
+
settings[key] = { value: argv, source, note };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const found = new Map(linkDirsFromManifests(ctx.cwd).map((e) => [e.dir, e.manifests]));
|
|
135
|
+
for (const pk of /** @type {Array<keyof typeof PROOF_LABELS>} */ (Object.keys(PROOF_LABELS))) {
|
|
136
|
+
const key = SETTING_KEYS[pk];
|
|
137
|
+
const value = values.proof[pk];
|
|
138
|
+
const source = origins.proof[pk] ?? 'default';
|
|
139
|
+
const label = PROOF_LABELS[pk];
|
|
140
|
+
let note;
|
|
141
|
+
if (isBlankValue(value)) {
|
|
142
|
+
if (source === 'flag') note = 'set blank by --proof';
|
|
143
|
+
else if (source === 'existing') note = 'empty in .code-forge.yml';
|
|
144
|
+
else if (pk === 'high') note = 'none set (all files light tier by path)';
|
|
145
|
+
else if (pk === 'link_dirs') note = 'no dependency manifest found';
|
|
146
|
+
else {
|
|
147
|
+
const looked = copyCandidates(ctx.profile);
|
|
148
|
+
note = looked.length > 0 ? `none found (looked for ${looked.join(', ')})` : 'none for this stack';
|
|
149
|
+
}
|
|
150
|
+
blank.push(key);
|
|
151
|
+
lines.push(` ${label}: blank — ${note}`);
|
|
152
|
+
} else {
|
|
153
|
+
const list = Array.isArray(value) ? value : [String(value)];
|
|
154
|
+
// copied files are evidence themselves; everything else names where it came from
|
|
155
|
+
const foundOnDisk = source === 'detected' && pk === 'copy_untracked';
|
|
156
|
+
if (foundOnDisk) note = 'found in the project';
|
|
157
|
+
else if (source !== 'detected') note = keptFrom(source, '--proof');
|
|
158
|
+
else note = [...new Set(list.flatMap((d) => found.get(d) ?? LINK_DIR_MANIFESTS[/** @type {keyof typeof LINK_DIR_MANIFESTS} */ (d)] ?? []))].join(', ');
|
|
159
|
+
lines.push(` ${label}: ${list.join(', ')} (${foundOnDisk ? note : `from ${note}`})`);
|
|
160
|
+
}
|
|
161
|
+
settings[key] = { value, source, note };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
lines.push(blank.length > 0
|
|
165
|
+
? `Set the blanks later: edit .code-forge.yml (${blank.join(', ')}), then run \`code-forge validate\`.`
|
|
166
|
+
: 'Change any of these later: edit .code-forge.yml, then run `code-forge validate`.');
|
|
167
|
+
return { settings, blank, lines };
|
|
168
|
+
}
|
package/src/worker/engine.mjs
CHANGED
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
* else `findings` = the open fix list, `next` = what the orchestrator does (`fix` at `level`,
|
|
22
22
|
* `trigger: review_stall | review_rounds`, `patch`), `stopped` = the stop reason.
|
|
23
23
|
* Without `ctx.key` (a direct call) the hook is the engine alone and writes no approval row.
|
|
24
|
+
* Every packet (round 1, recheck, patch check) carries the block's acceptance clauses from the run
|
|
25
|
+
* record in its facts slot, labelled `Acceptance clauses` (`acceptanceExcerpt`): redacted, capped
|
|
26
|
+
* at an eighth of `review.budgets.full_in` with a truncation marker, and `(acceptance unavailable)`
|
|
27
|
+
* when the record has none — a missing acceptance never fails the review.
|
|
24
28
|
* The first keyed ticket of a block (no `review.budget` row for it in the run yet) records the
|
|
25
29
|
* block's review budget (§4.10, B19): the forecast over the block's changed owned files (tier from
|
|
26
30
|
* the path floors, before S1), one `review.budget` row per block; a failure there never fails the
|
|
@@ -34,6 +38,7 @@ import { rmSync } from 'node:fs';
|
|
|
34
38
|
import path from 'node:path';
|
|
35
39
|
import { askJev } from '../decide/jev-client.mjs';
|
|
36
40
|
import { planAndRecord, tierOf } from '../review/budget.mjs';
|
|
41
|
+
import { budgetFor } from '../review/packet.mjs';
|
|
37
42
|
import { reviewFile, rulesRisk } from '../review/engine.mjs';
|
|
38
43
|
import { computeFileSet, ownsFile } from '../gates/scope.mjs';
|
|
39
44
|
import { newFileState, runRound } from '../review/fixloop.mjs';
|
|
@@ -84,14 +89,14 @@ import { assertTicketId } from './ticket.mjs';
|
|
|
84
89
|
* `block-unknown`; a record that cannot be read or parsed throws `run-record-unreadable`, and a
|
|
85
90
|
* block entry without a base `block-base-missing` — a guessed base could let a change escape review.
|
|
86
91
|
* @param {string} runId @param {string} block
|
|
87
|
-
* @returns {Promise<{base: string | null, level: string, owned: string[]}>}
|
|
92
|
+
* @returns {Promise<{base: string | null, level: string, owned: string[], acceptance?: unknown}>}
|
|
88
93
|
*/
|
|
89
94
|
export async function blockEntryFor(runId, block) {
|
|
90
95
|
let record;
|
|
91
96
|
try {
|
|
92
97
|
record = await readRun(runId);
|
|
93
98
|
} catch (err) {
|
|
94
|
-
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [] };
|
|
99
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], acceptance: undefined };
|
|
95
100
|
throw new Error('run-record-unreadable');
|
|
96
101
|
}
|
|
97
102
|
if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
|
|
@@ -100,7 +105,43 @@ export async function blockEntryFor(runId, block) {
|
|
|
100
105
|
const sha = entry?.base_sha;
|
|
101
106
|
if (typeof sha !== 'string' || sha.length === 0) throw new Error('block-base-missing');
|
|
102
107
|
const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
|
|
103
|
-
return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned };
|
|
108
|
+
return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, acceptance: entry.acceptance };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The acceptance line written when the block's clauses cannot be read. */
|
|
112
|
+
export const ACCEPTANCE_UNAVAILABLE = '(acceptance unavailable)';
|
|
113
|
+
const ACCEPTANCE_HEAD = 'Acceptance clauses (what the change must do):';
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The block's acceptance clauses as the packet's facts-slot text: one `- <clause>` line each
|
|
117
|
+
* (whitespace folded, so a clause can never open a packet section), redacted, cut at an eighth of
|
|
118
|
+
* the `full_in` budget (bytes = tokens × 4) with a marker naming how many clauses were left out.
|
|
119
|
+
* Not a list of `{clause}` objects with text ⇒ `(acceptance unavailable)`.
|
|
120
|
+
* @param {unknown} acceptance - the run record's `blocks.<id>.acceptance`
|
|
121
|
+
* @param {Record<string, any> | undefined} cfg
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
export function acceptanceExcerpt(acceptance, cfg) {
|
|
125
|
+
const clauses = Array.isArray(acceptance)
|
|
126
|
+
? acceptance.map((c) => (typeof c?.clause === 'string' ? /** @type {string} */ (redact(c.clause.replace(/\s+/g, ' ').trim())) : '')).filter((c) => c.length > 0)
|
|
127
|
+
: [];
|
|
128
|
+
if (clauses.length === 0) return `${ACCEPTANCE_HEAD}\n${ACCEPTANCE_UNAVAILABLE}`;
|
|
129
|
+
const maxBytes = Math.max(256, Math.floor((budgetFor(cfg, 'full_in') * 4) / 8));
|
|
130
|
+
const lines = [ACCEPTANCE_HEAD];
|
|
131
|
+
let used = Buffer.byteLength(ACCEPTANCE_HEAD) + 1;
|
|
132
|
+
for (let i = 0; i < clauses.length; i += 1) {
|
|
133
|
+
const line = `- ${clauses[i]}`;
|
|
134
|
+
const size = Buffer.byteLength(line) + 1;
|
|
135
|
+
if (used + size > maxBytes) {
|
|
136
|
+
if (lines.length === 1) lines.push(`${Buffer.from(line).subarray(0, Math.max(0, maxBytes - used - 4)).toString('utf8')} …`);
|
|
137
|
+
const left = clauses.length - (lines.length - 1);
|
|
138
|
+
if (left > 0) lines.push(`(acceptance truncated: ${left} more clause(s) over the packet budget)`);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
lines.push(line);
|
|
142
|
+
used += size;
|
|
143
|
+
}
|
|
144
|
+
return lines.join('\n');
|
|
104
145
|
}
|
|
105
146
|
|
|
106
147
|
/**
|
|
@@ -151,11 +192,11 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
151
192
|
try {
|
|
152
193
|
if (!ctx.key) {
|
|
153
194
|
return await reviewFile(
|
|
154
|
-
{ repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, workDir },
|
|
195
|
+
{ repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) },
|
|
155
196
|
{ spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
|
|
156
197
|
);
|
|
157
198
|
}
|
|
158
|
-
return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, key: ctx.key, workDir });
|
|
199
|
+
return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) });
|
|
159
200
|
} finally {
|
|
160
201
|
rmSync(workDir, { recursive: true, force: true });
|
|
161
202
|
}
|
|
@@ -164,10 +205,10 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
164
205
|
/**
|
|
165
206
|
* One fix-loop round for the ticket (see the module doc).
|
|
166
207
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
167
|
-
* @param {{base: string | null, level: string, owned: string[], key: Buffer, workDir: string}} opts
|
|
208
|
+
* @param {{base: string | null, level: string, owned: string[], key: Buffer, workDir: string, factsExcerpt: string}} opts
|
|
168
209
|
* @returns {Promise<ReviewOutcome>}
|
|
169
210
|
*/
|
|
170
|
-
async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir }) {
|
|
211
|
+
async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir, factsExcerpt }) {
|
|
171
212
|
if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
172
213
|
let rows;
|
|
173
214
|
try {
|
|
@@ -232,9 +273,10 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir }) {
|
|
|
232
273
|
workDir,
|
|
233
274
|
base,
|
|
234
275
|
writeRow,
|
|
276
|
+
factsExcerpt,
|
|
235
277
|
...(jev ? { jev } : {}),
|
|
236
278
|
review: async () => {
|
|
237
|
-
engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, workDir }, { spawn: ctx.spawn, writeRow });
|
|
279
|
+
engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow });
|
|
238
280
|
return engineOutcome;
|
|
239
281
|
},
|
|
240
282
|
spawn: async (opts) => {
|