@codedology/code-forge 0.7.7 → 0.9.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 +97 -0
- package/README.md +68 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +390 -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 +45 -7
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +25 -5
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +19 -2
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +427 -41
- 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 +324 -18
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +91 -3
- 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/ledger/write.mjs +7 -1
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +36 -2
- package/src/review/fixloop.mjs +25 -15
- package/src/review/gate-check.mjs +384 -83
- package/src/review/open-findings.mjs +59 -0
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -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 +46 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +230 -24
- package/src/worker/loop.mjs +29 -8
- package/src/worker/queue.mjs +296 -15
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +46 -6
package/src/doctor/local.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Doctor rows that need no provider CLI (plan §2.3; block B13b): config, harness links, Jev key
|
|
3
3
|
* resolution (the three `--quick` rows), `code-forge` on PATH, Solo, gate commands, the live Jev
|
|
4
|
-
* call, ledger writability and the `
|
|
4
|
+
* call, ledger writability, the `tmp` row and (B69) the `review state` row. Every check returns
|
|
5
|
+
* rows; none throws.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { constants, readdirSync } from 'node:fs';
|
|
@@ -17,10 +18,16 @@ import { missingTools } from '../install/tools.mjs';
|
|
|
17
18
|
import { OP_MESSAGES } from '../keys/onepassword.mjs';
|
|
18
19
|
import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
19
20
|
import { ledgerDir } from '../ledger/paths.mjs';
|
|
21
|
+
import { readAllRows } from '../ledger/write.mjs';
|
|
20
22
|
import { DEFAULT_PROVIDER_CONCURRENCY, providerLimit } from '../session/spawn.mjs';
|
|
23
|
+
import { snapshotFor } from '../state/config-snapshot.mjs';
|
|
24
|
+
import { assertRunId, runsDir } from '../state/paths.mjs';
|
|
25
|
+
import { readRun } from '../state/run.mjs';
|
|
26
|
+
import { loadKey } from '../state/signer.mjs';
|
|
21
27
|
import { listEntries } from '../util/reaper.mjs';
|
|
22
|
-
import { ownerState, tmpBase } from '../util/tmp.mjs';
|
|
28
|
+
import { ownerState, runRootPath, tmpBase } from '../util/tmp.mjs';
|
|
23
29
|
import { parallelTickets } from '../worker/loop.mjs';
|
|
30
|
+
import { missingStateFiles } from '../worker/review-state.mjs';
|
|
24
31
|
import { row } from './rows.mjs';
|
|
25
32
|
|
|
26
33
|
/** @typedef {import('./rows.mjs').Row} Row */
|
|
@@ -104,7 +111,8 @@ export async function checkCommands(cfg, pathEnv) {
|
|
|
104
111
|
rows.push(row('solo', 'INFO', 'solo', 'UNVERIFIED (no Solo probe in this version)'));
|
|
105
112
|
const gates = cfg?.gates && typeof cfg.gates === 'object' ? cfg.gates : {};
|
|
106
113
|
let any = false;
|
|
107
|
-
for (const [name,
|
|
114
|
+
for (const [name, command] of Object.entries(gates)) {
|
|
115
|
+
const argv = command && !Array.isArray(command) && typeof command === 'object' ? command.argv : command; // B60: {argv, env}
|
|
108
116
|
if (!Array.isArray(argv) || argv.length === 0) continue;
|
|
109
117
|
any = true;
|
|
110
118
|
const found = await commandOnPath(String(argv[0]), { pathEnv });
|
|
@@ -191,6 +199,86 @@ export function checkTmp(cfg) {
|
|
|
191
199
|
return [row('tmp', roots + pids > 0 ? 'WARN' : 'OK', 'tmp', roots + pids > 0 ? `${detail} (the next code-forge run start removes them)` : detail)];
|
|
192
200
|
}
|
|
193
201
|
|
|
202
|
+
/** B69: the most missing state files the `review state` row names. */
|
|
203
|
+
export const LISTED_MAX = 10;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* B69: one WARN row per ACTIVE run (its record's `status` is `active`, as `run end` and the worker
|
|
207
|
+
* decide it) whose ledger has `review.state` anchors whose latest state file is missing — neither
|
|
208
|
+
* at `<code-forge home>/runs/<run>/review-state/` nor at the old temp-root path. The row counts the
|
|
209
|
+
* files the ledger can rebuild (the latest anchor carries `state_body`) and the lost ones (no body:
|
|
210
|
+
* the next ticket is refused `review-state-lost`; the owner needs `code-forge block reopen`), and
|
|
211
|
+
* names the first {@link LISTED_MAX} blocks and files (then `… and K more`). No row when nothing
|
|
212
|
+
* is missing. When the old temp location cannot be named (`tmp.root` not absolute), nothing is
|
|
213
|
+
* counted and the row says so; when an anchor of the run does not verify, the row says the review
|
|
214
|
+
* state was not checked. Each project's ledger is read once. Never throws: a run whose record, key
|
|
215
|
+
* or ledger cannot be read is skipped. No file content is read or printed.
|
|
216
|
+
* @param {Record<string, any> | null} cfg
|
|
217
|
+
* @returns {Promise<Row[]>}
|
|
218
|
+
*/
|
|
219
|
+
export async function checkReviewState(cfg) {
|
|
220
|
+
let names = [];
|
|
221
|
+
try {
|
|
222
|
+
names = readdirSync(runsDir()).filter((n) => n.endsWith('.json'));
|
|
223
|
+
} catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
/** @type {Row[]} */
|
|
227
|
+
const rows = [];
|
|
228
|
+
/** @type {Map<string, Array<Record<string, any>>>} the ledger rows per project, read once */
|
|
229
|
+
const ledgers = new Map();
|
|
230
|
+
for (const name of names.sort()) {
|
|
231
|
+
const runId = name.slice(0, -'.json'.length);
|
|
232
|
+
try {
|
|
233
|
+
assertRunId(runId);
|
|
234
|
+
const record = await readRun(runId);
|
|
235
|
+
if (record?.status !== 'active' || typeof record.project !== 'string') continue;
|
|
236
|
+
const key = await loadKey(runId);
|
|
237
|
+
let ledger = ledgers.get(record.project);
|
|
238
|
+
if (ledger === undefined) {
|
|
239
|
+
ledger = await readAllRows(record.project);
|
|
240
|
+
ledgers.set(record.project, ledger);
|
|
241
|
+
}
|
|
242
|
+
const atNew = missingStateFiles({ runId, key, rows: ledger });
|
|
243
|
+
if (atNew === null) {
|
|
244
|
+
rows.push(row('review-state', 'WARN', 'review state', `run ${runId}: review.state anchors do not verify; review state not checked`));
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
let root;
|
|
248
|
+
try {
|
|
249
|
+
root = snapshotFor(record, '')?.config?.tmp?.root;
|
|
250
|
+
} catch {
|
|
251
|
+
root = undefined;
|
|
252
|
+
}
|
|
253
|
+
if (typeof root !== 'string') root = typeof cfg?.tmp?.root === 'string' ? cfg.tmp.root : undefined;
|
|
254
|
+
let runRootDir;
|
|
255
|
+
try {
|
|
256
|
+
runRootDir = runRootPath(runId, root === undefined ? {} : { root });
|
|
257
|
+
} catch {
|
|
258
|
+
runRootDir = undefined;
|
|
259
|
+
}
|
|
260
|
+
if (runRootDir === undefined) {
|
|
261
|
+
// the old place cannot be named: nothing is counted as missing, the row says why
|
|
262
|
+
if (atNew.length > 0) {
|
|
263
|
+
rows.push(row('review-state', 'WARN', 'review state', `run ${runId}: the old temp location could not be checked (tmp.root is not an absolute path); review state files are not counted`));
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const missing = missingStateFiles({ runId, key, rows: ledger, runRootDir });
|
|
268
|
+
if (missing === null || missing.length === 0) continue;
|
|
269
|
+
const rebuildable = missing.filter((m) => m.rebuildable).length;
|
|
270
|
+
const lost = missing.length - rebuildable;
|
|
271
|
+
const shown = missing.slice(0, LISTED_MAX).map((m) => `${m.block} ${m.file} (${m.rebuildable ? 'rebuildable' : 'lost'})`).join(', ');
|
|
272
|
+
const more = missing.length > LISTED_MAX ? ` … and ${missing.length - LISTED_MAX} more` : '';
|
|
273
|
+
rows.push(row('review-state', 'WARN', 'review state',
|
|
274
|
+
`run ${runId}: ${missing.length} state file(s) missing — ${rebuildable} rebuildable from the ledger, ${lost} lost (needs code-forge block reopen): ${shown}${more}`));
|
|
275
|
+
} catch {
|
|
276
|
+
// an unreadable record, key or ledger: not this row's business
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return rows;
|
|
280
|
+
}
|
|
281
|
+
|
|
194
282
|
/**
|
|
195
283
|
* B44: the `review concurrency` INFO row — how many review tickets run at once and each provider's
|
|
196
284
|
* session slots, from the effective config (defaults when a key is unset). Never throws.
|
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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B60: how many FILES a block owns, for `gates.full_suite_threshold_files`.
|
|
3
|
+
*
|
|
4
|
+
* The owned entries are resolved against the workspace's tracked and new (not ignored) files
|
|
5
|
+
* (`git ls-files -z --cached --others --exclude-standard`, argv only) with the B57
|
|
6
|
+
* ownership rule (`ownsFile`, state/registry.mjs): a directory entry counts every such file below it, a glob counts its matches, a file counts once. Each file is counted
|
|
7
|
+
* once however many entries cover it. An entry that covers none of them (a path the block will
|
|
8
|
+
* create) counts as 1. Outside a git repository every entry counts as 1; any other git failure
|
|
9
|
+
* (a timeout, a spawn error, git failing inside a repository) gives `Infinity`, so the full suite
|
|
10
|
+
* runs (`reason: threshold`) rather than a guess.
|
|
11
|
+
*
|
|
12
|
+
* {@link countOwnedFiles} is the pure rule; {@link ownedFileCount} reads the files from git.
|
|
13
|
+
*/
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import { ownsFile } from '../state/registry.mjs';
|
|
16
|
+
import { exec } from '../util/exec.mjs';
|
|
17
|
+
|
|
18
|
+
const GIT_TIMEOUT_MS = 30000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The number of files `owned` covers among `files`, each counted once, plus 1 for every entry
|
|
22
|
+
* that covers none of them.
|
|
23
|
+
* @param {ReadonlyArray<string>} owned
|
|
24
|
+
* @param {ReadonlyArray<string>} tracked - the workspace's files
|
|
25
|
+
* @param {Record<string, string>} [kinds] - the block's `owned_kinds`
|
|
26
|
+
* @returns {number}
|
|
27
|
+
*/
|
|
28
|
+
export function countOwnedFiles(owned, tracked, kinds) {
|
|
29
|
+
const covered = new Set();
|
|
30
|
+
let notThere = 0;
|
|
31
|
+
for (const entry of owned) {
|
|
32
|
+
const hits = tracked.filter((file) => ownsFile([entry], file, kinds));
|
|
33
|
+
if (hits.length === 0) notThere += 1;
|
|
34
|
+
for (const file of hits) covered.add(file);
|
|
35
|
+
}
|
|
36
|
+
return covered.size + notThere;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `process.env` minus every inherited `GIT_*`, with no system/global config, messages in English. */
|
|
40
|
+
function gitEnv() {
|
|
41
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
42
|
+
const env = { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: os.devNull };
|
|
43
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
44
|
+
if (!key.startsWith('GIT_')) env[key] = value;
|
|
45
|
+
}
|
|
46
|
+
env.LC_ALL = 'C'; // the "not a git repository" message is matched below
|
|
47
|
+
return env;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The files `git ls-files` lists: tracked (`--cached`) and new, not ignored (`--others --exclude-standard`). */
|
|
51
|
+
const LS_FILES_ARGV = Object.freeze(['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard']);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* {@link countOwnedFiles} over the tracked and new (not ignored) files of the workspace at `cwd`.
|
|
55
|
+
* Not a git repository: every entry counts 1. Any other failure: `Infinity` (the full suite runs).
|
|
56
|
+
* @param {string} cwd
|
|
57
|
+
* @param {ReadonlyArray<string>} owned
|
|
58
|
+
* @param {Record<string, string>} [kinds]
|
|
59
|
+
* @returns {Promise<number>}
|
|
60
|
+
*/
|
|
61
|
+
export async function ownedFileCount(cwd, owned, kinds) {
|
|
62
|
+
const res = await exec([...LS_FILES_ARGV], { cwd, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
63
|
+
if (res.result === 'ok') return countOwnedFiles(owned, res.stdout.split('\0').filter((f) => f.length > 0), kinds);
|
|
64
|
+
if (!res.timedOut && res.code === 128 && /not a git repository/i.test(res.stderr)) return countOwnedFiles(owned, [], kinds);
|
|
65
|
+
return Number.POSITIVE_INFINITY;
|
|
66
|
+
}
|
package/src/gates/run.mjs
CHANGED
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { exec } from '../util/exec.mjs';
|
|
10
|
+
import { registerSecret } from '../util/redact.mjs';
|
|
11
|
+
import { NO_BLOCK_SCOPE, envOf, expandArgv, maskableEnvValues, scopeFields } from './expand.mjs';
|
|
10
12
|
|
|
11
13
|
/** @typedef {import('./detect.mjs').Gate} Gate */
|
|
12
14
|
/** @typedef {import('./detect.mjs').GateCommand} GateCommand */
|
|
13
15
|
/** @typedef {import('./detect.mjs').DetectedGates} DetectedGates */
|
|
16
|
+
/** @typedef {import('./expand.mjs').TestScope} TestScope */
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* @typedef {object} GateResult
|
|
@@ -22,26 +25,35 @@ import { exec } from '../util/exec.mjs';
|
|
|
22
25
|
* @property {number | null} code
|
|
23
26
|
* @property {string} stdout
|
|
24
27
|
* @property {string} stderr
|
|
28
|
+
* @property {'related'|'full'} [scope] - B60: only on a gate whose argv has the `{test_files}` item.
|
|
29
|
+
* @property {number} [test_count] - B60: with `scope: related`, how many test files it ran.
|
|
30
|
+
* @property {'threshold'|'no_tests'} [reason] - B60: with `scope: full`, why the item was dropped.
|
|
31
|
+
* @property {string[]} [env_keys] - B60: only on a gate given with a non-empty `env`: the variable
|
|
32
|
+
* NAMES, sorted (the values are never in a result).
|
|
25
33
|
*/
|
|
26
34
|
|
|
27
35
|
/** Fixed run order — always all four, regardless of an earlier one's result. */
|
|
28
36
|
const GATE_ORDER = /** @type {const} */ (['test', 'lint', 'types', 'format']);
|
|
29
37
|
|
|
30
38
|
/**
|
|
31
|
-
* A gate command is either a plain argv array or `{argv, failOnStdout
|
|
32
|
-
* `GateCommand
|
|
33
|
-
* one shape to deal with.
|
|
39
|
+
* A gate command is either a plain argv array or `{argv, failOnStdout?, env?}` (detect.mjs's
|
|
40
|
+
* `GateCommand`: `failOnStdout` for `gofmt -l`, which always exits 0; B60 `env` from the config).
|
|
41
|
+
* Normalized here so the run loop below has one shape to deal with.
|
|
34
42
|
* @param {GateCommand} command
|
|
35
|
-
* @returns {{argv: string[], failOnStdout: boolean}}
|
|
43
|
+
* @returns {{argv: string[], failOnStdout: boolean, env: Record<string, string> | null}}
|
|
36
44
|
*/
|
|
37
45
|
function normalizeCommand(command) {
|
|
38
|
-
if (Array.isArray(command)) return { argv: command, failOnStdout: false };
|
|
39
|
-
return { argv: command.argv, failOnStdout: command.failOnStdout === true };
|
|
46
|
+
if (Array.isArray(command)) return { argv: command, failOnStdout: false, env: null };
|
|
47
|
+
return { argv: command.argv, failOnStdout: command.failOnStdout === true, env: envOf(command) }; // `env: {}` is no env
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
/**
|
|
51
|
+
* B60: `opts.scope` is the block's {@link TestScope} (`testScope`, gates/expand.mjs); a gate argv
|
|
52
|
+
* item that is exactly `{test_files}` expands to its files, or is dropped for a full run. Left out,
|
|
53
|
+
* no test file is known (`scope: full, reason: no_tests`). A gate with `env` runs with those
|
|
54
|
+
* variables added to the inherited environment; a gate with neither runs exactly as before B60.
|
|
43
55
|
* @param {{test?: Gate, lint?: Gate, types?: Gate, format?: Gate}} gates
|
|
44
|
-
* @param {{cwd: string, timeoutMs?: number}} opts
|
|
56
|
+
* @param {{cwd: string, timeoutMs?: number, scope?: TestScope}} opts
|
|
45
57
|
* @returns {Promise<{results: GateResult[], allOk: boolean}>}
|
|
46
58
|
*/
|
|
47
59
|
export async function runGates(gates, opts) {
|
|
@@ -49,24 +61,49 @@ export async function runGates(gates, opts) {
|
|
|
49
61
|
throw new TypeError('runGates: gates must be an object with test/lint/types/format');
|
|
50
62
|
}
|
|
51
63
|
const { cwd, timeoutMs = 300000 } = opts ?? {};
|
|
64
|
+
const scope = opts?.scope ?? NO_BLOCK_SCOPE; // null falls back too
|
|
52
65
|
if (typeof cwd !== 'string' || cwd.length === 0) {
|
|
53
66
|
throw new TypeError('runGates: opts.cwd must be a non-empty string');
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
// B60: gate env is not a secret store. Its values are never put in a result (only `env_keys`);
|
|
70
|
+
// a value of 8+ characters is also masked in the child's own output (a shorter one would mask
|
|
71
|
+
// unrelated text — `MODE=test` vs `npm test`)
|
|
72
|
+
for (const value of maskableEnvValues(gates)) registerSecret(value);
|
|
73
|
+
// Every gate is normalized and expanded BEFORE any runs, so a bad gate throws with nothing run.
|
|
74
|
+
// B60: config validation refuses a gate whose only item is {test_files}; the empty-argv check
|
|
75
|
+
// is the guard for one that reaches here anyway: it names the gate, not an opaque spawn error.
|
|
76
|
+
const planned = GATE_ORDER.map((gate) => {
|
|
77
|
+
const command = gates[gate] ?? null;
|
|
78
|
+
if (command === null) return { gate, command: null };
|
|
79
|
+
const normalized = normalizeCommand(command);
|
|
80
|
+
const { argv, scoped } = expandArgv(normalized.argv, scope);
|
|
81
|
+
if (argv.length === 0 && normalized.argv.length > 0) throw new TypeError(`gates.${gate}: the argv is empty once '{test_files}' is dropped for the full suite — put the test command before it`);
|
|
82
|
+
return { gate, command: { argv, scoped, failOnStdout: normalized.failOnStdout, env: normalized.env } };
|
|
83
|
+
});
|
|
56
84
|
/** @type {GateResult[]} */
|
|
57
85
|
const results = [];
|
|
58
|
-
for (const gate of
|
|
59
|
-
const command = gates[gate] ?? null;
|
|
86
|
+
for (const { gate, command } of planned) {
|
|
60
87
|
if (command === null) {
|
|
61
88
|
results.push({ gate, argv: null, skipped: true, ok: true, code: null, stdout: '', stderr: '' });
|
|
62
89
|
continue;
|
|
63
90
|
}
|
|
64
|
-
const { argv, failOnStdout } =
|
|
91
|
+
const { argv, scoped, failOnStdout, env } = command;
|
|
65
92
|
// Each command is run and its exit status read on its own — never `&&`-chained with the
|
|
66
93
|
// next, so a red gate is reported red without swallowing the gates that follow it.
|
|
67
|
-
const res = await exec(argv, { cwd, timeoutMs });
|
|
94
|
+
const res = await exec(argv, env ? { cwd, timeoutMs, env: { ...process.env, ...env } } : { cwd, timeoutMs });
|
|
68
95
|
const ok = res.result === 'ok' && (!failOnStdout || res.stdout.trim().length === 0);
|
|
69
|
-
results.push({
|
|
96
|
+
results.push({
|
|
97
|
+
gate,
|
|
98
|
+
argv,
|
|
99
|
+
skipped: false,
|
|
100
|
+
ok,
|
|
101
|
+
code: res.code,
|
|
102
|
+
stdout: res.stdout,
|
|
103
|
+
stderr: res.stderr,
|
|
104
|
+
...(scoped ? scopeFields(scope) : {}),
|
|
105
|
+
...(env ? { env_keys: Object.keys(env).sort() } : {}),
|
|
106
|
+
});
|
|
70
107
|
}
|
|
71
108
|
return { results, allOk: results.every((r) => r.ok) };
|
|
72
109
|
}
|