@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.
@@ -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
+ }
@@ -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) => {