geneseed 3.1.1 → 3.1.2
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 +68 -1
- package/DESIGN.md +15 -15
- package/README.md +1 -1
- package/SETUP.md +1 -1
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +39 -42
- package/bin/geneseed-cli.mjs +33 -32
- package/bin/geneseed-hook.mjs +7 -7
- package/docs/design-history.md +40 -40
- package/docs/extending.md +111 -83
- package/harness.config.json +1 -1
- package/js/README.md +203 -0
- package/js/build/bundle.mjs +231 -0
- package/js/{catalog.mjs → build/catalog.mjs} +7 -7
- package/js/build/emit-claude.mjs +287 -0
- package/js/build/emit-common.mjs +255 -0
- package/js/build/emit-opencode.mjs +214 -0
- package/js/{generate.mjs → build/generate.mjs} +27 -28
- package/js/{render.mjs → build/render.mjs} +13 -9
- package/js/{checkout.mjs → build/source.mjs} +9 -9
- package/js/build/stubs.mjs +256 -0
- package/js/{themes.mjs → build/themes.mjs} +24 -24
- package/js/build/version.mjs +155 -0
- package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
- package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
- package/js/{installs.mjs → hosts/installs.mjs} +18 -16
- package/js/{link.mjs → hosts/link.mjs} +29 -28
- package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
- package/js/{native.mjs → hosts/native.mjs} +24 -22
- package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
- package/js/{settings.mjs → hosts/settings.mjs} +63 -55
- package/js/inspect/checks-authoring.mjs +761 -0
- package/js/inspect/checks-build.mjs +231 -0
- package/js/inspect/checks-repo.mjs +308 -0
- package/js/{diff.mjs → inspect/diff.mjs} +34 -31
- package/js/inspect/doctor.mjs +286 -0
- package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
- package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
- package/js/{registry.mjs → inspect/registry.mjs} +12 -10
- package/js/inspect/scan.mjs +125 -0
- package/js/{status.mjs → inspect/status.mjs} +27 -25
- package/js/inspect/validate.mjs +199 -0
- package/js/lib/fs.mjs +37 -759
- package/js/lib/json.mjs +344 -0
- package/js/lib/paths.mjs +242 -0
- package/js/lib/proc.mjs +3 -3
- package/js/lib/text.mjs +181 -0
- package/js/lib/udiff.mjs +1 -1
- package/js/{memory.mjs → maintain/memory.mjs} +10 -10
- package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
- package/js/{setup.mjs → maintain/setup.mjs} +34 -32
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
- package/js/{update.mjs → maintain/update.mjs} +51 -51
- package/js/{anim.mjs → ui/anim.mjs} +30 -28
- package/js/{cli.mjs → ui/cli.mjs} +10 -10
- package/js/{menu.mjs → ui/menu.mjs} +10 -9
- package/js/{tui.mjs → ui/tui.mjs} +36 -34
- package/js/web/actions.mjs +84 -85
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +72 -50
- package/js/web/daemon.mjs +319 -0
- package/js/web/docs.mjs +24 -21
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +442 -0
- package/js/web/jobs.mjs +21 -19
- package/js/web/routes.mjs +223 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{About-DyK7yRy7.js → About-CPvIFZGh.js} +1 -1
- package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-z0uXdS7J.js} +1 -1
- package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-CEj5IJpF.js} +1 -1
- package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-BMR8fPuG.js} +1 -1
- package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-Bz5qE4r9.js} +1 -1
- package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-BV6a6t2l.js} +1 -1
- package/web/dist/assets/{Harnesses-DgRyOviS.js → Harnesses-C5Ag5PaQ.js} +1 -1
- package/web/dist/assets/{Laws-CWk3Mpri.js → Laws-BK07NKw9.js} +1 -1
- package/web/dist/assets/{Library-DXhTN9ju.js → Library-CiXN9sC3.js} +1 -1
- package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-DG6FjUz-.js} +1 -1
- package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-B2oSvdxH.js} +1 -1
- package/web/dist/assets/{Rules-eND7hJv9.js → Rules-DobVPd80.js} +1 -1
- package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DQPsvsn9.js} +1 -1
- package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-BhJ3HhdI.js} +1 -1
- package/web/dist/assets/{Themes-CoAWuIeM.js → Themes-BP38FBU9.js} +1 -1
- package/web/dist/assets/{activity-Dmb0FTAx.js → activity-D9mfv7pY.js} +1 -1
- package/web/dist/assets/index-BE226Gv6.css +1 -0
- package/web/dist/assets/{index-jrY4m9jP.js → index-BZ7cPLMI.js} +1 -1
- package/web/dist/assets/index-BgIs5eIa.js +12 -0
- package/web/dist/assets/{index-Dg58ScBe.js → index-DGM5j2hB.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +1 -1
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1750
- package/js/emit.mjs +0 -1411
- package/web/dist/assets/index-B-mXquBZ.css +0 -1
- package/web/dist/assets/index-CH_Z725C.js +0 -12
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The CATALOG surface — the taxonomy tables, the rendered-laws
|
|
3
3
|
* parser, the entity registry, and the render-accurate inventory the TUI, the `status`
|
|
4
4
|
* panel and the web console all read.
|
|
5
5
|
*
|
|
6
6
|
* WHY THIS FILE EXISTS RATHER THAN A THIRD COPY OF THE TABLES. `LAW_CLASS`, `SKILL_CLASS`,
|
|
7
|
-
* `LAW_CLASSES` and `ENTITY_STATUSES` crossed in P5g, inside `js/doctor.mjs`, because
|
|
8
|
-
* doctor's authoring gates are what validate them — and `js/status.mjs`'s docblock said
|
|
7
|
+
* `LAW_CLASSES` and `ENTITY_STATUSES` crossed in P5g, inside `js/inspect/doctor.mjs`, because
|
|
8
|
+
* doctor's authoring gates are what validate them — and `js/inspect/status.mjs`'s docblock said
|
|
9
9
|
* flatly that "the taxonomy P7 owns" was not ported, which was true of `_parse_laws` and
|
|
10
10
|
* `entity_status` and had stopped being true of the tables. P6c needs all of it, so the
|
|
11
|
-
* tables move HERE, to the module that mirrors where Python keeps them, and
|
|
12
|
-
*
|
|
11
|
+
* tables move HERE, to the module that mirrors where Python keeps them, and the doctor's
|
|
12
|
+
* check modules import them — `checks-authoring.mjs` takes `LAW_CLASS`, `LAW_CLASSES` and
|
|
13
|
+
* `SKILL_CLASS`, `checks-repo.mjs` takes `ENTITY_STATUSES`. A copy of a value under test
|
|
14
|
+
* silently stops being the value under test.
|
|
13
15
|
*
|
|
14
16
|
* AND `inventoryCounts` FOLDS BACK INTO `tuiInventory`. P5d shipped a counting-only twin
|
|
15
17
|
* with a docblock warning that the two must not become two classifiers; this is the phase
|
|
@@ -21,17 +23,18 @@
|
|
|
21
23
|
*/
|
|
22
24
|
import path from 'node:path';
|
|
23
25
|
|
|
24
|
-
import { ROOT, SRC, makeCfg, PACK_ORDER } from '
|
|
25
|
-
import { firstBlockquote } from '
|
|
26
|
-
import { renderAll } from '
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
26
|
+
import { ROOT, SRC, makeCfg, PACK_ORDER } from '../build/source.mjs';
|
|
27
|
+
import { firstBlockquote } from '../hosts/native.mjs';
|
|
28
|
+
import { renderAll } from '../build/render.mjs';
|
|
29
|
+
import { resolvePath } from '../hosts/hosts.mjs';
|
|
30
|
+
import { readText } from '../lib/fs.mjs';
|
|
31
|
+
import { parseJson } from '../lib/json.mjs';
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* `_harness_tui.LAW_HEADING_RE` — a heading in the RENDERED laws file, e.g.
|
|
32
35
|
* `### Rule IV — Deletion Is Deliberate`.
|
|
33
36
|
*
|
|
34
|
-
* Not `js/
|
|
37
|
+
* Not `js/inspect/checks-authoring.mjs`'s `LAW_HEADING_RE`, which matches the `{{LAW}}` token in the
|
|
35
38
|
* unrendered source. Two patterns, two inputs, two names in Python as well.
|
|
36
39
|
*/
|
|
37
40
|
export const LAW_HEADING_RE = /^###\s+\S+\s+([IVXLCDM]+)\s+[—-]\s+(.+?)\s*$/;
|
|
@@ -173,7 +176,7 @@ export function parseDoctrines(text) {
|
|
|
173
176
|
*
|
|
174
177
|
* `id` is the slugified title, and it is an ADDRESS: `#/item/law/ont:telos` is how the console
|
|
175
178
|
* deep-links a section, so it has to survive a theme change. It does, because the four titles
|
|
176
|
-
* come from `STRUCTURE` in `js/render.mjs` and not from any theme file — the same reason a
|
|
179
|
+
* come from `STRUCTURE` in `js/build/render.mjs` and not from any theme file — the same reason a
|
|
177
180
|
* citation can spell itself `({{ONTOLOGY}}: {{ONT_TELOS}})` with a token on both sides.
|
|
178
181
|
*
|
|
179
182
|
* The preamble above the first `####` is dropped, as in the two parsers above. It is the
|
|
@@ -202,7 +205,7 @@ export function parseOntology(text) {
|
|
|
202
205
|
*
|
|
203
206
|
* Deliberately forgiving on BOTH failure modes, as the reference is: a missing file and a
|
|
204
207
|
* corrupt one both yield `{}`, which reads every badge as "unknown" rather than breaking
|
|
205
|
-
* the browser. `registryProblems` in `js/
|
|
208
|
+
* the browser. `registryProblems` in `js/inspect/checks-repo.mjs` is the loud reader of the same file
|
|
206
209
|
* and stays separate — the two answer different questions.
|
|
207
210
|
*/
|
|
208
211
|
export function loadRegistry() {
|
|
@@ -265,7 +268,7 @@ export function tuiInventory(themeName, doctrines = null, excluded = []) {
|
|
|
265
268
|
const name = two ? parts[1].slice(0, -3) : null;
|
|
266
269
|
if (two && (parts[0] === 'agents' || parts[0] === 'skills')) {
|
|
267
270
|
const entry = { name, desc: firstBlockquote(text), body: text,
|
|
268
|
-
source:
|
|
271
|
+
source: resolvePath(src), status: entityStatus(registry, `${parts[0]}/${name}`) };
|
|
269
272
|
if (parts[0] === 'agents') agents.push(entry);
|
|
270
273
|
else {
|
|
271
274
|
entry.klass = has(SKILL_CLASS, name) ? SKILL_CLASS[name] : 'build';
|
|
@@ -279,12 +282,12 @@ export function tuiInventory(themeName, doctrines = null, excluded = []) {
|
|
|
279
282
|
if (two && parts[0] === 'doctrines') {
|
|
280
283
|
const lead = text.split('\n').map((l) => PACK_LEAD_RE.exec(l)).find(Boolean);
|
|
281
284
|
packs.set(name, { pack: name, title: lead ? lead[1] : name, desc: lead ? lead[2] : '',
|
|
282
|
-
source:
|
|
285
|
+
source: resolvePath(src), rules: parseDoctrines(text) });
|
|
283
286
|
}
|
|
284
287
|
// Scoped to the laws dir, not matched on the basename: `src/ontology/universal.md`
|
|
285
288
|
// shares the name, sorts after `laws/`, and would clobber the parse with `[]` —
|
|
286
289
|
// `LAW_HEADING_RE` wants `###` and the ontology uses `####`. Same spelling as the
|
|
287
|
-
// already-scoped sites at `js/render.mjs`'s `rel === 'laws/universal.md'`.
|
|
290
|
+
// already-scoped sites at `js/build/render.mjs`'s `rel === 'laws/universal.md'`.
|
|
288
291
|
if (parts.join('/') === 'laws/universal.md') laws = parseLaws(text);
|
|
289
292
|
if (parts.join('/') === 'ontology/universal.md') ontology = parseOntology(text);
|
|
290
293
|
}
|
|
@@ -294,7 +297,7 @@ export function tuiInventory(themeName, doctrines = null, excluded = []) {
|
|
|
294
297
|
// ⚠ `PACK_ORDER` IS THE SINGLE OWNER OF THE SET, and of its order. Filtering the order by
|
|
295
298
|
// what was found — rather than publishing what was found — is what keeps `README.md` and any
|
|
296
299
|
// other stray `.md` in that directory out of the catalogue, and it is the same membership
|
|
297
|
-
// rule `js/render.mjs` refuses the build over, so a fifth pack cannot be silently dropped
|
|
300
|
+
// rule `js/build/render.mjs` refuses the build over, so a fifth pack cannot be silently dropped
|
|
298
301
|
// here either. Order because `sortedSourceFiles` walks alphabetically, which puts `ops`
|
|
299
302
|
// second: the narrative order is the reading order, and it is what the marker line, the
|
|
300
303
|
// wizard and the rendered section all use.
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `_install_registry.py` — the persistent list of folders a harness was deployed into.
|
|
3
3
|
*
|
|
4
|
-
* MOVED OUT OF `bin/
|
|
4
|
+
* MOVED OUT OF `bin/build-driver.mjs` IN P5f, for the third time the same arithmetic has decided
|
|
5
5
|
* a module boundary in this port (P5c took the host resolvers, P5d the checkout paths). The
|
|
6
6
|
* driver WRITES the registry, one row per per-repo emit; `harness rebuild-all` READS it to
|
|
7
7
|
* find every install it must re-emit, and `bin/geneseed-cli.mjs` may not reach into a driver
|
|
8
|
-
* for a reader. `js/generate.mjs` importing `main` from `bin/
|
|
8
|
+
* for a reader. `js/build/generate.mjs` importing `main` from `bin/build-driver.mjs` is not a
|
|
9
9
|
* precedent for it either: `harness build` IS the driver, and a registry reader is not.
|
|
10
10
|
*
|
|
11
|
-
* The move is licensed the same way the two before it were: `tests/golden.
|
|
12
|
-
* `bin/
|
|
11
|
+
* The move is licensed the same way the two before it were: `tests/golden.mjs` drives
|
|
12
|
+
* `bin/build-driver.mjs` over 259 cells with `$XDG_CONFIG_HOME` redirected into the sandbox and
|
|
13
13
|
* compares `installs.json` byte-for-byte, so a changed path or a dropped prune fails cells
|
|
14
14
|
* rather than passing quietly.
|
|
15
15
|
*/
|
|
@@ -17,8 +17,10 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync
|
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import os from 'node:os';
|
|
19
19
|
|
|
20
|
-
import { expanduser } from '
|
|
21
|
-
import { writeText
|
|
20
|
+
import { expanduser } from '../hosts/hosts.mjs';
|
|
21
|
+
import { writeText } from '../lib/fs.mjs';
|
|
22
|
+
import { parseJson, jsonDumpsIndent } from '../lib/json.mjs';
|
|
23
|
+
import { toPlatformPath } from '../lib/paths.mjs';
|
|
22
24
|
|
|
23
25
|
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
24
26
|
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
@@ -53,7 +55,7 @@ export function registrySave(items) {
|
|
|
53
55
|
*
|
|
54
56
|
* `realpathSync` rather than `path.resolve`, because Python's `Path.resolve()` returns the
|
|
55
57
|
* filesystem's own casing and follows links; `path.resolve` only makes a path absolute. The
|
|
56
|
-
* registry is compared byte-for-byte by `tests/golden.
|
|
58
|
+
* registry is compared byte-for-byte by `tests/golden.mjs` (XDG is redirected into the
|
|
57
59
|
* sandbox), so a differently-cased entry is a failing cell.
|
|
58
60
|
*/
|
|
59
61
|
export function registryRecord(dir) {
|
|
@@ -80,20 +82,20 @@ export function registryRoots() {
|
|
|
80
82
|
const kept = [];
|
|
81
83
|
const seen = new Set();
|
|
82
84
|
for (const s of original) {
|
|
83
|
-
// `Path(s).expanduser()`, and `
|
|
85
|
+
// `Path(s).expanduser()`, and `toPlatformPath` is the `str()` around it — which is where the
|
|
84
86
|
// NORMALISATION happens. Python turns `C:/x/other` into `C:\x\other` before it is
|
|
85
87
|
// compared, kept, returned and printed, so a registry entry spelled with forward slashes
|
|
86
88
|
// (routine: every tool that writes one from Git Bash) makes Python rewrite the file and
|
|
87
89
|
// print a backslash path while a raw `expanduser` prints the entry verbatim and leaves
|
|
88
90
|
// installs.json alone. Invisible to every cell before P5h, because `rebuild-all`'s
|
|
89
91
|
// registry cells seed native spellings; `uninstall`'s inventory prints the root.
|
|
90
|
-
// `expanduser` now REFUSES a `~user` entry (js/hosts.mjs's docblock) instead of passing
|
|
92
|
+
// `expanduser` now REFUSES a `~user` entry (js/hosts/hosts.mjs's docblock) instead of passing
|
|
91
93
|
// it through. This loop scans STORED registry rows, not a fresh CLI argument — a single
|
|
92
94
|
// bad row (hand-edited, or written before this refusal existed) must not abort the scan
|
|
93
95
|
// for every other install, so it is dropped exactly like any other dead row rather than
|
|
94
96
|
// propagated. `rebuild-all`'s contract is "one broken install never blocks the rest".
|
|
95
97
|
let root;
|
|
96
|
-
try { root =
|
|
98
|
+
try { root = toPlatformPath(expanduser(s)); } catch { continue; }
|
|
97
99
|
let key;
|
|
98
100
|
try { key = existsSync(root) ? realpathSync.native(root) : root; } catch { key = root; }
|
|
99
101
|
if (seen.has(key)) continue;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `_harness_core`'s scanning primitives — what every doctor check reads the tree with.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `doctor.mjs` because all three check modules need them and none of the three
|
|
5
|
+
* owns them. Nothing here decides anything: these walk, sort, strip and test, and the
|
|
6
|
+
* judgement lives in `checks-build.mjs`, `checks-repo.mjs` and `checks-authoring.mjs`.
|
|
7
|
+
*
|
|
8
|
+
* `NOTE` and `isDoctorNote` sit here for the same reason — a check in any of the three
|
|
9
|
+
* marks an advisory line with the prefix, and the driver in `doctor.mjs` reads it back.
|
|
10
|
+
*/
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { SRC } from '../build/source.mjs';
|
|
14
|
+
import { comparePaths, normcase } from '../lib/paths.mjs';
|
|
15
|
+
import { mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
16
|
+
|
|
17
|
+
// --------------------------------------------------------------------------------------
|
|
18
|
+
// _harness_core's scanning primitives
|
|
19
|
+
// --------------------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** `_harness_core.TOKEN_RE` / `LINK_RE` / `ABS_LINK_RE`. `g` where `findall` is used. */
|
|
22
|
+
// Must stay in step with `js/build/render.mjs`'s TOKEN_RE: a token the renderer substitutes but this
|
|
23
|
+
// scan cannot see is an unresolved-token gate that silently stops gating. Digits are legal after
|
|
24
|
+
// the first character for `DOC_<PACK>_<n>`; a leading digit is still not a token.
|
|
25
|
+
export const TOKEN_RE = /\{\{[A-Z_][A-Z0-9_]*\}\}/g;
|
|
26
|
+
export const LINK_RE = /\]\((?!https?:\/\/|#)([^)]+)\)/g;
|
|
27
|
+
export const ABS_LINK_RE = /^([A-Za-z]:[\\/]|\/|~)/;
|
|
28
|
+
const FENCE_RE = /```[\s\S]*?```/g;
|
|
29
|
+
const INLINE_CODE_RE = /`[^`]*`/g;
|
|
30
|
+
const COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
31
|
+
|
|
32
|
+
/** `_harness_core.strip_code` — fenced blocks, inline code and HTML comments, in that order. */
|
|
33
|
+
export function stripCode(text) {
|
|
34
|
+
return text.replace(FENCE_RE, '').replace(COMMENT_RE, '').replace(INLINE_CODE_RE, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `_harness_core._within` — `child.relative_to(parent)` without the exception.
|
|
39
|
+
*
|
|
40
|
+
* Segment-wise and through `normcase`, because that is what `PurePath` comparison does: a
|
|
41
|
+
* bundle at `C:\Temp\X` contains `c:\temp\x\a.md` on Windows and does not on Linux. A
|
|
42
|
+
* `startsWith` on the raw strings would additionally call `/tmp/bundle2` a child of
|
|
43
|
+
* `/tmp/bundle`, which is the classic version of this bug.
|
|
44
|
+
*/
|
|
45
|
+
export function within(child, parent) {
|
|
46
|
+
const c = normcase(child).split(/[\\/]/);
|
|
47
|
+
const p = normcase(parent).split(/[\\/]/);
|
|
48
|
+
return p.length <= c.length && p.every((seg, i) => c[i] === seg);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
52
|
+
export const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
53
|
+
|
|
54
|
+
/** `sorted(d.glob(pat))`, or `[]` for a directory that is not there. */
|
|
55
|
+
export function globSorted(dir, filter) {
|
|
56
|
+
let names;
|
|
57
|
+
try { names = readdirSync(dir); } catch { return []; }
|
|
58
|
+
return names.filter(filter).map((n) => path.join(dir, n)).sort(comparePaths);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `Path.rglob('*')` — every entry under `dir`, sorted as `sorted()` sorts paths. */
|
|
62
|
+
export function rglob(dir) {
|
|
63
|
+
const out = [];
|
|
64
|
+
const walk = (d) => {
|
|
65
|
+
let entries;
|
|
66
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
67
|
+
for (const e of entries.map((x) => x.name).sort(comparePaths)) {
|
|
68
|
+
const p = path.join(d, e);
|
|
69
|
+
out.push(p);
|
|
70
|
+
if (isDir(p)) walk(p);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
walk(dir);
|
|
74
|
+
return out.sort(comparePaths);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// `withDiscardableStderr` was defined here while `renderedProblems` was its only caller.
|
|
78
|
+
// `js/hosts/hooks.mjs` became the second and third when the hook started CATCHING `expanduser`'s
|
|
79
|
+
// refusal, so it moved beside `printErr` in `js/lib/fs.mjs` — the writes it intercepts —
|
|
80
|
+
// and its docblock carries the "not for silencing noise" warning with it.
|
|
81
|
+
|
|
82
|
+
/** `Path.stem` — the basename with its last suffix removed. */
|
|
83
|
+
export const stemOf = (p) => path.basename(p, path.extname(p));
|
|
84
|
+
|
|
85
|
+
/** `sorted(set)` over strings, the ordering every problem list is emitted in. */
|
|
86
|
+
export const sortedUnique = (xs) => [...new Set(xs)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
87
|
+
/** `sorted(probs)` — SORTED, not deduped, which is what `_ran` fills a group with. */
|
|
88
|
+
export const sortedProblems = (xs) => [...xs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `k in d` for a Python dict — OWN keys only.
|
|
92
|
+
*
|
|
93
|
+
* Every membership test in this file reads a key that came out of a JSON document or a source
|
|
94
|
+
* filename, and a bare `k in obj` would answer true for `constructor`, `toString` and the rest
|
|
95
|
+
* of `Object.prototype`. A theme defining a `toString` voice token, or a skill file named
|
|
96
|
+
* `valueOf.md`, would then be reported as missing by the reference and not by this port — a
|
|
97
|
+
* divergence no cell can reach and no reviewer would look for.
|
|
98
|
+
*/
|
|
99
|
+
export const has = (obj, k) => Object.prototype.hasOwnProperty.call(obj, k);
|
|
100
|
+
|
|
101
|
+
/** `_harness_build._src_stems` — spec stems under `src/<folder>`, minus `_`-scaffolds. */
|
|
102
|
+
export function srcStems(folder) {
|
|
103
|
+
const d = path.join(SRC, folder);
|
|
104
|
+
if (!isDir(d)) return new Set();
|
|
105
|
+
return new Set(globSorted(d, (n) => n.endsWith('.md') && !n.startsWith('_')).map(stemOf));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A line reported by the doctor that is NOT a failure — `[note] …` rather than `[authoring] …`.
|
|
110
|
+
*
|
|
111
|
+
* ⚠ ONE PRODUCER, AND IT IS THE ONLY THING THAT NEEDED A SECOND CHANNEL. A build with a
|
|
112
|
+
* doctrine pack off is LEGAL, and it leaves body prose citing rules that pack owns. That state
|
|
113
|
+
* has to be visible — a silent dangling citation is how prose and boundary drift apart — but
|
|
114
|
+
* reporting it as a problem would make `doctor` exit 1 over a configuration its owner chose.
|
|
115
|
+
* So `cmdDoctor` prints notes and does not count them.
|
|
116
|
+
*/
|
|
117
|
+
export const NOTE = '[note] ';
|
|
118
|
+
export const isDoctorNote = (p) => p.startsWith(NOTE);
|
|
119
|
+
|
|
120
|
+
/** `tempfile.TemporaryDirectory()` — created, handed to `fn`, removed whatever happens. */
|
|
121
|
+
export function withTempDir(fn, prefix = 'geneseed-doctor-') {
|
|
122
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
123
|
+
try { return fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
124
|
+
}
|
|
125
|
+
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
*
|
|
31
31
|
* WHERE THE DETECTORS WENT. `themeOfDir`, `footprintOfDir`, `installedDefaults`,
|
|
32
32
|
* `manifestIsClaude` and the configured defaults were written here in P5d because `status`
|
|
33
|
-
* was their only caller. `js/installs.mjs` owns them since P5f, when `diff` and `rebuild-all`
|
|
33
|
+
* was their only caller. `js/hosts/installs.mjs` owns them since P5f, when `diff` and `rebuild-all`
|
|
34
34
|
* became the second and third. Nothing about them changed in the move — the 166-cell
|
|
35
35
|
* acceptance matrix is what licensed attempting it, `status`'s own eleven cells among them.
|
|
36
36
|
*
|
|
@@ -41,10 +41,11 @@
|
|
|
41
41
|
* know), `accentFor`'s cyan fallback (an unknown theme is refused upstream by
|
|
42
42
|
* `effectiveTheme`), and the whole ANSI half of `statusLines` (`_color_enabled` is
|
|
43
43
|
* `sys.stdout.isatty()`, and every harness captures stdout through a pipe). All three are
|
|
44
|
-
* PURE FUNCTIONS of their arguments, so all three are gated as a corpus
|
|
45
|
-
* `tests/
|
|
46
|
-
*
|
|
47
|
-
* it
|
|
44
|
+
* PURE FUNCTIONS of their arguments, so all three are gated as a corpus instead:
|
|
45
|
+
* `tests/fixtures/pure_probe.mjs` calls them and `tests/snapshot/pure_snapshot.test.mjs`
|
|
46
|
+
* replays that against `tests/__snapshots__/primitives/`. That was a third answer to the
|
|
47
|
+
* colour question, which had been posed as a choice between shipping it ungated and not
|
|
48
|
+
* shipping it.
|
|
48
49
|
*
|
|
49
50
|
* `manifestIsClaude` was a FOURTH until wave 2 of the P0/P1 review: it is only reached for
|
|
50
51
|
* a candidate with no known host, and `ROOT/"Harness"` was ordered ahead of the sandbox's
|
|
@@ -56,13 +57,13 @@
|
|
|
56
57
|
import { existsSync, readdirSync } from 'node:fs';
|
|
57
58
|
import path from 'node:path';
|
|
58
59
|
|
|
59
|
-
import { THEMES, ROOT, makeCfg } from '
|
|
60
|
+
import { THEMES, ROOT, makeCfg } from '../build/source.mjs';
|
|
60
61
|
import { tuiInventory } from './inventory.mjs';
|
|
61
|
-
import { readVersion, sourceFingerprint } from '
|
|
62
|
+
import { readVersion, sourceFingerprint } from '../build/version.mjs';
|
|
62
63
|
import {
|
|
63
|
-
claudeConfigDir, bobConfigDir, copilotConfigDir, opencodeConfigDir,
|
|
64
|
+
claudeConfigDir, bobConfigDir, copilotConfigDir, opencodeConfigDir, resolvePath,
|
|
64
65
|
resolveMemoryDir,
|
|
65
|
-
} from '
|
|
66
|
+
} from '../hosts/hosts.mjs';
|
|
66
67
|
// P5f moved the install DETECTORS out of this file — `diff` renders its expected copy in the
|
|
67
68
|
// deployed theme and footprint, and `rebuild-all` re-emits in the deployed everything, so
|
|
68
69
|
// three verbs now read them. `defaultTheme`, `manifestIsClaude` and `installedDefaults` are
|
|
@@ -70,8 +71,9 @@ import {
|
|
|
70
71
|
// and a corpus that followed the code to its new file would stop testing the caller's view.
|
|
71
72
|
import {
|
|
72
73
|
defaultTheme, installedDefaults, manifestIsClaude, readJsonMaybe, readMaybe,
|
|
73
|
-
} from '
|
|
74
|
-
import {
|
|
74
|
+
} from '../hosts/installs.mjs';
|
|
75
|
+
import { printOut } from '../lib/fs.mjs';
|
|
76
|
+
import { codePointLength, padEndToWidth } from '../lib/text.mjs';
|
|
75
77
|
|
|
76
78
|
export { defaultTheme, manifestIsClaude };
|
|
77
79
|
|
|
@@ -91,9 +93,9 @@ export function versionVerdict(installed, current) {
|
|
|
91
93
|
export function cmdVersion(args) {
|
|
92
94
|
const cfg = makeCfg();
|
|
93
95
|
const current = sourceFingerprint(cfg);
|
|
94
|
-
// `Path(args.target).expanduser().resolve()` — `
|
|
96
|
+
// `Path(args.target).expanduser().resolve()` — `resolvePath` already expands, so the tilde
|
|
95
97
|
// is handled once rather than twice.
|
|
96
|
-
let target = args.target ?
|
|
98
|
+
let target = args.target ? resolvePath(args.target) : opencodeConfigDir();
|
|
97
99
|
let installed = readVersion(target);
|
|
98
100
|
if (installed === null) {
|
|
99
101
|
// A claude/bob/copilot-only machine must not report "no install detected". The bundle
|
|
@@ -104,10 +106,10 @@ export function cmdVersion(args) {
|
|
|
104
106
|
if (v) { installed = v; target = base; break; }
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
printOut(`[version] source: ${current} (${ROOT})\n`);
|
|
110
|
+
printOut(`[version] installed: ${installed || '(none found)'}`
|
|
109
111
|
+ (installed ? ` (${target})` : '') + '\n');
|
|
110
|
-
|
|
112
|
+
printOut(`[version] ${versionVerdict(installed, current)}\n`);
|
|
111
113
|
return 0;
|
|
112
114
|
}
|
|
113
115
|
|
|
@@ -121,7 +123,7 @@ export function cmdVersion(args) {
|
|
|
121
123
|
* and porting them to produce three integers would have been absurd — with a docblock
|
|
122
124
|
* warning that the two must not become two classifiers. `api_catalog` consumes exactly the
|
|
123
125
|
* fields the count threw away, so P6c is the phase that would have made them two. It did
|
|
124
|
-
* not: `tuiInventory` in `js/inventory.mjs` is the one walk, this is three `length`s off
|
|
126
|
+
* not: `tuiInventory` in `js/inspect/inventory.mjs` is the one walk, this is three `length`s off
|
|
125
127
|
* it, and the eleven `status` cells and the catalog cells now gate the same classifier
|
|
126
128
|
* from opposite ends.
|
|
127
129
|
*/
|
|
@@ -294,29 +296,29 @@ export function statusLines(d, color = false) {
|
|
|
294
296
|
`${d.agent_md} (${d.agent_md_present ? 'present' : 'MISSING'})`]);
|
|
295
297
|
}
|
|
296
298
|
|
|
297
|
-
const labelW = Math.max(...rows.map(([k]) =>
|
|
298
|
-
const body = rows.map(([k, v]) => ` ${
|
|
299
|
+
const labelW = Math.max(...rows.map(([k]) => codePointLength(k)));
|
|
300
|
+
const body = rows.map(([k, v]) => ` ${padEndToWidth(k, labelW)} ${v}`);
|
|
299
301
|
const verdict = ` ${mark} ${d.version_verdict}`;
|
|
300
302
|
const title = ` ${badge} Geneseed ${emdash} status `;
|
|
301
|
-
const width = Math.max(...body.map(
|
|
303
|
+
const width = Math.max(...body.map(codePointLength), codePointLength(verdict), codePointLength(title) + 2);
|
|
302
304
|
|
|
303
305
|
const ac = ANSI_CODES[d.accent] ?? '36';
|
|
304
306
|
const c = (s, code) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
305
307
|
|
|
306
308
|
const top = color
|
|
307
|
-
? c(TL + H, ac) + c(title, `${ac};1`) + c(H.repeat(width -
|
|
308
|
-
: TL + H + title + H.repeat(width -
|
|
309
|
+
? c(TL + H, ac) + c(title, `${ac};1`) + c(H.repeat(width - codePointLength(title) - 1) + TR, ac)
|
|
310
|
+
: TL + H + title + H.repeat(width - codePointLength(title) - 1) + TR;
|
|
309
311
|
const edge = c(V, ac);
|
|
310
312
|
const lines = [top];
|
|
311
|
-
for (const b of body) lines.push(edge +
|
|
313
|
+
for (const b of body) lines.push(edge + padEndToWidth(b, width) + edge);
|
|
312
314
|
lines.push(c(LT + H.repeat(width) + RT, ac));
|
|
313
|
-
lines.push(edge + c(
|
|
315
|
+
lines.push(edge + c(padEndToWidth(verdict, width), vcode) + edge);
|
|
314
316
|
lines.push(c(BL + H.repeat(width) + BR, ac));
|
|
315
317
|
return lines;
|
|
316
318
|
}
|
|
317
319
|
|
|
318
320
|
/** `_harness_status.cmd_status`. */
|
|
319
321
|
export function cmdStatus() {
|
|
320
|
-
for (const line of statusLines(statusData(), colorEnabled()))
|
|
322
|
+
for (const line of statusLines(statusData(), colorEnabled())) printOut(`${line}\n`);
|
|
321
323
|
return 0;
|
|
322
324
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `validate` — the generator's old `--validate-only`, on this binary because it runs the
|
|
3
|
+
* doctor's scans over a sandbox it renders first.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `doctor.mjs`, where it already sat behind a banner of its own. It is a
|
|
6
|
+
* separate VERB with a separate exit contract, and it is the one verb no recorded cell ever
|
|
7
|
+
* reached — see its row in `tests/ported.json`.
|
|
8
|
+
*/
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { buildInto, emitGlobalInto, emitProjectInto } from '../../bin/build-driver.mjs';
|
|
11
|
+
import { resolvePath } from '../hosts/hosts.mjs';
|
|
12
|
+
import { validateIsVendored } from '../hosts/native.mjs';
|
|
13
|
+
import { printOut, printErr, readText } from '../lib/fs.mjs';
|
|
14
|
+
import { comparePaths } from '../lib/paths.mjs';
|
|
15
|
+
import { stripWhitespace } from '../lib/text.mjs';
|
|
16
|
+
import { linkProblems } from './checks-build.mjs';
|
|
17
|
+
import { cmdDoctor } from './doctor.mjs';
|
|
18
|
+
import { TOKEN_RE, isDir, isFile, rglob, sortedUnique, withTempDir, within } from './scan.mjs';
|
|
19
|
+
|
|
20
|
+
// --------------------------------------------------------------------------------------
|
|
21
|
+
// validate — the generator's `--validate-only`, on this binary because it runs the doctor
|
|
22
|
+
// --------------------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `build._validate_sandbox_problems` — the unresolved-token / dead-link / non-hermetic-link
|
|
26
|
+
* scan over an already-rendered sandbox tree.
|
|
27
|
+
*
|
|
28
|
+
* A NEAR-TWIN OF `checkBuild` ABOVE, AND DELIBERATELY NOT IT, because the reference is two
|
|
29
|
+
* functions and they differ in three observable ways: the token list is `sorted(set(...))`
|
|
30
|
+
* here and bare `set(...)` there, the message carries no `[theme]` prefix (the caller adds
|
|
31
|
+
* `[emit]` instead), and an unreadable file is SKIPPED rather than raised. Python duplicates
|
|
32
|
+
* the loop because `build.py` cannot import the harness tree; here the two live in one file
|
|
33
|
+
* and the two now sit one file apart (`checkBuild` is in `checks-build.mjs`); the
|
|
34
|
+
* duplication is eight lines — every primitive underneath (`rglob`, `stripCode`,
|
|
35
|
+
* `linkProblems`, `readText`) is the shared one, which is the half that could actually drift.
|
|
36
|
+
*/
|
|
37
|
+
export function validateSandboxProblems(sandbox) {
|
|
38
|
+
const out = resolvePath(sandbox);
|
|
39
|
+
const problems = [];
|
|
40
|
+
for (const md of rglob(out)) {
|
|
41
|
+
if (!md.endsWith('.md') || !isFile(md)) continue;
|
|
42
|
+
const rel = path.relative(out, md);
|
|
43
|
+
if (validateIsVendored(rel)) continue;
|
|
44
|
+
let text;
|
|
45
|
+
// `except (OSError, UnicodeDecodeError): continue` — binary or unreadable, nothing here.
|
|
46
|
+
try { text = readText(md); } catch { continue; }
|
|
47
|
+
for (const tok of sortedUnique(text.match(TOKEN_RE) ?? [])) {
|
|
48
|
+
problems.push(`unresolved token ${tok} in ${rel}`);
|
|
49
|
+
}
|
|
50
|
+
problems.push(...linkProblems(md, text, out, rel));
|
|
51
|
+
}
|
|
52
|
+
return problems;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `subprocess.run(..., capture_output=True, text=True)` around an IN-PROCESS call.
|
|
57
|
+
*
|
|
58
|
+
* The reference shells to `harness.py doctor`; this calls `cmdDoctor` directly, and the
|
|
59
|
+
* difference has to be undone in one place: a child's `capture_output` hands the parent
|
|
60
|
+
* UNIVERSAL-NEWLINE text, so `r.stdout` holds `\n` on Windows even though the child wrote
|
|
61
|
+
* `\r\n`. `printOut` inside `cmdDoctor` writes `\r\n`. Without the fold, the `.strip()` below
|
|
62
|
+
* would leave every interior line CRLF and the re-print would double the translation.
|
|
63
|
+
*/
|
|
64
|
+
function captureStreams(fn) {
|
|
65
|
+
const chunks = { out: [], err: [] };
|
|
66
|
+
const realOut = process.stdout.write.bind(process.stdout);
|
|
67
|
+
const realErr = process.stderr.write.bind(process.stderr);
|
|
68
|
+
process.stdout.write = (c) => { chunks.out.push(String(c)); return true; };
|
|
69
|
+
process.stderr.write = (c) => { chunks.err.push(String(c)); return true; };
|
|
70
|
+
let code;
|
|
71
|
+
try {
|
|
72
|
+
code = fn();
|
|
73
|
+
} finally {
|
|
74
|
+
process.stdout.write = realOut;
|
|
75
|
+
process.stderr.write = realErr;
|
|
76
|
+
}
|
|
77
|
+
const decode = (xs) => xs.join('').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
78
|
+
return { code, out: decode(chunks.out), err: decode(chunks.err) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `build._validate_only` — render + emit the requested target into a throwaway sandbox, run
|
|
83
|
+
* every validation a real build would gate on, print what WOULD have been written, and return
|
|
84
|
+
* the exit code. Nothing under the sandbox survives, and no marker, manifest or registry row
|
|
85
|
+
* is ever written for real.
|
|
86
|
+
*
|
|
87
|
+
* WHY IT IS HERE AND NOT ON THE GENERATOR DRIVER, which is where its flag lives. The
|
|
88
|
+
* source-tree half of the check IS the doctor, and this module starts a process (`node --check`
|
|
89
|
+
* over the OpenCode plugins). `bin/build-driver.mjs` is under a transitive ban on reaching any
|
|
90
|
+
* module that can, gated by `tests/unit/hook_cli.test.mjs`, which walks the driver's TRANSITIVE
|
|
91
|
+
* imports rather than grepping its own source — a source grep walks straight past an
|
|
92
|
+
* `import … from './x.mjs'` that reaches child_process. Siting the tool on the CLI
|
|
93
|
+
* binary, which already carries the doctor, crosses it with neither gate amended and without
|
|
94
|
+
* the dynamic `import()` that would evade the walk.
|
|
95
|
+
*
|
|
96
|
+
* THE MARKER GUARANTEE COMES FREE, and that is why `emitProjectInto`/`emitGlobalInto`/
|
|
97
|
+
* `buildInto` are used rather than `driverMain(['--emit', …])`: the driver writes
|
|
98
|
+
* `.geneseed-emit`/`.geneseed-footprint` and records an install-registry row AFTER the
|
|
99
|
+
* dispatch, so routing through it would register a temp directory that is deleted a
|
|
100
|
+
* millisecond later. The reference calls the emit functions directly for the same reason.
|
|
101
|
+
*
|
|
102
|
+
* THE EMIT'S OWN STDOUT IS NOT SWALLOWED. `globalEmitProblems` above wraps its call in
|
|
103
|
+
* `swallowStdout` and this deliberately does not: `_validate_only` has no `redirect_stdout`,
|
|
104
|
+
* so the ~200 lines of emit summary ARE part of what a dry run shows the operator.
|
|
105
|
+
*
|
|
106
|
+
* NO CELL REACHES THIS. The recorded corpus's argv never emitted `--validate-only`, so the
|
|
107
|
+
* flag was ungated across implementations for the whole port. What holds it now is
|
|
108
|
+
* `tests/unit/generate.test.mjs`, which drives `cmdValidate` directly and asserts its exit
|
|
109
|
+
* contract absolutely — this verb is deliberately outside the verb table, so nothing else does.
|
|
110
|
+
*/
|
|
111
|
+
export function cmdValidate(args) {
|
|
112
|
+
const problems = [];
|
|
113
|
+
const scan = withTempDir((tmpRaw) => {
|
|
114
|
+
// `.resolve()` mirrors `_check_build`'s: on Windows a temp dir can come back in 8.3
|
|
115
|
+
// short form (`RUNNER~1`) while every link TARGET below is resolved long-form, and
|
|
116
|
+
// `within` then rejects every relative link rather than only the escaping ones.
|
|
117
|
+
const tmp = resolvePath(tmpRaw);
|
|
118
|
+
// With a distinct `--root` the per-repo emits split their output — the bundle under
|
|
119
|
+
// `out`, the native layer under `root` — so the sandbox bundle nests INSIDE the sandbox
|
|
120
|
+
// root and the scan covers both layers.
|
|
121
|
+
const root = args.root ? path.join(tmp, 'root') : path.join(tmp, 'out');
|
|
122
|
+
const sandbox = args.root ? path.join(root, 'bundle') : root;
|
|
123
|
+
const cfgDir = path.join(tmp, 'cfg');
|
|
124
|
+
const emit = args.emit;
|
|
125
|
+
const opts = { theme: args.theme, footprint: args.footprint };
|
|
126
|
+
let scanDirs;
|
|
127
|
+
try {
|
|
128
|
+
if (emit.endsWith('-global')) {
|
|
129
|
+
emitGlobalInto(emit.slice(0, -'-global'.length), { ...opts, out: sandbox, cfgDir });
|
|
130
|
+
scanDirs = [cfgDir];
|
|
131
|
+
} else if (emit === 'files') {
|
|
132
|
+
buildInto({ ...opts, out: sandbox });
|
|
133
|
+
scanDirs = [sandbox];
|
|
134
|
+
} else {
|
|
135
|
+
emitProjectInto(emit, { ...opts, out: sandbox, root });
|
|
136
|
+
scanDirs = [root];
|
|
137
|
+
}
|
|
138
|
+
} catch (e) {
|
|
139
|
+
// `except SystemExit as e` — a DELIBERATE refusal from the render (an unknown theme,
|
|
140
|
+
// an incomplete source), interpolated as `{e}`.
|
|
141
|
+
//
|
|
142
|
+
// THE EXIT CODE AND NOT THE MESSAGE, measured rather than assumed: the reference's
|
|
143
|
+
// render half is ALREADY this port. `_build_render.build` calls `_build_core.run_node`,
|
|
144
|
+
// which ends `raise SystemExit(res.get("exit", 1))` — an INTEGER — so every refusal
|
|
145
|
+
// that crosses the seam arrives here as `SystemExit(1)` and `str(e)` is `'1'`, not the
|
|
146
|
+
// sentence `load_theme` wrote on stderr on the way out. A port that printed `e.message`
|
|
147
|
+
// says `unknown theme 'x'` where the reference says `1`. The corpus in
|
|
148
|
+
// `tests/test_maintainer_tools_parity.py` is what found it; nothing else could.
|
|
149
|
+
if (e && e.exitCode !== undefined) {
|
|
150
|
+
printOut(`[validate-only] render/emit FAILED for theme '${args.theme}' `
|
|
151
|
+
+ `emit '${emit}': ${e.exitCode}\n`);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const written = scanDirs.filter(isDir).flatMap((d) => rglob(d)).filter(isFile)
|
|
158
|
+
.sort(comparePaths);
|
|
159
|
+
printOut(`[validate-only] theme=${args.theme} emit=${emit} `
|
|
160
|
+
+ `footprint=${args.footprint}\n`);
|
|
161
|
+
printOut(`[validate-only] would write ${written.length} file(s) under ${args.out}`
|
|
162
|
+
+ (args.root ? ` (root ${args.root})` : '')
|
|
163
|
+
+ ' — nothing was actually written (sandboxed).\n');
|
|
164
|
+
if (args.verbose) {
|
|
165
|
+
let base = scanDirs[0];
|
|
166
|
+
for (const p of written) {
|
|
167
|
+
for (const d of scanDirs) {
|
|
168
|
+
if (within(p, d)) { base = d; break; }
|
|
169
|
+
}
|
|
170
|
+
printOut(` would write: ${path.relative(base, p)}\n`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const d of scanDirs) {
|
|
174
|
+
for (const p of validateSandboxProblems(d)) problems.push(`[${emit}] ${p}`);
|
|
175
|
+
}
|
|
176
|
+
return scanDirs;
|
|
177
|
+
}, 'geneseed-validate-');
|
|
178
|
+
if (scan === null) return 1;
|
|
179
|
+
|
|
180
|
+
// The source-tree-wide checks (theme parity, authoring gates, AGENT.md table parity,
|
|
181
|
+
// colour themes) do not depend on --out/--root/--emit at all and already live fully tested
|
|
182
|
+
// in the doctor, so they run through it rather than being re-derived. The reference SHELLS
|
|
183
|
+
// to `harness.py doctor --theme T --no-bundle`; one process is what this port has.
|
|
184
|
+
const doctor = captureStreams(() => cmdDoctor({ theme: args.theme, noBundle: true }));
|
|
185
|
+
if (stripWhitespace(doctor.out)) printOut(`${stripWhitespace(doctor.out)}\n`);
|
|
186
|
+
if (stripWhitespace(doctor.err)) printErr(`${stripWhitespace(doctor.err)}\n`);
|
|
187
|
+
if (doctor.code !== 0) {
|
|
188
|
+
problems.push(`[doctor] source-tree validation failed for theme '${args.theme}' `
|
|
189
|
+
+ '(see output above)');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (problems.length) {
|
|
193
|
+
printOut(`[validate-only] ${problems.length} problem(s):\n`);
|
|
194
|
+
for (const p of problems) printOut(` - ${p}\n`);
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
printOut('[validate-only] ok — would render and emit cleanly, no problems found.\n');
|
|
198
|
+
return 0;
|
|
199
|
+
}
|