@codedology/code-forge 0.7.7 → 0.8.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 +41 -0
- package/README.md +50 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +157 -1
- package/docs/reference/config.md +26 -8
- package/package.json +1 -1
- package/schema/code-forge.schema.json +81 -4
- package/skill/SKILL.md +6 -6
- package/skill/references/code.md +33 -4
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +4 -2
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +18 -1
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +292 -33
- package/src/cli/context.mjs +175 -0
- package/src/cli/gates.mjs +125 -15
- package/src/cli/ledger.mjs +46 -2
- package/src/cli/proof.mjs +62 -16
- package/src/cli/report.mjs +7 -4
- package/src/cli/review-file.mjs +61 -6
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/local.mjs +2 -1
- package/src/engines/sentinel.mjs +27 -6
- package/src/gates/detect.mjs +21 -8
- package/src/gates/expand.mjs +202 -0
- package/src/gates/owned-count.mjs +66 -0
- package/src/gates/run.mjs +49 -12
- package/src/install/wizard/answers.mjs +34 -5
- package/src/install/wizard/flags.mjs +8 -1
- package/src/install/wizard/project-file.mjs +2 -0
- package/src/install/wizard/run.mjs +6 -0
- package/src/install/wizard/steps.mjs +24 -4
- package/src/install/wizard/summary.mjs +13 -5
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/outside-review.mjs +164 -0
- package/src/ledger/report.mjs +17 -5
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/gate-check.mjs +9 -5
- package/src/review/open-findings.mjs +59 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +103 -39
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +51 -0
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +10 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +36 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/worker/engine.mjs +157 -17
- package/src/worker/loop.mjs +26 -7
- package/src/worker/queue.mjs +36 -10
- package/src/worker/ticket.mjs +46 -6
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coder context pack (block B63a; issue #13 item 5, issue #10 item 2).
|
|
3
|
+
*
|
|
4
|
+
* The top-level `context` key of `.code-forge.yml` names what every coder task should carry:
|
|
5
|
+
*
|
|
6
|
+
* context:
|
|
7
|
+
* rules: [CLAUDE.md, docs/conventions.md] repository-relative files, in order
|
|
8
|
+
* lessons: docs/lessons.md one repository-relative file
|
|
9
|
+
* commands: {build: [...], test: [...], lint: [...], format: [...]} argv arrays, each optional
|
|
10
|
+
* max_lines: 300 the pack's line cap (integer >= 20)
|
|
11
|
+
* require_coder_tests: false B63b: close needs the coder's `TESTS … exit=0` line
|
|
12
|
+
*
|
|
13
|
+
* `assemblePack` turns it into ONE pack for a block, in this order: the header (run, block, level,
|
|
14
|
+
* owned files, test files when the block has them, worktree path and branch when it has one), the
|
|
15
|
+
* commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing), each
|
|
16
|
+
* rules file, then the lessons file — every file section headed by its source path (an empty
|
|
17
|
+
* file: the heading alone). Over `max_lines` the lessons are cut first, then the
|
|
18
|
+
* rules from the last file backwards, each cut section ending in `[cut: N lines dropped]`; the
|
|
19
|
+
* header and the commands are never cut.
|
|
20
|
+
*
|
|
21
|
+
* `reviewDigest` is the ONE builder of the review packets' rules digest (`## project rules`, ≤ 60
|
|
22
|
+
* lines, `packet.mjs`): the rules files' text, then a `### lessons` subheading and the lessons.
|
|
23
|
+
* With no `context` key it is '' — the packet is then byte-identical to before (`(none)`). A file
|
|
24
|
+
* it cannot read throws: the worker answers that ticket `review.unavailable`, `context-unreadable`.
|
|
25
|
+
*
|
|
26
|
+
* Files are always read from the MAIN tree's root (`repoRoot`), never from a block worktree a coder
|
|
27
|
+
* can write to. A path that is absolute, has a `..` segment, or leads out of the repository through
|
|
28
|
+
* a symlink (a broken one too: its target is checked) is refused, and so is a missing file
|
|
29
|
+
* (`ContextError`). A control character (a newline included) in a command token, a rules or
|
|
30
|
+
* lessons path, or a header value (owned or test file, worktree path or branch) is refused, so
|
|
31
|
+
* nothing can forge a line of the pack. Command lines go through `redact`, as the rules and
|
|
32
|
+
* lessons do. The `context` key is not
|
|
33
|
+
* authority: it is not fixed for the run, and `run reload` takes a change to it — except B63b's
|
|
34
|
+
* `context.require_coder_tests`, which tightens `block close`: it is read from the run-start
|
|
35
|
+
* snapshot only (`requireCoderTests`, `src/state/config-snapshot.mjs`) and `run reload` refuses a
|
|
36
|
+
* change to it. It plays no part in the pack.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { lstatSync, readFileSync, readlinkSync, realpathSync } from 'node:fs';
|
|
40
|
+
import path from 'node:path';
|
|
41
|
+
import { estimateTokens } from '../review/packet.mjs';
|
|
42
|
+
import { splitLines } from '../review/context.mjs';
|
|
43
|
+
import { redact } from '../util/redact.mjs';
|
|
44
|
+
|
|
45
|
+
/** The pack's line cap when `context.max_lines` is not set. */
|
|
46
|
+
export const DEFAULT_MAX_LINES = 300;
|
|
47
|
+
|
|
48
|
+
/** The smallest `context.max_lines` accepted. */
|
|
49
|
+
export const MIN_MAX_LINES = 20;
|
|
50
|
+
|
|
51
|
+
/** The `context.commands` keys, in the order the pack lists them. */
|
|
52
|
+
export const COMMAND_KEYS = Object.freeze(['build', 'test', 'lint', 'format']);
|
|
53
|
+
|
|
54
|
+
/** The subheading the lessons get in the review digest. */
|
|
55
|
+
export const DIGEST_LESSONS_HEADING = '### lessons';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A refusal with a stable `code`. Thrown here: `bad-context` (a bad `context` value),
|
|
59
|
+
* `control-character` (in a path or a header value), `outside`, `missing` (ENOENT), `unreadable`
|
|
60
|
+
* (any other lstat or read error, with its code), `not-file`. Thrown by `src/cli/context.mjs`:
|
|
61
|
+
* `no-block`, `bad-config`, `no-context`, `no-run-root`, `write-failed`.
|
|
62
|
+
*/
|
|
63
|
+
export class ContextError extends Error {
|
|
64
|
+
/** @param {string} code @param {string} message */
|
|
65
|
+
constructor(code, message) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = 'ContextError';
|
|
68
|
+
this.code = code;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** C0 and C1 control characters and DEL: a newline among them. */
|
|
73
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
74
|
+
|
|
75
|
+
/** @param {unknown} v @returns {v is Record<string, unknown>} */
|
|
76
|
+
const isMapping = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {object} ContextSettings
|
|
80
|
+
* @property {string[]} rules
|
|
81
|
+
* @property {string | null} lessons
|
|
82
|
+
* @property {Record<string, string[]>} commands - only the keys that are set.
|
|
83
|
+
* @property {number} maxLines
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The `context` key of a config, checked; null when the key is absent.
|
|
88
|
+
* @param {Record<string, any> | null | undefined} cfg
|
|
89
|
+
* @returns {ContextSettings | null}
|
|
90
|
+
* @throws {ContextError} `bad-context`, naming the key.
|
|
91
|
+
*/
|
|
92
|
+
export function contextSettings(cfg) {
|
|
93
|
+
const raw = cfg?.context;
|
|
94
|
+
if (raw === undefined || raw === null) return null;
|
|
95
|
+
if (!isMapping(raw)) throw new ContextError('bad-context', 'context must be a mapping');
|
|
96
|
+
const rules = raw.rules ?? [];
|
|
97
|
+
if (!Array.isArray(rules) || !rules.every((r) => typeof r === 'string' && r.length > 0)) throw new ContextError('bad-context', 'context.rules must be a list of file paths');
|
|
98
|
+
// `CLAUDE.md` and `./CLAUDE.md` are the same file
|
|
99
|
+
if (new Set(rules.map((r) => path.posix.normalize(r))).size !== rules.length) throw new ContextError('bad-context', 'context.rules names a file twice');
|
|
100
|
+
const lessons = raw.lessons ?? null;
|
|
101
|
+
if (lessons !== null && (typeof lessons !== 'string' || lessons.length === 0)) throw new ContextError('bad-context', 'context.lessons must be one file path');
|
|
102
|
+
const commandsRaw = raw.commands ?? {};
|
|
103
|
+
if (!isMapping(commandsRaw)) throw new ContextError('bad-context', 'context.commands must be a mapping');
|
|
104
|
+
/** @type {Record<string, string[]>} */
|
|
105
|
+
const commands = {};
|
|
106
|
+
for (const key of Object.keys(commandsRaw)) {
|
|
107
|
+
if (!COMMAND_KEYS.includes(key)) throw new ContextError('bad-context', `context.commands.${key} is not one of ${COMMAND_KEYS.join(', ')}`);
|
|
108
|
+
}
|
|
109
|
+
for (const key of COMMAND_KEYS) {
|
|
110
|
+
const argv = commandsRaw[key];
|
|
111
|
+
if (argv === undefined || argv === null) continue;
|
|
112
|
+
if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string' && a.length > 0)) throw new ContextError('bad-context', `context.commands.${key} must be an argv list of non-empty strings`);
|
|
113
|
+
if (argv.some((a) => CONTROL.test(a))) throw new ContextError('bad-context', `context.commands.${key} contains a control character`);
|
|
114
|
+
commands[key] = [...argv];
|
|
115
|
+
}
|
|
116
|
+
// B63b: authority, read by `block close` from the run-start snapshot only; checked here so a
|
|
117
|
+
// pack is never built from a config that carries a malformed value
|
|
118
|
+
const requireTests = raw.require_coder_tests ?? false;
|
|
119
|
+
if (typeof requireTests !== 'boolean') throw new ContextError('bad-context', 'context.require_coder_tests must be true or false');
|
|
120
|
+
const maxLines = raw.max_lines ?? DEFAULT_MAX_LINES;
|
|
121
|
+
if (!Number.isInteger(maxLines) || /** @type {number} */ (maxLines) < MIN_MAX_LINES) throw new ContextError('bad-context', `context.max_lines must be an integer >= ${MIN_MAX_LINES}`);
|
|
122
|
+
return { rules: [...rules], lessons: /** @type {string | null} */ (lessons), commands, maxLines: /** @type {number} */ (maxLines) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** How many symlinks one path may pass through before it counts as a loop (as the kernel's ELOOP). */
|
|
126
|
+
const MAX_LINK_HOPS = 40;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `abs` with every symlink on it resolved, as far as its components exist — a symlink whose target
|
|
130
|
+
* is missing (a broken link) is still followed through its `readlink` text, so where it POINTS is
|
|
131
|
+
* what is checked. A component that does not exist ends the walk (the rest is joined as given); a
|
|
132
|
+
* link loop ends it at the link.
|
|
133
|
+
* @param {string} abs - absolute, without `..` segments. @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
function walkLinks(abs) {
|
|
136
|
+
const parts = abs.split(path.sep).filter(Boolean);
|
|
137
|
+
let cur = path.parse(abs).root;
|
|
138
|
+
let hops = 0;
|
|
139
|
+
while (parts.length > 0) {
|
|
140
|
+
const next = path.join(cur, /** @type {string} */ (parts.shift()));
|
|
141
|
+
let st;
|
|
142
|
+
try {
|
|
143
|
+
st = lstatSync(next);
|
|
144
|
+
} catch {
|
|
145
|
+
return path.join(next, ...parts);
|
|
146
|
+
}
|
|
147
|
+
if (!st.isSymbolicLink()) {
|
|
148
|
+
cur = next;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
hops += 1;
|
|
152
|
+
if (hops > MAX_LINK_HOPS) return path.join(next, ...parts);
|
|
153
|
+
// `cur` holds no symlink, so resolving the target's `..` against it is exact
|
|
154
|
+
const target = path.resolve(cur, readlinkSync(next));
|
|
155
|
+
parts.unshift(...target.split(path.sep).filter(Boolean));
|
|
156
|
+
cur = path.parse(target).root;
|
|
157
|
+
}
|
|
158
|
+
return cur;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** @param {string} root @param {string} p @returns {boolean} `p` is `root` or under it. */
|
|
162
|
+
const inside = (root, p) => {
|
|
163
|
+
const rel = path.relative(root, p);
|
|
164
|
+
// only a real `..` segment leaves the root: a file named `..notes.md` is inside
|
|
165
|
+
return !(rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel));
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The real path of the context file `rel` (named by `key`) under `repoRoot`.
|
|
170
|
+
* @param {string} repoRoot - the main tree's root. @param {string} rel @param {string} key
|
|
171
|
+
* @returns {string}
|
|
172
|
+
* @throws {ContextError} `control-character`, `outside`, `missing`, `unreadable`, `not-file`
|
|
173
|
+
*/
|
|
174
|
+
export function resolveContextFile(repoRoot, rel, key) {
|
|
175
|
+
const shown = JSON.stringify(rel);
|
|
176
|
+
if (CONTROL.test(rel)) throw new ContextError('control-character', `${key}: ${shown} contains a control character`);
|
|
177
|
+
if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
|
|
178
|
+
throw new ContextError('outside', `${key}: ${shown} is outside the repository (give a path relative to the repository root)`);
|
|
179
|
+
}
|
|
180
|
+
const root = realpathSync(repoRoot);
|
|
181
|
+
// every symlink on the way is followed — a broken one too, by its target — and the end checked
|
|
182
|
+
const real = walkLinks(path.resolve(root, rel));
|
|
183
|
+
if (!inside(root, real)) throw new ContextError('outside', `${key}: ${shown} leads outside the repository`);
|
|
184
|
+
let st;
|
|
185
|
+
try {
|
|
186
|
+
st = lstatSync(real);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code ?? 'error';
|
|
189
|
+
if (code === 'ENOENT') throw new ContextError('missing', `${key}: ${shown} does not exist`);
|
|
190
|
+
throw new ContextError('unreadable', `${key}: ${shown} cannot be read (${code})`);
|
|
191
|
+
}
|
|
192
|
+
// still a link here: a loop (or more than MAX_LINK_HOPS links) — nothing to read
|
|
193
|
+
if (st.isSymbolicLink()) throw new ContextError('missing', `${key}: ${shown} does not exist`);
|
|
194
|
+
if (!st.isFile()) throw new ContextError('not-file', `${key}: ${shown} is not a file`);
|
|
195
|
+
return real;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {string} repoRoot @param {string} rel @param {string} key
|
|
200
|
+
* @returns {string[]} the file's lines (redacted, trailing whitespace of the file dropped); none
|
|
201
|
+
* for an empty or whitespace-only file.
|
|
202
|
+
* @throws {ContextError} as {@link resolveContextFile}, or `unreadable` when the read fails.
|
|
203
|
+
*/
|
|
204
|
+
function readLines(repoRoot, rel, key) {
|
|
205
|
+
const file = resolveContextFile(repoRoot, rel, key);
|
|
206
|
+
let raw;
|
|
207
|
+
try {
|
|
208
|
+
raw = readFileSync(file, 'utf8');
|
|
209
|
+
} catch (err) {
|
|
210
|
+
throw new ContextError('unreadable', `${key}: ${JSON.stringify(rel)} cannot be read (${/** @type {NodeJS.ErrnoException} */ (err).code ?? 'error'})`);
|
|
211
|
+
}
|
|
212
|
+
const text = /** @type {string} */ (redact(raw)).trimEnd();
|
|
213
|
+
return text === '' ? [] : splitLines(text);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Every rules file and the lessons file, read. A missing or outside file is a refusal.
|
|
218
|
+
* @param {string} repoRoot @param {ContextSettings} settings
|
|
219
|
+
* @returns {{rules: Array<{path: string, lines: string[]}>, lessons: {path: string, lines: string[]} | null}}
|
|
220
|
+
* @throws {ContextError}
|
|
221
|
+
*/
|
|
222
|
+
export function readSources(repoRoot, settings) {
|
|
223
|
+
const rules = settings.rules.map((rel) => ({ path: rel, lines: readLines(repoRoot, rel, 'context.rules') }));
|
|
224
|
+
const lessons = settings.lessons === null ? null : { path: settings.lessons, lines: readLines(repoRoot, settings.lessons, 'context.lessons') };
|
|
225
|
+
return { rules, lessons };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The token as a POSIX shell would take it back: bare when it holds only characters no shell
|
|
230
|
+
* expands, else in single quotes (an embedded `'` as `'\\''`), so a pasted line never expands
|
|
231
|
+
* `$x`, a backtick, a glob or a brace.
|
|
232
|
+
* @param {string} token @returns {string}
|
|
233
|
+
*/
|
|
234
|
+
const argvToken = (token) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(token) ? token : `'${token.replaceAll("'", "'\\''")}'`);
|
|
235
|
+
|
|
236
|
+
/** @param {string[]} argv @returns {string} */
|
|
237
|
+
export const argvText = (argv) => argv.map(argvToken).join(' ');
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* @typedef {object} PackBlock
|
|
241
|
+
* @property {string} run
|
|
242
|
+
* @property {string} block
|
|
243
|
+
* @property {string | null} [level]
|
|
244
|
+
* @property {string[]} [owned]
|
|
245
|
+
* @property {string[] | null} [tests]
|
|
246
|
+
* @property {{path: string, branch: string} | null} [worktree]
|
|
247
|
+
*/
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Build the pack text for a block (pure over what it is given).
|
|
251
|
+
* @param {{settings: ContextSettings, sources: ReturnType<typeof readSources>, block: PackBlock}} opts
|
|
252
|
+
* @returns {{text: string, lines: number, cut: number}} `cut`: the lines dropped.
|
|
253
|
+
* @throws {ContextError} `control-character` when a header value holds one.
|
|
254
|
+
*/
|
|
255
|
+
export function assemblePack({ settings, sources, block }) {
|
|
256
|
+
/** @type {Array<[string, unknown]>} */
|
|
257
|
+
const values = [['level', block.level], ...(block.owned ?? []).map((f) => /** @type {[string, unknown]} */ (['owned file', f])), ...(block.tests ?? []).map((f) => /** @type {[string, unknown]} */ (['test file', f]))];
|
|
258
|
+
if (block.worktree) values.push(['worktree path', block.worktree.path], ['worktree branch', block.worktree.branch]);
|
|
259
|
+
for (const [label, v] of values) {
|
|
260
|
+
if (typeof v === 'string' && CONTROL.test(v)) throw new ContextError('control-character', `block ${block.block}: ${label} ${JSON.stringify(v)} contains a control character`);
|
|
261
|
+
}
|
|
262
|
+
const header = ['# code-forge context pack', '## block', `run: ${block.run}`, `block: ${block.block}`, `level: ${block.level ?? '(unknown)'}`, 'owned files:'];
|
|
263
|
+
for (const f of block.owned ?? []) header.push(`- ${f}`);
|
|
264
|
+
if (Array.isArray(block.tests) && block.tests.length > 0) {
|
|
265
|
+
header.push('test files:');
|
|
266
|
+
for (const f of block.tests) header.push(`- ${f}`);
|
|
267
|
+
}
|
|
268
|
+
if (block.worktree) header.push(`worktree: ${block.worktree.path}`, `branch: ${block.worktree.branch}`);
|
|
269
|
+
header.push('## commands (never run by code-forge)');
|
|
270
|
+
const keys = COMMAND_KEYS.filter((k) => Object.hasOwn(settings.commands, k));
|
|
271
|
+
if (keys.length === 0) header.push('(none)');
|
|
272
|
+
// a command line can name a secret (a token in an argv): it is redacted as rules and lessons are
|
|
273
|
+
for (const k of keys) header.push(`${k}: ${redact(argvText(settings.commands[k]))}`);
|
|
274
|
+
|
|
275
|
+
/** @type {Array<{heading: string, body: string[], dropped: number}>} */
|
|
276
|
+
const sections = sources.rules.map((r) => ({ heading: `## rules: ${r.path}`, body: [...r.lines], dropped: 0 }));
|
|
277
|
+
if (sources.lessons) sections.push({ heading: `## lessons: ${sources.lessons.path}`, body: [...sources.lessons.lines], dropped: 0 });
|
|
278
|
+
|
|
279
|
+
const total = () => header.length + sections.reduce((n, s) => n + 1 + s.body.length + (s.dropped > 0 ? 1 : 0), 0);
|
|
280
|
+
// cut order: the lessons first, then the rules from the last file backwards
|
|
281
|
+
for (const s of [...sections].reverse()) {
|
|
282
|
+
let excess = total() - settings.maxLines;
|
|
283
|
+
if (excess <= 0) break;
|
|
284
|
+
// the marker takes one line: a section is only cut when that leaves it shorter
|
|
285
|
+
if (s.body.length < 2) continue;
|
|
286
|
+
const drop = Math.min(s.body.length, excess + 1);
|
|
287
|
+
s.body = s.body.slice(0, s.body.length - drop);
|
|
288
|
+
s.dropped = drop;
|
|
289
|
+
}
|
|
290
|
+
const out = [...header];
|
|
291
|
+
for (const s of sections) {
|
|
292
|
+
out.push(s.heading, ...s.body);
|
|
293
|
+
if (s.dropped > 0) out.push(`[cut: ${s.dropped} lines dropped]`);
|
|
294
|
+
}
|
|
295
|
+
return { text: `${out.join('\n')}\n`, lines: out.length, cut: sections.reduce((n, s) => n + s.dropped, 0) };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The review packets' rules digest (`## project rules`), built from the MAIN tree each time a
|
|
300
|
+
* packet is built: the rules files' lines in order, then `### lessons` and the lessons' lines.
|
|
301
|
+
* '' when the config has no `context` key (the packet stays as it was), or names no rules and no
|
|
302
|
+
* lessons. `packet.mjs` keeps the first 60 lines and trims from the end over budget, rules and
|
|
303
|
+
* lessons together.
|
|
304
|
+
* @param {{repoRoot: string, cfg: Record<string, any> | null | undefined}} opts
|
|
305
|
+
* @returns {string}
|
|
306
|
+
* @throws {ContextError} a bad `context` value, or a rules or lessons file that is missing,
|
|
307
|
+
* outside the repository or unreadable — the worker makes that ticket `review.unavailable`
|
|
308
|
+
* with `reason: context-unreadable` (no review runs without the rules it was promised).
|
|
309
|
+
*/
|
|
310
|
+
export function reviewDigest({ repoRoot, cfg }) {
|
|
311
|
+
const settings = contextSettings(cfg);
|
|
312
|
+
if (settings === null) return '';
|
|
313
|
+
const out = settings.rules.flatMap((rel) => readLines(repoRoot, rel, 'context.rules'));
|
|
314
|
+
if (settings.lessons !== null) out.push(DIGEST_LESSONS_HEADING, ...readLines(repoRoot, settings.lessons, 'context.lessons'));
|
|
315
|
+
return out.join('\n');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** @param {string} text @returns {number} the pack's estimated tokens (the review packets' bytes / 4). */
|
|
319
|
+
export const packTokens = (text) => estimateTokens(text);
|
package/src/doctor/local.mjs
CHANGED
|
@@ -104,7 +104,8 @@ export async function checkCommands(cfg, pathEnv) {
|
|
|
104
104
|
rows.push(row('solo', 'INFO', 'solo', 'UNVERIFIED (no Solo probe in this version)'));
|
|
105
105
|
const gates = cfg?.gates && typeof cfg.gates === 'object' ? cfg.gates : {};
|
|
106
106
|
let any = false;
|
|
107
|
-
for (const [name,
|
|
107
|
+
for (const [name, command] of Object.entries(gates)) {
|
|
108
|
+
const argv = command && !Array.isArray(command) && typeof command === 'object' ? command.argv : command; // B60: {argv, env}
|
|
108
109
|
if (!Array.isArray(argv) || argv.length === 0) continue;
|
|
109
110
|
any = true;
|
|
110
111
|
const found = await commandOnPath(String(argv[0]), { pathEnv });
|
package/src/engines/sentinel.mjs
CHANGED
|
@@ -11,6 +11,16 @@
|
|
|
11
11
|
* trimmed line (`^...$`), so a coder quoting the sentinel text inside ordinary prose ("I will not
|
|
12
12
|
* print ===BLOCK B4 COMPLETE=== yet") never matches — only a line that IS exactly the sentinel does.
|
|
13
13
|
*
|
|
14
|
+
* **TESTS.** B63b: a coder proves a green test run with a line that IS exactly `TESTS <command>
|
|
15
|
+
* exit=<n>` (matched after trimming; `<command>` non-empty free text, `<n>` a decimal 0–255 with no
|
|
16
|
+
* leading zeros — a non-numeric, signed or out-of-range value does not count) printed before its
|
|
17
|
+
* sentinel. On a returned sentinel, `tests: {command, exit}` is the LAST such line in that
|
|
18
|
+
* sentinel's own segment: after the previous sentinel line of ANY block (COMPLETE or FAILED) and
|
|
19
|
+
* before this one. A TESTS line before an earlier attempt's sentinel, or before another block's
|
|
20
|
+
* sentinel in a shared log, never carries over, and one after the sentinel is ignored; with none
|
|
21
|
+
* the result has no `tests` key at all. Like the sentinel, the pattern is anchored to the whole
|
|
22
|
+
* trimmed line, so a TESTS line quoted in prose never matches.
|
|
23
|
+
*
|
|
14
24
|
* **ACK.** Before any tool call, a coder replies `ACK <sha8> lines=<n>` to the wire receipt it was
|
|
15
25
|
* sent — the sha of the brief file, truncated to 8 hex characters (matched case-insensitively;
|
|
16
26
|
* compared lowercased), and the brief's line count. `parseAck` returns the LAST matching line (fix
|
|
@@ -23,17 +33,21 @@
|
|
|
23
33
|
* look like an ACK (an echoed instruction, a quoted example) being mistaken for the real one.
|
|
24
34
|
*/
|
|
25
35
|
|
|
26
|
-
/** @typedef {{
|
|
36
|
+
/** @typedef {{command: string, exit: number}} TestsLine */
|
|
37
|
+
/** @typedef {({status: "complete", blockId: string} | {status: "failed", blockId: string, reason: string}) & {tests?: TestsLine}} SentinelResult */
|
|
27
38
|
|
|
28
39
|
const COMPLETE_RE = /^===BLOCK\s+(\S+)\s+COMPLETE===$/;
|
|
29
40
|
const FAILED_RE = /^===BLOCK\s+(\S+)\s+FAILED:\s*(.*?)\s*===$/;
|
|
41
|
+
/** B63b: `TESTS <command> exit=<n>` as a whole trimmed line (no leading zeros); 0–255 is checked after the match. */
|
|
42
|
+
const TESTS_RE = /^TESTS\s+(\S.*?)\s+exit=(0|[1-9]\d{0,2})$/;
|
|
30
43
|
|
|
31
44
|
/**
|
|
32
45
|
* @param {string} output - the full captured transcript (subprocess stdout, or a Solo
|
|
33
46
|
* `get_process_output` join).
|
|
34
47
|
* @param {string} [blockId] - when given, only sentinel lines for THIS block id count — a shared
|
|
35
48
|
* pane/log can interleave another block's lines.
|
|
36
|
-
* @returns {SentinelResult | null} the LAST matching sentinel line, or `null` if none is present
|
|
49
|
+
* @returns {SentinelResult | null} the LAST matching sentinel line, or `null` if none is present;
|
|
50
|
+
* with `tests` when a `TESTS` line comes before it in its segment (B63b).
|
|
37
51
|
*/
|
|
38
52
|
export function parseSentinel(output, blockId) {
|
|
39
53
|
if (typeof output !== 'string') {
|
|
@@ -41,17 +55,24 @@ export function parseSentinel(output, blockId) {
|
|
|
41
55
|
}
|
|
42
56
|
/** @type {SentinelResult | null} */
|
|
43
57
|
let last = null;
|
|
58
|
+
/** @type {TestsLine | null} */
|
|
59
|
+
let tests = null;
|
|
44
60
|
for (const rawLine of output.split('\n')) {
|
|
45
61
|
const line = rawLine.trim();
|
|
46
62
|
const complete = COMPLETE_RE.exec(line);
|
|
47
|
-
if (complete
|
|
48
|
-
last = { status: 'complete', blockId: complete[1] };
|
|
63
|
+
if (complete) {
|
|
64
|
+
if (!blockId || complete[1] === blockId) last = { status: 'complete', blockId: complete[1], ...(tests ? { tests } : {}) };
|
|
65
|
+
tests = null; // B63b: any sentinel line, of any block, ends the segment
|
|
49
66
|
continue;
|
|
50
67
|
}
|
|
51
68
|
const failed = FAILED_RE.exec(line);
|
|
52
|
-
if (failed
|
|
53
|
-
last = { status: 'failed', blockId: failed[1], reason: failed[2] };
|
|
69
|
+
if (failed) {
|
|
70
|
+
if (!blockId || failed[1] === blockId) last = { status: 'failed', blockId: failed[1], reason: failed[2], ...(tests ? { tests } : {}) };
|
|
71
|
+
tests = null;
|
|
72
|
+
continue;
|
|
54
73
|
}
|
|
74
|
+
const run = TESTS_RE.exec(line);
|
|
75
|
+
if (run && Number(run[2]) <= 255) tests = { command: run[1], exit: Number(run[2]) };
|
|
55
76
|
}
|
|
56
77
|
return last;
|
|
57
78
|
}
|
package/src/gates/detect.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* arrays, or `null` when the stack has no tool for that gate. Detection is pure filesystem
|
|
5
5
|
* evidence (`fs.existsSync`/`readFileSync`) — no `git`, no child process, no network.
|
|
6
6
|
*
|
|
7
|
-
* Evidence is checked in the table's own order (PHP/Pest, then `package.json` scripts, Cargo,
|
|
7
|
+
* Evidence is checked in the table's own order (PHP/Pest or PHPUnit, then `package.json` scripts, Cargo,
|
|
8
8
|
* pyproject, go.mod); the first stack whose marker file exists wins. A project with no marker
|
|
9
9
|
* file at all is `unknown`: every gate is `null`, and `code` (a later block) refuses to run a
|
|
10
10
|
* block whose `gates.test` is null — a block without a test gate cannot produce evidence.
|
|
@@ -18,10 +18,15 @@ import path from 'node:path';
|
|
|
18
18
|
* (`gofmt -l`) exit 0 regardless of whether anything is unformatted and instead print the
|
|
19
19
|
* offending file names to stdout — for those, the descriptor carries `failOnStdout: true` so
|
|
20
20
|
* `run.mjs` treats non-empty stdout as red too, since a bare exit-code check can never fail.
|
|
21
|
-
*
|
|
21
|
+
* B60: a configured gate may be `{argv, env}` — `env` adds variables to the gate's environment
|
|
22
|
+
* (the values are never printed, `run.mjs`).
|
|
23
|
+
* @typedef {string[] | {argv: string[], failOnStdout?: true, env?: Record<string, string>}} GateCommand
|
|
22
24
|
* @typedef {GateCommand | null} Gate
|
|
23
25
|
*/
|
|
24
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* `stack` is one of `php-pest`, `php-phpunit`, `node`, `rust`, `python`, `go` or `unknown`.
|
|
28
|
+
* @typedef {{stack: string, test: Gate, lint: Gate, types: Gate, format: Gate}} DetectedGates
|
|
29
|
+
*/
|
|
25
30
|
|
|
26
31
|
/** @param {string} root @param {string} rel @returns {boolean} */
|
|
27
32
|
function has(root, rel) {
|
|
@@ -49,15 +54,23 @@ function readText(root, rel) {
|
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
|
|
52
|
-
// ── PHP
|
|
57
|
+
// ── PHP (Pest, PHPUnit) ─────────────────────────────────────────────────────
|
|
53
58
|
|
|
54
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* `composer.json` plus an installed runner: `vendor/bin/pest` is `php-pest` (checked first, so a
|
|
61
|
+
* project with both binaries is Pest), else `vendor/bin/phpunit` is `php-phpunit` (B61). A Laravel
|
|
62
|
+
* app (`artisan` present) runs either through `php artisan test --compact`; without `artisan` the
|
|
63
|
+
* runner's own binary runs. `types` and `format` do not depend on the runner.
|
|
64
|
+
* @param {string} root @returns {DetectedGates | null}
|
|
65
|
+
*/
|
|
55
66
|
function detectPhp(root) {
|
|
56
|
-
if (!has(root, 'composer.json')
|
|
57
|
-
const
|
|
67
|
+
if (!has(root, 'composer.json')) return null;
|
|
68
|
+
const runner = has(root, 'vendor/bin/pest') ? 'pest' : has(root, 'vendor/bin/phpunit') ? 'phpunit' : null;
|
|
69
|
+
if (runner === null) return null;
|
|
70
|
+
const test = has(root, 'artisan') ? ['php', 'artisan', 'test', '--compact'] : [`vendor/bin/${runner}`];
|
|
58
71
|
const types = has(root, 'phpstan.neon') || has(root, 'phpstan.neon.dist') ? ['vendor/bin/phpstan'] : null;
|
|
59
72
|
const format = has(root, 'pint.json') || has(root, 'vendor/bin/pint') ? ['vendor/bin/pint', '--test'] : null;
|
|
60
|
-
return { stack:
|
|
73
|
+
return { stack: `php-${runner}`, test, lint: null, types, format };
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
// ── Node (package.json scripts) ────────────────────────────────────────────
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B60 (issue #13 item 3): run only the block's related tests, and give a gate its own environment.
|
|
3
|
+
* Pure — no filesystem, no child process. A gate argv item that is EXACTLY {@link TEST_FILES_TOKEN}
|
|
4
|
+
* (one whole item, never a substring, never a shell string) stands for the block's test files:
|
|
5
|
+
*
|
|
6
|
+
* - the block's `test_files` (`block open --tests`), else its owned entries that are concrete
|
|
7
|
+
* files (no glob, no trailing `/`) and look like tests ({@link isTestPath});
|
|
8
|
+
* - when the block owns more FILES than `gates.full_suite_threshold_files` (default 10; globs and
|
|
9
|
+
* directories count the tracked and new (not ignored) files they cover — the caller counts them, gates/owned-count.mjs),
|
|
10
|
+
* or no test file is known (also: a gate run with no block), the item is DROPPED and the gate
|
|
11
|
+
* runs the full suite.
|
|
12
|
+
*
|
|
13
|
+
* {@link testScope} decides which, once per gate run; {@link expandArgv} applies it to one argv.
|
|
14
|
+
* A gate without the token is never touched.
|
|
15
|
+
*
|
|
16
|
+
* A gate given as `{argv, env}` runs with `env` added to the inherited environment. Gate env is
|
|
17
|
+
* NOT a secret store: code-forge prints and records only the variable NAMES ({@link envKeys},
|
|
18
|
+
* {@link printableGates}), never a value. The child's own output is masked only for values of
|
|
19
|
+
* {@link MASK_MIN_LENGTH} or more characters ({@link maskableEnvValues}) — a short value such as
|
|
20
|
+
* `CI=1` or `MODE=test` would otherwise mask unrelated text (`npm test`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { isExactEntry } from '../state/registry.mjs';
|
|
24
|
+
import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
25
|
+
|
|
26
|
+
export { TEST_FILES_TOKEN };
|
|
27
|
+
|
|
28
|
+
/** `gates.full_suite_threshold_files` when the config leaves it out (the schema default). */
|
|
29
|
+
export const DEFAULT_FULL_SUITE_THRESHOLD = 10;
|
|
30
|
+
|
|
31
|
+
/** The shortest env value masked in a gate's own output. */
|
|
32
|
+
export const MASK_MIN_LENGTH = 8;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The threshold a configured value gives: the value itself when it is an integer >= 0, else the
|
|
36
|
+
* default.
|
|
37
|
+
* @param {unknown} value - `gates.full_suite_threshold_files` as the config holds it.
|
|
38
|
+
* @returns {number}
|
|
39
|
+
*/
|
|
40
|
+
export function thresholdOf(value) {
|
|
41
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : DEFAULT_FULL_SUITE_THRESHOLD;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Directory names that hold tests. */
|
|
45
|
+
const TEST_DIRS = new Set(['test', 'tests', '__tests__', 'spec']);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The shared test-path predicate: a directory segment named `test`, `tests`, `__tests__` or
|
|
49
|
+
* `spec`, or a file name ending `.test.<ext>` / `.spec.<ext>` (`a.test.mjs`, `a.spec.ts`),
|
|
50
|
+
* `Test.php` (`UserTest.php`), `_test.go`, or a Python `test_*.py`.
|
|
51
|
+
* @param {string} p - a repo-relative POSIX path (an owned entry; a glob is judged by its text).
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
export function isTestPath(p) {
|
|
55
|
+
if (typeof p !== 'string' || p.length === 0) return false;
|
|
56
|
+
const segments = p.split('/');
|
|
57
|
+
const name = segments[segments.length - 1];
|
|
58
|
+
if (segments.slice(0, -1).some((s) => TEST_DIRS.has(s))) return true;
|
|
59
|
+
return /\.(test|spec)\.[^/]+$/.test(name) || name.endsWith('Test.php') || name.endsWith('_test.go') || /^test_.*\.py$/.test(name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* An owned entry that names one file: no glob character (`*`, `?`, `{`) and no trailing `/`.
|
|
64
|
+
* @param {string} p @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
const isConcreteFile = (p) => typeof p === 'string' && p.length > 0 && isExactEntry(p) && !p.endsWith('/');
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @typedef {{scope: 'related', files: string[]} | {scope: 'full', reason: 'threshold' | 'no_tests'}} TestScope
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/** A gate run with no block: no test file is known. */
|
|
73
|
+
export const NO_BLOCK_SCOPE = Object.freeze(/** @type {TestScope} */ ({ scope: 'full', reason: 'no_tests' }));
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Which tests a `{test_files}` gate runs for one block.
|
|
77
|
+
* @param {{testFiles?: ReadonlyArray<string> | null, owned: ReadonlyArray<string>, ownedFiles?: number, threshold?: number}} opts
|
|
78
|
+
* `testFiles` - the block's `test_files` (`block open --tests`), or none; `owned` - its owned
|
|
79
|
+
* entries; `ownedFiles` - how many files those entries cover (`ownedFileCount`,
|
|
80
|
+
* gates/owned-count.mjs; when not given, one per entry); `threshold` -
|
|
81
|
+
* `gates.full_suite_threshold_files`, compared with `ownedFiles`.
|
|
82
|
+
* @returns {TestScope}
|
|
83
|
+
*/
|
|
84
|
+
export function testScope({ testFiles, owned, ownedFiles = owned.length, threshold = DEFAULT_FULL_SUITE_THRESHOLD }) {
|
|
85
|
+
if (ownedFiles > threshold) return { scope: 'full', reason: 'threshold' };
|
|
86
|
+
const files = testFiles && testFiles.length > 0 ? [...testFiles] : owned.filter((p) => isConcreteFile(p) && isTestPath(p));
|
|
87
|
+
if (files.length === 0) return { scope: 'full', reason: 'no_tests' };
|
|
88
|
+
return { scope: 'related', files };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The argv of one gate command (an argv array or `{argv, ...}`), or null.
|
|
93
|
+
* @param {unknown} command @returns {ReadonlyArray<unknown> | null}
|
|
94
|
+
*/
|
|
95
|
+
function argvOf(command) {
|
|
96
|
+
if (Array.isArray(command)) return command;
|
|
97
|
+
if (command && typeof command === 'object' && Array.isArray(/** @type {{argv?: unknown}} */ (command).argv)) return /** @type {{argv: unknown[]}} */ (command).argv;
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Does any gate in `gates` have an argv item that is exactly {@link TEST_FILES_TOKEN} (a whole
|
|
103
|
+
* item — the same test {@link expandArgv} applies)? Only then is a block's test scope worked out;
|
|
104
|
+
* a run of plain gates never reads it.
|
|
105
|
+
* @param {Record<string, unknown> | null | undefined} gates @returns {boolean}
|
|
106
|
+
*/
|
|
107
|
+
export function hasTestFilesItem(gates) {
|
|
108
|
+
return Object.values(gates ?? {}).some((command) => argvOf(command)?.includes(TEST_FILES_TOKEN) === true);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One gate argv with every item that is exactly {@link TEST_FILES_TOKEN} replaced by the related
|
|
113
|
+
* test files, or dropped for a full run. Always a new array; `scoped` is false (and the items
|
|
114
|
+
* unchanged) when the gate has no such item.
|
|
115
|
+
* @param {ReadonlyArray<string>} argv @param {TestScope} scope
|
|
116
|
+
* @returns {{argv: string[], scoped: boolean}}
|
|
117
|
+
* @throws {TypeError} a test path that starts with `-` (it would be read as an option; `block
|
|
118
|
+
* open --tests` refuses one too).
|
|
119
|
+
*/
|
|
120
|
+
export function expandArgv(argv, scope) {
|
|
121
|
+
if (!argv.includes(TEST_FILES_TOKEN)) return { argv: [...argv], scoped: false };
|
|
122
|
+
const files = scope.scope === 'related' ? scope.files : [];
|
|
123
|
+
const dashed = files.find((f) => f.startsWith('-'));
|
|
124
|
+
if (dashed !== undefined) throw new TypeError(`expandArgv: test path ${JSON.stringify(dashed)} starts with "-"`);
|
|
125
|
+
return { argv: argv.flatMap((item) => (item === TEST_FILES_TOKEN ? files : [item])), scoped: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The fields a scoped gate's result and ledger row carry: `scope`, plus `test_count` when related
|
|
130
|
+
* or `reason` when full.
|
|
131
|
+
* @param {TestScope} scope
|
|
132
|
+
* @returns {{scope: 'related', test_count: number} | {scope: 'full', reason: 'threshold' | 'no_tests'}}
|
|
133
|
+
*/
|
|
134
|
+
export function scopeFields(scope) {
|
|
135
|
+
return scope.scope === 'related' ? { scope: 'related', test_count: scope.files.length } : { scope: 'full', reason: scope.reason };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A gate command's env as a plain `NAME: value` map, or null when it has none: an argv array, an
|
|
140
|
+
* object without `env`, or an empty `env: {}` (treated as no env at all).
|
|
141
|
+
* @param {unknown} command
|
|
142
|
+
* @returns {Record<string, string> | null}
|
|
143
|
+
*/
|
|
144
|
+
export function envOf(command) {
|
|
145
|
+
if (!command || typeof command !== 'object' || Array.isArray(command)) return null;
|
|
146
|
+
const env = /** @type {{env?: unknown}} */ (command).env;
|
|
147
|
+
if (!env || typeof env !== 'object' || Array.isArray(env) || Object.keys(env).length === 0) return null;
|
|
148
|
+
return /** @type {Record<string, string>} */ (env);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The sorted variable NAMES of a gate command's env, or null when it has none.
|
|
153
|
+
* @param {unknown} command @returns {string[] | null}
|
|
154
|
+
*/
|
|
155
|
+
export function envKeys(command) {
|
|
156
|
+
const env = envOf(command);
|
|
157
|
+
return env ? Object.keys(env).sort() : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Every non-empty string value of every gate's `env`, across every entry of `gates`.
|
|
162
|
+
* @param {Record<string, unknown> | null | undefined} gates
|
|
163
|
+
* @returns {string[]}
|
|
164
|
+
*/
|
|
165
|
+
export function envValues(gates) {
|
|
166
|
+
/** @type {string[]} */
|
|
167
|
+
const values = [];
|
|
168
|
+
for (const command of Object.values(gates ?? {})) {
|
|
169
|
+
for (const value of Object.values(envOf(command) ?? {})) if (typeof value === 'string' && value.length > 0) values.push(value);
|
|
170
|
+
}
|
|
171
|
+
return values;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The env values a gate's own output is masked for: those of {@link MASK_MIN_LENGTH} or more
|
|
176
|
+
* characters. Shorter ones are not masked (they would hide unrelated text).
|
|
177
|
+
* @param {Record<string, unknown> | null | undefined} gates @returns {string[]}
|
|
178
|
+
*/
|
|
179
|
+
export function maskableEnvValues(gates) {
|
|
180
|
+
return envValues(gates).filter((value) => value.length >= MASK_MIN_LENGTH);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A copy of `gates` safe to print: each command given with `env` keeps its other fields and shows
|
|
185
|
+
* `env_keys` (the names) in place of `env`; an empty env is left out. Nothing else changes.
|
|
186
|
+
* @template {Record<string, any>} T
|
|
187
|
+
* @param {T} gates @returns {T}
|
|
188
|
+
*/
|
|
189
|
+
export function printableGates(gates) {
|
|
190
|
+
/** @type {Record<string, unknown>} */
|
|
191
|
+
const out = {};
|
|
192
|
+
for (const [key, command] of Object.entries(gates ?? {})) {
|
|
193
|
+
if (!command || typeof command !== 'object' || Array.isArray(command) || !Object.hasOwn(command, 'env')) {
|
|
194
|
+
out[key] = command;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const { env: _env, ...rest } = /** @type {Record<string, unknown>} */ (command);
|
|
198
|
+
const keys = envKeys(command);
|
|
199
|
+
out[key] = keys ? { ...rest, env_keys: keys } : rest;
|
|
200
|
+
}
|
|
201
|
+
return /** @type {T} */ (out);
|
|
202
|
+
}
|