geneseed 3.1.0 → 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 +110 -0
- package/DESIGN.md +31 -20
- package/README.md +2 -2
- package/SETUP.md +30 -3
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +88 -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 +113 -85
- package/docs/web/rules.md +2 -0
- package/docs/web/setup-choices.md +13 -1
- 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} +45 -31
- package/js/{render.mjs → build/render.mjs} +72 -11
- package/js/{checkout.mjs → build/source.mjs} +52 -11
- 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} +68 -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} +94 -69
- 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} +34 -20
- 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} +49 -37
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
- 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 +124 -89
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +88 -58
- 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 +29 -21
- package/js/web/routes.mjs +223 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/AGENT.md.tmpl +1 -1
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
- package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
- package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
- package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
- package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
- package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
- package/web/dist/assets/Laws-BK07NKw9.js +3 -0
- package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
- package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
- package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
- package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
- package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
- package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
- package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
- package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
- package/web/dist/assets/index-BE226Gv6.css +1 -0
- package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
- package/web/dist/assets/index-BgIs5eIa.js +12 -0
- package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +135 -69
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1712
- package/js/emit.mjs +0 -1410
- package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
- package/web/dist/assets/index-1pPSz7Kl.css +0 -1
- package/web/dist/assets/index-C-gb-q-r.js +0 -12
package/js/doctor.mjs
DELETED
|
@@ -1,1712 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `_harness_build.cmd_doctor` — validate the build.
|
|
3
|
-
*
|
|
4
|
-
* Fifteen `*Problems` checks over one theme (or every theme) — and `[doctor] ok — …` when all
|
|
5
|
-
* fifteen find nothing. That last sentence is why this module's gate looks the way it does, and
|
|
6
|
-
* it is worth stating here rather than only in the spec: **delete any single check
|
|
7
|
-
* and a clean run is byte-identical**, so a comparison of two runs cannot tell this
|
|
8
|
-
* file from a stub that prints the OK line. `tests/unit/harness.test.mjs` and
|
|
9
|
-
* `tests/unit/authoring_gates.test.mjs` therefore plant ONE FAULT PER CHECK, in a private copy
|
|
10
|
-
* of the checkout — see `copyCheckout` in `tests/helpers/cli_golden.mjs` for why a verb that
|
|
11
|
-
* reads `ROOT` needed a fixture kind that did not exist until the port.
|
|
12
|
-
*
|
|
13
|
-
* WHY THIS IS THE VERB THAT SPAWNS. `bin/geneseed-cli.mjs` was under a blanket transitive
|
|
14
|
-
* `child_process` ban, and its own docblock predicted the day a verb that genuinely spawns
|
|
15
|
-
* would land: this is it. `_authoring_problems` runs `node --check` over the OpenCode
|
|
16
|
-
* plugins, and there is no in-process equivalent — `vm.Script` compiles as a SCRIPT and every
|
|
17
|
-
* plugin is ESM (`import { promises as fs } from "node:fs"`), which `node --check` accepts
|
|
18
|
-
* through module-syntax detection and `vm.Script` rejects with a SyntaxError of its own. So
|
|
19
|
-
* the ban becomes an ALLOW-LIST of exactly the shape the hook entry already carries for
|
|
20
|
-
* `$GENESEED_LLM`: one binding, one call site, and a test that names the argv. What the ban
|
|
21
|
-
* was actually protecting — that the port never shells back to Python or to the generator —
|
|
22
|
-
* is now asserted directly instead of as a side effect.
|
|
23
|
-
*
|
|
24
|
-
* WHAT IT DOES NOT REACH, MEASURED RATHER THAN ASSUMED. `_doctor_collect` takes `on_progress`
|
|
25
|
-
* and `groups`; `cmd_doctor` passes neither, and they are the TUI's and the web's. The naive
|
|
26
|
-
* closure walk counts ~140 lines of curses drawing behind the first and a structured-view
|
|
27
|
-
* accumulator behind the second. `_ran` is reproduced here anyway, because it is four lines
|
|
28
|
-
* and dropping it would make P6's port of `_doctor_collect` a rewrite rather than a
|
|
29
|
-
* parameter; the progress callback is not, because nothing in this entry can supply one.
|
|
30
|
-
*/
|
|
31
|
-
import { existsSync, readFileSync, readdirSync, mkdtempSync, rmSync, statSync } from 'node:fs';
|
|
32
|
-
import { spawnSync } from 'node:child_process';
|
|
33
|
-
import os from 'node:os';
|
|
34
|
-
import path from 'node:path';
|
|
35
|
-
|
|
36
|
-
import {
|
|
37
|
-
CONFIG, PACK_ORDER, PLUGIN_SRC, ROOT, SRC, THEMES, WORKFLOW_SRC, makeCfg,
|
|
38
|
-
} from './checkout.mjs';
|
|
39
|
-
import {
|
|
40
|
-
buildInto, main as driverMain, emitGlobalInto, emitProjectInto,
|
|
41
|
-
} from '../bin/geneseed.mjs';
|
|
42
|
-
import { stripCapabilityLinks } from './emit.mjs';
|
|
43
|
-
import { hostCatalogsNatively, pyResolve } from './hosts.mjs';
|
|
44
|
-
import { EMIT_HOST_SCOPE, footprintOfDir, installedDefaults, themeFiles } from './installs.mjs';
|
|
45
|
-
import {
|
|
46
|
-
ENTITY_STATUSES, LAW_CLASS, LAW_CLASSES, SKILL_CLASS,
|
|
47
|
-
} from './inventory.mjs';
|
|
48
|
-
import {
|
|
49
|
-
VENDORED_SKILL_DIRS, descBlockProblem, firstBlockquote, isVendoredPath, validateIsVendored,
|
|
50
|
-
} from './native.mjs';
|
|
51
|
-
import { PALETTE_ROLES, colorThemeFiles } from './opencode.mjs';
|
|
52
|
-
import { STRUCTURE, renderAll } from './render.mjs';
|
|
53
|
-
import { hookShimPath, shimDeadPaths } from './settings.mjs';
|
|
54
|
-
import { pySplitLines } from './lib/udiff.mjs';
|
|
55
|
-
import { NO_WINDOW } from './lib/proc.mjs';
|
|
56
|
-
import {
|
|
57
|
-
comparePaths, normcase, parseJson, pyPrint, pyPrintErr, pyRepr, pyStr, pyStripSpace, pyWhich,
|
|
58
|
-
readText, withDiscardableStderr,
|
|
59
|
-
} from './lib/fs.mjs';
|
|
60
|
-
|
|
61
|
-
// --------------------------------------------------------------------------------------
|
|
62
|
-
// _harness_core's scanning primitives
|
|
63
|
-
// --------------------------------------------------------------------------------------
|
|
64
|
-
|
|
65
|
-
/** `_harness_core.TOKEN_RE` / `LINK_RE` / `ABS_LINK_RE`. `g` where `findall` is used. */
|
|
66
|
-
// Must stay in step with `js/render.mjs`'s TOKEN_RE: a token the renderer substitutes but this
|
|
67
|
-
// scan cannot see is an unresolved-token gate that silently stops gating. Digits are legal after
|
|
68
|
-
// the first character for `DOC_<PACK>_<n>`; a leading digit is still not a token.
|
|
69
|
-
const TOKEN_RE = /\{\{[A-Z_][A-Z0-9_]*\}\}/g;
|
|
70
|
-
const LINK_RE = /\]\((?!https?:\/\/|#)([^)]+)\)/g;
|
|
71
|
-
const ABS_LINK_RE = /^([A-Za-z]:[\\/]|\/|~)/;
|
|
72
|
-
const FENCE_RE = /```[\s\S]*?```/g;
|
|
73
|
-
const INLINE_CODE_RE = /`[^`]*`/g;
|
|
74
|
-
const COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
75
|
-
|
|
76
|
-
/** `_harness_core.strip_code` — fenced blocks, inline code and HTML comments, in that order. */
|
|
77
|
-
function stripCode(text) {
|
|
78
|
-
return text.replace(FENCE_RE, '').replace(COMMENT_RE, '').replace(INLINE_CODE_RE, '');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* `_harness_core._within` — `child.relative_to(parent)` without the exception.
|
|
83
|
-
*
|
|
84
|
-
* Segment-wise and through `normcase`, because that is what `PurePath` comparison does: a
|
|
85
|
-
* bundle at `C:\Temp\X` contains `c:\temp\x\a.md` on Windows and does not on Linux. A
|
|
86
|
-
* `startsWith` on the raw strings would additionally call `/tmp/bundle2` a child of
|
|
87
|
-
* `/tmp/bundle`, which is the classic version of this bug.
|
|
88
|
-
*/
|
|
89
|
-
function within(child, parent) {
|
|
90
|
-
const c = normcase(child).split(/[\\/]/);
|
|
91
|
-
const p = normcase(parent).split(/[\\/]/);
|
|
92
|
-
return p.length <= c.length && p.every((seg, i) => c[i] === seg);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
96
|
-
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
97
|
-
|
|
98
|
-
/** `sorted(d.glob(pat))`, or `[]` for a directory that is not there. */
|
|
99
|
-
function globSorted(dir, filter) {
|
|
100
|
-
let names;
|
|
101
|
-
try { names = readdirSync(dir); } catch { return []; }
|
|
102
|
-
return names.filter(filter).map((n) => path.join(dir, n)).sort(comparePaths);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** `Path.rglob('*')` — every entry under `dir`, sorted as `sorted()` sorts paths. */
|
|
106
|
-
function rglob(dir) {
|
|
107
|
-
const out = [];
|
|
108
|
-
const walk = (d) => {
|
|
109
|
-
let entries;
|
|
110
|
-
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
111
|
-
for (const e of entries.map((x) => x.name).sort(comparePaths)) {
|
|
112
|
-
const p = path.join(d, e);
|
|
113
|
-
out.push(p);
|
|
114
|
-
if (isDir(p)) walk(p);
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
walk(dir);
|
|
118
|
-
return out.sort(comparePaths);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// `withDiscardableStderr` was defined here while `renderedProblems` was its only caller.
|
|
122
|
-
// `js/hooks.mjs` became the second and third when the hook started CATCHING `expanduser`'s
|
|
123
|
-
// refusal, so it moved beside `pyPrintErr` in `js/lib/fs.mjs` — the writes it intercepts —
|
|
124
|
-
// and its docblock carries the "not for silencing noise" warning with it.
|
|
125
|
-
|
|
126
|
-
/** `Path.stem` — the basename with its last suffix removed. */
|
|
127
|
-
const stemOf = (p) => path.basename(p, path.extname(p));
|
|
128
|
-
|
|
129
|
-
/** `sorted(set)` over strings, the ordering every problem list is emitted in. */
|
|
130
|
-
const sortedUnique = (xs) => [...new Set(xs)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
131
|
-
/** `sorted(probs)` — SORTED, not deduped, which is what `_ran` fills a group with. */
|
|
132
|
-
const sortedProblems = (xs) => [...xs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* `k in d` for a Python dict — OWN keys only.
|
|
136
|
-
*
|
|
137
|
-
* Every membership test in this file reads a key that came out of a JSON document or a source
|
|
138
|
-
* filename, and a bare `k in obj` would answer true for `constructor`, `toString` and the rest
|
|
139
|
-
* of `Object.prototype`. A theme defining a `toString` voice token, or a skill file named
|
|
140
|
-
* `valueOf.md`, would then be reported as missing by the reference and not by this port — a
|
|
141
|
-
* divergence no cell can reach and no reviewer would look for.
|
|
142
|
-
*/
|
|
143
|
-
const has = (obj, k) => Object.prototype.hasOwnProperty.call(obj, k);
|
|
144
|
-
|
|
145
|
-
// --------------------------------------------------------------------------------------
|
|
146
|
-
// the checks
|
|
147
|
-
// --------------------------------------------------------------------------------------
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* `_harness_build._link_problems` — dead links AND non-hermetic ones.
|
|
151
|
-
*
|
|
152
|
-
* Hermeticity is what lets a bundle be copied or subtree-split into any repo; a link that
|
|
153
|
-
* escapes `out` breaks it silently. The two are separate messages because they have separate
|
|
154
|
-
* causes, and a target that merely does not exist is the DEAD one — the absolute check runs
|
|
155
|
-
* first and returns, so `~/x.md` never reaches the filesystem at all.
|
|
156
|
-
*/
|
|
157
|
-
export function linkProblems(md, text, out, rel) {
|
|
158
|
-
const problems = [];
|
|
159
|
-
for (const m of stripCode(text).matchAll(LINK_RE)) {
|
|
160
|
-
const link = m[1];
|
|
161
|
-
const raw = link.split('#')[0].trim();
|
|
162
|
-
if (!raw) continue;
|
|
163
|
-
if (ABS_LINK_RE.test(raw)) {
|
|
164
|
-
problems.push(`non-hermetic absolute link '${link}' in ${rel}`);
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
// `(md.parent / raw).resolve()` — `pyResolve`, not `path.resolve`, because Python's
|
|
168
|
-
// canonicalises the symlinks in the part that exists and this comparison is against an
|
|
169
|
-
// `out` that went through the same call.
|
|
170
|
-
const target = pyResolve(path.join(path.dirname(md), raw));
|
|
171
|
-
if (!existsSync(target)) problems.push(`dead link '${link}' in ${rel}`);
|
|
172
|
-
else if (!within(target, out)) {
|
|
173
|
-
problems.push(`non-hermetic link '${link}' escapes the bundle in ${rel}`);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return problems;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* `_harness_build._check_build` — scan one rendered tree for unresolved tokens and bad links.
|
|
181
|
-
*
|
|
182
|
-
* `isVendored` decides which relative shape counts as a vendored folder, and the two callers
|
|
183
|
-
* pass different ones ON PURPOSE: a `files`/opencode-global bundle nests skills at
|
|
184
|
-
* `skills/<name>/…` and a per-repo native layer one level deeper at `.claude/skills/<name>/…`.
|
|
185
|
-
* Vendored folders are verbatim upstream docs carrying their own license, and their internal
|
|
186
|
-
* cross-links point at the upstream project's own files, so they are exempt.
|
|
187
|
-
*
|
|
188
|
-
* `rel` is `str(Path)`, i.e. the platform's separator, because that is what the Python prints
|
|
189
|
-
* — every emit-scan cell in the acceptance matrix names a backslash on Windows.
|
|
190
|
-
*/
|
|
191
|
-
export function checkBuild(themeName, out, isVendored = isVendoredPath) {
|
|
192
|
-
const outAbs = pyResolve(out);
|
|
193
|
-
const problems = [];
|
|
194
|
-
for (const md of rglob(outAbs)) {
|
|
195
|
-
if (!md.endsWith('.md') || !isFile(md)) continue;
|
|
196
|
-
const rel = path.relative(outAbs, md);
|
|
197
|
-
if (isVendored(rel)) continue;
|
|
198
|
-
const text = readText(md);
|
|
199
|
-
// `set(TOKEN_RE.findall(text))` — Python iterates the set in ITS order, and the whole
|
|
200
|
-
// list is sorted by `_doctor_collect` before printing, so only uniqueness travels.
|
|
201
|
-
for (const tok of new Set(text.match(TOKEN_RE) ?? [])) {
|
|
202
|
-
problems.push(`[${themeName}] unresolved token ${tok} in ${rel}`);
|
|
203
|
-
}
|
|
204
|
-
for (const p of linkProblems(md, text, outAbs, rel)) problems.push(`[${themeName}] ${p}`);
|
|
205
|
-
}
|
|
206
|
-
return problems;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* `_harness_build._theme_parity_problems` — every theme defines the same VOICE keys.
|
|
211
|
-
*
|
|
212
|
-
* A token present in one theme map and missing from another renders as a raw `{{TOKEN}}`
|
|
213
|
-
* only in the files that use it and only under that theme, so a plain build can miss it
|
|
214
|
-
* entirely. The maps are compared directly, in every mode, whatever `--theme` scoped the rest
|
|
215
|
-
* of the run to.
|
|
216
|
-
*/
|
|
217
|
-
export function themeParityProblems() {
|
|
218
|
-
const themes = new Map();
|
|
219
|
-
for (const p of themeFiles()) {
|
|
220
|
-
let doc;
|
|
221
|
-
try { doc = JSON.parse(readText(p)); } catch (e) {
|
|
222
|
-
return [`[themes] ${path.basename(p)} unreadable: ${e.message}`];
|
|
223
|
-
}
|
|
224
|
-
themes.set(stemOf(p), doc);
|
|
225
|
-
}
|
|
226
|
-
if (themes.size < 2) return [];
|
|
227
|
-
const allKeys = new Set();
|
|
228
|
-
for (const t of themes.values()) for (const k of Object.keys(t)) allKeys.add(k);
|
|
229
|
-
const problems = [];
|
|
230
|
-
for (const [name, t] of themes) {
|
|
231
|
-
for (const k of [...allKeys].filter((x) => !has(t, x)).sort()) {
|
|
232
|
-
problems.push(`[themes] '${name}' missing key {${k}} (defined in another theme)`);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
return problems;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** `_harness_build._HEX_RE`. Anchored at BOTH ends, so `#12345` and `#1122334` both fail. */
|
|
239
|
-
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* `_harness_build._color_theme_problems` — every curated colour theme carries the full
|
|
243
|
-
* palette, in 6-digit hex. Voice-theme parity, for colours.
|
|
244
|
-
*
|
|
245
|
-
* TWO OF ITS THREE ARMS ARE UNREACHABLE FROM ANY CELL, and it is doctor's own ordering that
|
|
246
|
-
* makes them so: the opencode-global emit reads the same files (`writeColorThemes` ->
|
|
247
|
-
* `palette[role]`) and runs BEFORE this check, so a palette that is missing a role, or
|
|
248
|
-
* missing entirely, kills the emit rather than reaching here. The consumer is stricter than
|
|
249
|
-
* the gate. Only the third arm — a value present and not `#rrggbb` — passes the emit through
|
|
250
|
-
* to be reported.
|
|
251
|
-
*/
|
|
252
|
-
export function colorThemeProblems() {
|
|
253
|
-
const problems = [];
|
|
254
|
-
for (const p of colorThemeFiles(makeCfg())) {
|
|
255
|
-
let spec;
|
|
256
|
-
try { spec = JSON.parse(readText(p)); } catch (e) {
|
|
257
|
-
problems.push(`[colors] ${path.basename(p)} unreadable: ${e.message}`);
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
const pal = spec.palette;
|
|
261
|
-
if (pal === null || typeof pal !== 'object' || Array.isArray(pal)) {
|
|
262
|
-
problems.push(`[colors] ${path.basename(p)} has no 'palette' object`);
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
for (const role of [...PALETTE_ROLES].filter((r) => !has(pal, r)).sort()) {
|
|
266
|
-
problems.push(`[colors] '${stemOf(p)}' palette missing role '${role}'`);
|
|
267
|
-
}
|
|
268
|
-
for (const [role, val] of Object.entries(pal)) {
|
|
269
|
-
if (!(typeof val === 'string' && HEX_RE.test(val))) {
|
|
270
|
-
problems.push(`[colors] '${stemOf(p)}' role '${role}' is not #rrggbb hex: ${pyRepr(val)}`);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
return problems;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* `_harness_build._rendered_problems` — a committed bundle must match a fresh render of
|
|
279
|
-
* `src/` for its OWN recorded theme, emit and footprint.
|
|
280
|
-
*
|
|
281
|
-
* Doctor's temp builds never touch the committed bundle, so drift there is invisible to
|
|
282
|
-
* every other check. All three values are read back off the bundle's own markers rather than
|
|
283
|
-
* assumed, and each for the same reason: assuming one reports every file as drifted the
|
|
284
|
-
* moment that default moves, which is a diagnosis about this function rather than about the
|
|
285
|
-
* bundle. The emit marker is the one that took longest to learn — a host that catalogues
|
|
286
|
-
* capabilities to the model itself gets AGENT.md's tables collapsed to a pointer, and the
|
|
287
|
-
* OpenCode emits additionally strip the per-row spec links, so rendering the portable shape
|
|
288
|
-
* and comparing it against an OpenCode bundle reports AGENT.md stale on EVERY run with no
|
|
289
|
-
* rebuild able to clear it.
|
|
290
|
-
*
|
|
291
|
-
* Host-state files (context.json, MEMORY.md, the markers) are created once and never
|
|
292
|
-
* rendered, so they are not in the render set and are correctly ignored. Notebook files
|
|
293
|
-
* except its `.gitignore` are seed-once and agent-owned after the first build, so they are
|
|
294
|
-
* compared only for EXISTENCE.
|
|
295
|
-
*/
|
|
296
|
-
export function renderedProblems(bundle) {
|
|
297
|
-
if (!isDir(bundle)) return [];
|
|
298
|
-
const cfg = makeCfg();
|
|
299
|
-
const marker = path.join(bundle, '.geneseed-theme');
|
|
300
|
-
let themeName;
|
|
301
|
-
if (existsSync(marker)) themeName = readText(marker).trim();
|
|
302
|
-
else if (existsSync(CONFIG)) themeName = parseJson(readText(CONFIG))?.theme ?? 'neutral';
|
|
303
|
-
else themeName = 'neutral';
|
|
304
|
-
let emit;
|
|
305
|
-
try { emit = readText(path.join(bundle, '.geneseed-emit')).trim(); } catch { emit = ''; }
|
|
306
|
-
const host = (EMIT_HOST_SCOPE.get(emit) ?? ['', ''])[0];
|
|
307
|
-
let items;
|
|
308
|
-
try {
|
|
309
|
-
// THE FIRST CALLER IN THE PORT THAT CATCHES A REFUSAL, and that is why the stderr is
|
|
310
|
-
// buffered. `sys.exit(msg)` attaches the message to the EXCEPTION and the interpreter
|
|
311
|
-
// prints it on the way out, so a Python caller that catches SystemExit sees no output at
|
|
312
|
-
// all; this port's convention is to write at the raise site (`loadTheme`, and its
|
|
313
|
-
// docblock says why), which is identical for every caller that lets the throw propagate
|
|
314
|
-
// and wrong for exactly this one. Buffered and replayed on success, so a WARN the render
|
|
315
|
-
// legitimately emits still reaches the user, and discarded with the refusal it belongs to.
|
|
316
|
-
({ items } = withDiscardableStderr(() => renderAll(cfg, themeName, {
|
|
317
|
-
footprint: footprintOfDir(bundle), nativeCatalog: hostCatalogsNatively(host),
|
|
318
|
-
})));
|
|
319
|
-
} catch (e) {
|
|
320
|
-
// `except SystemExit` — `effectiveTheme` refuses an unknown theme, and the driver's
|
|
321
|
-
// marker for a deliberate refusal is the `exitCode` this port already uses everywhere.
|
|
322
|
-
if (e && e.exitCode !== undefined) {
|
|
323
|
-
return [`[rendered] cannot render theme '${themeName}' for ${path.basename(bundle)}/`];
|
|
324
|
-
}
|
|
325
|
-
throw e;
|
|
326
|
-
}
|
|
327
|
-
const problems = [];
|
|
328
|
-
const nbDirname = STRUCTURE.DIR_NOTEBOOK ?? 'notebook';
|
|
329
|
-
for (const { rel: outRel, text: rendered, src } of items) {
|
|
330
|
-
const dest = path.join(bundle, outRel);
|
|
331
|
-
const parts = outRel.split('/');
|
|
332
|
-
const name = path.basename(bundle);
|
|
333
|
-
if (!existsSync(dest)) {
|
|
334
|
-
problems.push(`[rendered] ${name}/${outRel} missing — rebuild the bundle`);
|
|
335
|
-
} else if (parts[0] === nbDirname && parts[parts.length - 1] !== '.gitignore') {
|
|
336
|
-
continue; // seed-once, agent-owned: a rewrite is not drift
|
|
337
|
-
} else if (rendered !== null) {
|
|
338
|
-
// The OpenCode emits de-link AGENT.md's per-row table entries after rendering, so the
|
|
339
|
-
// bundle on disk legitimately differs from `renderAll`'s output for this one file.
|
|
340
|
-
const text = host === 'opencode' && outRel === 'AGENT.md'
|
|
341
|
-
? stripCapabilityLinks(cfg, rendered) : rendered;
|
|
342
|
-
if (readText(dest) !== text) {
|
|
343
|
-
problems.push(`[rendered] ${name}/${outRel} stale (differs from a fresh render) `
|
|
344
|
-
+ '— rebuild');
|
|
345
|
-
}
|
|
346
|
-
} else if (!readFileSync(dest).equals(readFileSync(src))) {
|
|
347
|
-
problems.push(`[rendered] ${name}/${outRel} stale — rebuild`);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
return problems;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/** `_harness_build._src_stems` — spec stems under `src/<folder>`, minus `_`-scaffolds. */
|
|
354
|
-
function srcStems(folder) {
|
|
355
|
-
const d = path.join(SRC, folder);
|
|
356
|
-
if (!isDir(d)) return new Set();
|
|
357
|
-
return new Set(globSorted(d, (n) => n.endsWith('.md') && !n.startsWith('_')).map(stemOf));
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* `_harness_build._registry_keys` — every entity the registry must describe.
|
|
362
|
-
*
|
|
363
|
-
* The flat agent and skill specs plus the vendored skill FOLDERS, which have no flat spec and
|
|
364
|
-
* are still shipped capabilities. Keyed `<folder>/<stem>`, the shape doctor already prints.
|
|
365
|
-
*/
|
|
366
|
-
function registryKeys() {
|
|
367
|
-
const keys = new Set();
|
|
368
|
-
for (const s of srcStems('agents')) keys.add(`agents/${s}`);
|
|
369
|
-
for (const s of srcStems('skills')) keys.add(`skills/${s}`);
|
|
370
|
-
for (const d of VENDORED_SKILL_DIRS) keys.add(`skills/${d}`);
|
|
371
|
-
return keys;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
375
|
-
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
376
|
-
|
|
377
|
-
// `ENTITY_STATUSES`, `LAW_CLASSES`, `LAW_CLASS` and `SKILL_CLASS` moved to
|
|
378
|
-
// `js/inventory.mjs` in P6c — see the import above. They crossed HERE in P5g because
|
|
379
|
-
// doctor's authoring gates are what validate them, and they belong where Python keeps
|
|
380
|
-
// them (`_harness_tui`) now that the catalog reads them too. The gates below are
|
|
381
|
-
// unchanged, which is what licensed the move: a copy would have stopped being the
|
|
382
|
-
// value under test the first time one of them was edited.
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* `_harness_build._registry_problems` — `registry.json` describes exactly what `src/`
|
|
386
|
-
* provides, with well-formed fields.
|
|
387
|
-
*
|
|
388
|
-
* Two-way, like every anti-drift gate here: a new spec with no row and a row whose spec is
|
|
389
|
-
* gone are both errors. Without the second half a shipped skill would show no lifecycle
|
|
390
|
-
* status in the TUI and web catalogs while doctor stayed green. `last_verified` may be empty
|
|
391
|
-
* — nothing writes it yet — but when set it must be a date.
|
|
392
|
-
*/
|
|
393
|
-
export function registryProblems() {
|
|
394
|
-
let doc;
|
|
395
|
-
const file = path.join(ROOT, 'registry.json');
|
|
396
|
-
let raw;
|
|
397
|
-
try { raw = readText(file); } catch (e) {
|
|
398
|
-
return [`[authoring] registry.json unreadable: ${e.message}`];
|
|
399
|
-
}
|
|
400
|
-
try { doc = JSON.parse(raw); } catch (e) {
|
|
401
|
-
return [`[authoring] registry.json is not valid JSON: ${e.message}`];
|
|
402
|
-
}
|
|
403
|
-
const entities = doc && typeof doc === 'object' && !Array.isArray(doc) ? doc.entities : null;
|
|
404
|
-
if (entities === null || typeof entities !== 'object' || Array.isArray(entities)) {
|
|
405
|
-
return ["[authoring] registry.json has no 'entities' object"];
|
|
406
|
-
}
|
|
407
|
-
const expected = registryKeys();
|
|
408
|
-
const present = new Set(Object.keys(entities));
|
|
409
|
-
const problems = [...expected].filter((k) => !present.has(k)).sort()
|
|
410
|
-
.map((key) => `[authoring] ${key} has no row in registry.json`);
|
|
411
|
-
problems.push(...[...present].filter((k) => !expected.has(k)).sort()
|
|
412
|
-
.map((key) => `[authoring] registry.json lists '${key}' but no such entity exists`));
|
|
413
|
-
for (const key of [...expected].filter((k) => present.has(k)).sort()) {
|
|
414
|
-
const row = entities[key];
|
|
415
|
-
if (row === null || typeof row !== 'object' || Array.isArray(row)) {
|
|
416
|
-
problems.push(`[authoring] registry.json['${key}'] is not an object`);
|
|
417
|
-
continue;
|
|
418
|
-
}
|
|
419
|
-
// `row.get(...)` is None for an absent key, and `!r` renders that as `None`; a bare JS
|
|
420
|
-
// lookup is `undefined`, which `pyRepr` would not recognise as one.
|
|
421
|
-
if (!ENTITY_STATUSES.includes(row.status)) {
|
|
422
|
-
problems.push(`[authoring] registry.json['${key}'].status ${pyRepr(row.status ?? null)} `
|
|
423
|
-
+ `is not one of ${pyRepr(ENTITY_STATUSES)}`);
|
|
424
|
-
}
|
|
425
|
-
if (!SEMVER_RE.test(pyStr(row.version ?? ''))) {
|
|
426
|
-
problems.push(`[authoring] registry.json['${key}'].version `
|
|
427
|
-
+ `${pyRepr(row.version ?? null)} is not a semver (N.N.N)`);
|
|
428
|
-
}
|
|
429
|
-
if (!pyStr(row.owner ?? '').trim()) {
|
|
430
|
-
problems.push(`[authoring] registry.json['${key}'] has no owner`);
|
|
431
|
-
}
|
|
432
|
-
for (const field of ['added', 'last_verified']) {
|
|
433
|
-
const value = pyStr(row[field] ?? '');
|
|
434
|
-
if (value && !ISO_DATE_RE.test(value)) {
|
|
435
|
-
problems.push(`[authoring] registry.json['${key}'].${field} ${pyRepr(value)} is `
|
|
436
|
-
+ 'not an ISO date (YYYY-MM-DD) or empty');
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
return problems;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/**
|
|
444
|
-
* `_harness_build._SECRET_PATTERNS` — credential shapes, by the prefix each issuer stamps,
|
|
445
|
-
* plus the generic assignment. Deliberately narrow: a gate that cries wolf gets disabled.
|
|
446
|
-
*
|
|
447
|
-
* `(?i)` becomes the `i` flag on the last one only, which is where the Python puts it.
|
|
448
|
-
*/
|
|
449
|
-
const SECRET_PATTERNS = [
|
|
450
|
-
['Anthropic API key', /sk-ant-[A-Za-z0-9_-]{20,}/],
|
|
451
|
-
['OpenAI-style API key', /\bsk-[A-Za-z0-9]{32,}/],
|
|
452
|
-
['GitHub token', /\bgh[pousr]_[A-Za-z0-9]{36}\b/],
|
|
453
|
-
['AWS access key id', /\bAKIA[0-9A-Z]{16}\b/],
|
|
454
|
-
['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
|
|
455
|
-
['private key block', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
|
|
456
|
-
['assigned credential',
|
|
457
|
-
/\b(password|passwd|secret|api[_-]?key|access[_-]?token)\s*[:=]\s*["'][^"'\s]{8,}["']/i],
|
|
458
|
-
];
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* `_harness_build._secret_problems` — no credential may ship.
|
|
462
|
-
*
|
|
463
|
-
* Sweeps the four trees `sourceFingerprint()` hashes, which is exactly the material that ends
|
|
464
|
-
* up inside a user's bundle. Reports file:line and the KIND only — echoing the matched text
|
|
465
|
-
* would republish the secret into every CI log.
|
|
466
|
-
*/
|
|
467
|
-
export function secretProblems() {
|
|
468
|
-
const problems = [];
|
|
469
|
-
for (const root of [SRC, THEMES, PLUGIN_SRC, WORKFLOW_SRC]) {
|
|
470
|
-
if (!isDir(root)) continue;
|
|
471
|
-
for (const p of rglob(root)) {
|
|
472
|
-
if (!isFile(p) || p.split(path.sep).includes('__pycache__')) continue;
|
|
473
|
-
// `except (OSError, UnicodeDecodeError): continue` — binary or unreadable, nothing to
|
|
474
|
-
// scan. `readText` would substitute U+FFFD where Python RAISES, so the strict decode is
|
|
475
|
-
// the check rather than a catch, and the newline folding `Path.read_text` does has to
|
|
476
|
-
// be applied by hand afterwards for the LINE NUMBERS to agree.
|
|
477
|
-
let raw;
|
|
478
|
-
try { raw = readFileSync(p); } catch { continue; }
|
|
479
|
-
let s;
|
|
480
|
-
try { s = new TextDecoder('utf-8', { fatal: true }).decode(raw); } catch { continue; }
|
|
481
|
-
s = s.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
482
|
-
// `path.relative` returns a `..`-prefixed path where `relative_to` RAISES; the fallback
|
|
483
|
-
// is the Python's, for a tree outside the repo (a test tmpdir).
|
|
484
|
-
const r = path.relative(ROOT, p);
|
|
485
|
-
const rel = (r.startsWith('..') || path.isAbsolute(r) ? p : r)
|
|
486
|
-
.split(path.sep).join('/');
|
|
487
|
-
// `str.splitlines()`, not `split('\n')`: it also breaks on VT, FF, NEL and the two
|
|
488
|
-
// Unicode separators, and the line NUMBER is printed.
|
|
489
|
-
pySplitLines(s).forEach((line, i) => {
|
|
490
|
-
for (const [label, pattern] of SECRET_PATTERNS) {
|
|
491
|
-
if (pattern.test(line)) {
|
|
492
|
-
problems.push(`[authoring] possible ${label} in ${rel}:${i + 1} — a `
|
|
493
|
-
+ 'credential must never be committed');
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
return problems;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/**
|
|
503
|
-
* `_harness_build._shim_problems` — the hook shim exists and points at something real.
|
|
504
|
-
*
|
|
505
|
-
* This gate exists because the shim DISARMED an accidental safety net. When the emitted hook
|
|
506
|
-
* command still carried the checkout path, moving the checkout made that command
|
|
507
|
-
* non-canonical and the next emit's prune-and-rewire repaired every install by itself. Now
|
|
508
|
-
* the config is invariant under a move and the stale path lives in the shim body, which no
|
|
509
|
-
* other gate reads (the build scan only walks `*.md`). Without this a moved checkout leaves
|
|
510
|
-
* every gate silently dead: the hooks still fire, the shim still runs, and the interpreter
|
|
511
|
-
* reports "no such file" into a channel nobody reads.
|
|
512
|
-
*
|
|
513
|
-
* An ABSENT shim is not a problem: a source checkout that has never emitted has no reason to
|
|
514
|
-
* own one.
|
|
515
|
-
*/
|
|
516
|
-
export function shimProblems() {
|
|
517
|
-
const p = hookShimPath();
|
|
518
|
-
if (!isFile(p)) return [];
|
|
519
|
-
let body;
|
|
520
|
-
try { body = readText(p); } catch (e) {
|
|
521
|
-
return [`[shim] ${p} exists but cannot be read (${e.message}) — hooks may be dead`];
|
|
522
|
-
}
|
|
523
|
-
// The body is machine-generated and quotes the two baked paths — plus, on POSIX, the argv
|
|
524
|
-
// placeholder `"$@"`, which is not a path. `shimDeadPaths` is the single owner of that rule;
|
|
525
|
-
// `hookPrefix` is its other reader, and the two must not drift.
|
|
526
|
-
return shimDeadPaths(body).map(
|
|
527
|
-
(m) => `[shim] ${p} pointed at ${m}, which does not exist — every hook in every `
|
|
528
|
-
+ "install was dead (the checkout most likely moved). This run's own "
|
|
529
|
-
+ 'emit has refreshed it; no further action needed.',
|
|
530
|
-
);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
const HEX40_RE = /^[0-9a-f]{40}$/;
|
|
534
|
-
/** Refs that move under the pin — re-copying months later changes the shipped skill. */
|
|
535
|
-
const MOVING_REFS = new Set(['main', 'master', 'develop', 'dev', 'head', 'latest', 'trunk']);
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* `_harness_build._vendor_pin_problems` — every vendored skill folder records where it came
|
|
539
|
-
* from, under which license, and at WHICH immutable commit.
|
|
540
|
-
*
|
|
541
|
-
* First-party folders that merely ride the vendored mechanism for their multi-file layout
|
|
542
|
-
* declare that instead (`**Upstream:** this …`) and are exempt from the pin: there is no
|
|
543
|
-
* upstream to drift against.
|
|
544
|
-
*/
|
|
545
|
-
export function vendorPinProblems() {
|
|
546
|
-
const problems = [];
|
|
547
|
-
for (const name of VENDORED_SKILL_DIRS) {
|
|
548
|
-
const folder = path.join(SRC, 'skills', name);
|
|
549
|
-
if (!isDir(folder)) {
|
|
550
|
-
problems.push(`[authoring] VENDORED_SKILL_DIRS lists '${name}' but `
|
|
551
|
-
+ `src/skills/${name}/ does not exist`);
|
|
552
|
-
continue;
|
|
553
|
-
}
|
|
554
|
-
let text;
|
|
555
|
-
try { text = readText(path.join(folder, 'VENDOR.md')); } catch {
|
|
556
|
-
problems.push(`[authoring] skills/${name}/ has no VENDOR.md — its upstream, `
|
|
557
|
-
+ 'pinned commit and license are unrecorded');
|
|
558
|
-
continue;
|
|
559
|
-
}
|
|
560
|
-
if (!/\*\*License:\*\*/.test(text)) {
|
|
561
|
-
problems.push(`[authoring] skills/${name}/VENDOR.md records no '**License:**'`);
|
|
562
|
-
}
|
|
563
|
-
const upstream = /\*\*Upstream:\*\*\s*(\S+)/.exec(text);
|
|
564
|
-
if (!upstream) {
|
|
565
|
-
problems.push(`[authoring] skills/${name}/VENDOR.md declares no '**Upstream:**'`);
|
|
566
|
-
continue;
|
|
567
|
-
}
|
|
568
|
-
if (upstream[1].toLowerCase().startsWith('this')) continue; // first-party
|
|
569
|
-
const pin = /\*\*Commit:\*\*\s*(\S+)/.exec(text);
|
|
570
|
-
if (!pin) {
|
|
571
|
-
problems.push(`[authoring] skills/${name}/VENDOR.md has no '**Commit:**' pin`);
|
|
572
|
-
} else if (MOVING_REFS.has(pin[1].toLowerCase())) {
|
|
573
|
-
problems.push(`[authoring] skills/${name}/VENDOR.md pins '${pin[1]}', a `
|
|
574
|
-
+ 'moving branch — record the commit sha instead');
|
|
575
|
-
} else if (!HEX40_RE.test(pin[1].toLowerCase())) {
|
|
576
|
-
problems.push(`[authoring] skills/${name}/VENDOR.md pin '${pin[1]}' is not `
|
|
577
|
-
+ 'a 40-character commit sha');
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
return problems;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
const ROMAN_VALUES = { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 };
|
|
584
|
-
|
|
585
|
-
/**
|
|
586
|
-
* `_harness_build._roman_to_int`. Law headings number in Roman and the web ledger keys its
|
|
587
|
-
* per-rule copy by the Arabic equivalent, so the gate has to bridge the two. 0 on an
|
|
588
|
-
* unparseable numeral, which surfaces as a gate problem rather than a crash.
|
|
589
|
-
*/
|
|
590
|
-
export function romanToInt(num) {
|
|
591
|
-
let total = 0;
|
|
592
|
-
let prev = 0;
|
|
593
|
-
for (const ch of [...num.toUpperCase()].reverse()) {
|
|
594
|
-
const v = ROMAN_VALUES[ch];
|
|
595
|
-
if (v === undefined) return 0;
|
|
596
|
-
total += v < prev ? -v : v;
|
|
597
|
-
prev = Math.max(prev, v);
|
|
598
|
-
}
|
|
599
|
-
return total;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
/**
|
|
603
|
-
* `_harness_build._LAW_META_ROW` — `<arabic>: ['<class>', '<principle>'],` in the web page.
|
|
604
|
-
*
|
|
605
|
-
* Either quote style, because a principle carrying an apostrophe is double-quoted. Every
|
|
606
|
-
* `\s*` spans newlines and the trailing comma before `]` is optional, so a row a prettier has
|
|
607
|
-
* reflowed across several lines still matches — a gate that only understood the one-line form
|
|
608
|
-
* silently lost it and left the law's Principle unguarded.
|
|
609
|
-
*
|
|
610
|
-
* `(?m)` is the `m` flag; JS has no inline form. The `.` inside the captures must NOT match a
|
|
611
|
-
* newline (Python has no `re.S` here), which is JS's default.
|
|
612
|
-
*/
|
|
613
|
-
const LAW_META_ROW = /^[^\S\n]*(\d+):\s*\[\s*(['"])(.*?)\2\s*,\s*(['"])(.*?)\4\s*,?\s*\]\s*,?[^\S\n]*$/gm;
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* `_harness_build._law_meta_problems` — the web Laws ledger's per-rule copy stays complete.
|
|
617
|
-
*
|
|
618
|
-
* `LAW_META` holds each law's one-line Principle, copy that exists nowhere else in the tree,
|
|
619
|
-
* and a law with no row falls back to `['craft', '']`: the rule renders with a blank
|
|
620
|
-
* description and the wrong class chip, while everything upstream is fine. That is exactly
|
|
621
|
-
* how two laws shipped description-less.
|
|
622
|
-
*/
|
|
623
|
-
export function lawMetaProblems(lawNums, lawClass, lawClasses) {
|
|
624
|
-
const page = path.join(ROOT, 'web', 'src', 'pages', 'Laws.jsx');
|
|
625
|
-
let text;
|
|
626
|
-
try { text = readText(page); } catch (e) {
|
|
627
|
-
return [`[authoring] web/src/pages/Laws.jsx unreadable: ${e.message}`];
|
|
628
|
-
}
|
|
629
|
-
const block = /^const LAW_META = \{$([\s\S]*?)^\}$/m.exec(text);
|
|
630
|
-
if (!block) {
|
|
631
|
-
return ['[authoring] LAW_META literal not found in web/src/pages/Laws.jsx — the web '
|
|
632
|
-
+ 'Laws ledger\'s Principle column would have no gate'];
|
|
633
|
-
}
|
|
634
|
-
const meta = new Map();
|
|
635
|
-
for (const m of block[1].matchAll(LAW_META_ROW)) {
|
|
636
|
-
meta.set(Number(m[1]), [m[3], m[5]]);
|
|
637
|
-
}
|
|
638
|
-
const problems = [];
|
|
639
|
-
const seen = new Set();
|
|
640
|
-
for (const roman of lawNums) {
|
|
641
|
-
const n = romanToInt(roman);
|
|
642
|
-
seen.add(n);
|
|
643
|
-
if (!meta.has(n)) {
|
|
644
|
-
problems.push(`[authoring] laws/universal.md rule ${roman} (${n}) has no row in `
|
|
645
|
-
+ 'LAW_META (web/src/pages/Laws.jsx) — the web Laws ledger would '
|
|
646
|
-
+ 'render it with a blank Principle');
|
|
647
|
-
continue;
|
|
648
|
-
}
|
|
649
|
-
const [klass, principle] = meta.get(n);
|
|
650
|
-
if (!principle.trim()) {
|
|
651
|
-
problems.push(`[authoring] LAW_META[${n}] has an empty principle line — rule `
|
|
652
|
-
+ `${roman} would render with a blank description`);
|
|
653
|
-
}
|
|
654
|
-
if (!lawClasses.includes(klass)) {
|
|
655
|
-
problems.push(`[authoring] LAW_META[${n}] class '${klass}' is not a known class `
|
|
656
|
-
+ `${pyRepr(lawClasses)}`);
|
|
657
|
-
} else if (lawClass[roman] && klass !== lawClass[roman]) {
|
|
658
|
-
problems.push(`[authoring] LAW_META[${n}] classes rule ${roman} as '${klass}' but `
|
|
659
|
-
+ `LAW_CLASS says '${lawClass[roman]}'`);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
for (const n of [...meta.keys()].filter((k) => !seen.has(k)).sort((a, b) => a - b)) {
|
|
663
|
-
problems.push(`[authoring] LAW_META lists rule ${n} but laws/universal.md has no such rule`);
|
|
664
|
-
}
|
|
665
|
-
return problems;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/** `LAW_META_ROW`'s twin for a STRING key — `'<pack>.<n>': ['<pack>', '<principle>'],`. */
|
|
669
|
-
const DOCTRINE_META_ROW =
|
|
670
|
-
/^[^\S\n]*(['"])([a-z]+\.\d+)\1:\s*\[\s*(['"])(.*?)\3\s*,\s*(['"])(.*?)\5\s*,?\s*\]\s*,?[^\S\n]*$/gm;
|
|
671
|
-
|
|
672
|
-
/**
|
|
673
|
-
* `lawMetaProblems`' doctrine half — the console's per-rule Principle column stays complete.
|
|
674
|
-
*
|
|
675
|
-
* SAME DEFECT, ONE TIER OVER. `DOCTRINE_META` holds copy that exists nowhere else in the tree,
|
|
676
|
-
* and a rule with no row falls back to an empty principle: it renders with a blank description
|
|
677
|
-
* while every count upstream reads correct. Two invariants shipped exactly that way before
|
|
678
|
-
* `lawMetaProblems` existed, and 23 new rules is a much larger surface to lose one in.
|
|
679
|
-
*
|
|
680
|
-
* ⚠ THE CLASS ARM IS DIFFERENT FROM THE INVARIANTS'. An invariant's class is checked against
|
|
681
|
-
* `LAW_CLASSES`, a vocabulary of six; a doctrine rule has no second taxonomy — its class IS its
|
|
682
|
-
* pack — so the check is an EQUALITY against the pack in its own key. That catches the copy
|
|
683
|
-
* error a six-way vocabulary cannot: a row pasted from the pack above it, keeping the class of
|
|
684
|
-
* the pack it came from, which would colour the row and label its chip with the wrong pack.
|
|
685
|
-
*/
|
|
686
|
-
export function doctrineMetaProblems(addrs) {
|
|
687
|
-
const page = path.join(ROOT, 'web', 'src', 'pages', 'Laws.jsx');
|
|
688
|
-
let text;
|
|
689
|
-
try { text = readText(page); } catch (e) {
|
|
690
|
-
return [`[authoring] web/src/pages/Laws.jsx unreadable: ${e.message}`];
|
|
691
|
-
}
|
|
692
|
-
const block = /^const DOCTRINE_META = \{$([\s\S]*?)^\}$/m.exec(text);
|
|
693
|
-
if (!block) {
|
|
694
|
-
return ['[authoring] DOCTRINE_META literal not found in web/src/pages/Laws.jsx — the '
|
|
695
|
-
+ "console's doctrine rows would have no gate on their Principle column"];
|
|
696
|
-
}
|
|
697
|
-
const meta = new Map();
|
|
698
|
-
for (const m of block[1].matchAll(DOCTRINE_META_ROW)) meta.set(m[2], [m[4], m[6]]);
|
|
699
|
-
const problems = [];
|
|
700
|
-
const seen = new Set();
|
|
701
|
-
for (const addr of addrs) {
|
|
702
|
-
seen.add(addr);
|
|
703
|
-
if (!meta.has(addr)) {
|
|
704
|
-
problems.push(`[authoring] doctrine rule ${addr} has no row in DOCTRINE_META `
|
|
705
|
-
+ '(web/src/pages/Laws.jsx) — the console would render it with a blank Principle');
|
|
706
|
-
continue;
|
|
707
|
-
}
|
|
708
|
-
const [klass, principle] = meta.get(addr);
|
|
709
|
-
if (!principle.trim()) {
|
|
710
|
-
problems.push(`[authoring] DOCTRINE_META['${addr}'] has an empty principle line — that `
|
|
711
|
-
+ 'rule would render with a blank description');
|
|
712
|
-
}
|
|
713
|
-
const pack = addr.split('.')[0];
|
|
714
|
-
if (klass !== pack) {
|
|
715
|
-
problems.push(`[authoring] DOCTRINE_META['${addr}'] carries pack '${klass}' — a doctrine `
|
|
716
|
-
+ `rule's class IS its pack, so this must read '${pack}'`);
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
for (const addr of [...meta.keys()].filter((k) => !seen.has(k)).sort()) {
|
|
720
|
-
problems.push(`[authoring] DOCTRINE_META lists ${addr} but no pack file defines it`);
|
|
721
|
-
}
|
|
722
|
-
return problems;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* `_harness_build._prose_mirror_problems` — the human-readable count mirrors, the ones the
|
|
727
|
-
* badge regex never sees.
|
|
728
|
-
*
|
|
729
|
-
* The README "What you get" table and the web onboarding copy each restate the law / agent /
|
|
730
|
-
* skill counts in prose, and the README enumerates the skills by name. Nothing renders these,
|
|
731
|
-
* so they drift silently — they already had. Pure over its inputs, so the corpus can feed it
|
|
732
|
-
* crafted drift without touching the tree.
|
|
733
|
-
*/
|
|
734
|
-
export function proseMirrorProblems(readme, web, counts, skillStems, shipped = '') {
|
|
735
|
-
const problems = [];
|
|
736
|
-
const { laws, agents, skills, plugins } = counts;
|
|
737
|
-
|
|
738
|
-
for (const m of readme.matchAll(/(\d+) universal laws/g)) {
|
|
739
|
-
if (Number(m[1]) !== laws) {
|
|
740
|
-
problems.push(`[authoring] README prose says '${m[1]} universal laws' but src has ${laws}`);
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
// The README plugin count. It had drifted — the overview sentence said six while seven
|
|
744
|
-
// shipped — because the project's other plugin count ({N_PLUGINS} in the web docs) reads
|
|
745
|
-
// a DEPLOYED plugins dir that a markdown file in this repository never has. `plugins` is
|
|
746
|
-
// optional in `counts` (Python's `.get`, undefined here) because the corpus predates it.
|
|
747
|
-
if (plugins !== undefined) {
|
|
748
|
-
for (const m of readme.matchAll(/(\d+) \*{0,2}plugins/g)) {
|
|
749
|
-
if (Number(m[1]) !== plugins) {
|
|
750
|
-
problems.push(`[authoring] README prose says '${m[1]} plugins' but `
|
|
751
|
-
+ `adapters/opencode/plugins has ${plugins}`);
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
for (const [label, want] of [['Agents', agents], ['Skills', skills]]) {
|
|
756
|
-
const m = new RegExp(`${label}\\*\\*\\s*\\((\\d+)\\)`).exec(readme);
|
|
757
|
-
if (m && Number(m[1]) !== want) {
|
|
758
|
-
problems.push(`[authoring] README '${label} (${m[1]})' but src has ${want}`);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
// The README Skills row must enumerate EXACTLY the source skills — a dropped name is
|
|
762
|
-
// invisible to the (N) count alone, which is the off-by-one this gate forbids.
|
|
763
|
-
const row = readme.split('\n').find((ln) => ln.includes('🛠') && ln.includes('Skills')) ?? '';
|
|
764
|
-
if (row.includes('workflows:')) {
|
|
765
|
-
const listed = new Set(row.split('workflows:').slice(1).join('workflows:').split('·')
|
|
766
|
-
.map((seg) => seg.replace(/[^a-z0-9-]/g, '')));
|
|
767
|
-
listed.delete('');
|
|
768
|
-
for (const missing of [...skillStems].filter((s) => !listed.has(s)).sort()) {
|
|
769
|
-
problems.push(`[authoring] README skills list omits '${missing}'`);
|
|
770
|
-
}
|
|
771
|
-
for (const orphan of [...listed].filter((s) => !skillStems.has(s)).sort()) {
|
|
772
|
-
problems.push(`[authoring] README skills list names '${orphan}' but no `
|
|
773
|
-
+ `skills/${orphan}.md exists`);
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
for (const m of web.matchAll(/(\d+) universal (?:laws|Rules)/g)) {
|
|
778
|
-
if (Number(m[1]) !== laws) {
|
|
779
|
-
problems.push(`[authoring] _web_core prose says '${m[1]} universal laws/Rules' but `
|
|
780
|
-
+ `src has ${laws}`);
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
for (const m of web.matchAll(/(\d+) capability specialists/g)) {
|
|
784
|
-
if (Number(m[1]) !== agents) {
|
|
785
|
-
problems.push(`[authoring] _web_core prose says '${m[1]} capability specialists' but `
|
|
786
|
-
+ `src has ${agents}`);
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
// N is a curated-subset size, not the total, so it is gated against the wikilinks it
|
|
790
|
-
// introduces rather than against the skill count. `[\s\S]` is Python's `re.S` dot.
|
|
791
|
-
const m = /(\d+) repeatable workflows the agent can invoke by name([\s\S]*?)playbook under/
|
|
792
|
-
.exec(web);
|
|
793
|
-
if (m) {
|
|
794
|
-
const listedN = (m[2].match(/\[\[[^\]]+\]\]/g) ?? []).length;
|
|
795
|
-
if (Number(m[1]) !== listedN) {
|
|
796
|
-
problems.push(`[authoring] _web_core says '${m[1]} repeatable workflows' `
|
|
797
|
-
+ `but its wikilink list has ${listedN}`);
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
// ⚠ THE ABSENCE OF THIS TRIPLE IS CHECKED BY THE CALLER, NOT HERE, AND THAT IS FORCED RATHER
|
|
802
|
-
// THAN CHOSEN. This arm is satisfiable by DELETING THE SENTENCE — no match, no problem, green
|
|
803
|
-
// — which is a gate failing open. The obvious fix is an `else` right here, and it cannot go
|
|
804
|
-
// here: `prose_mirror_problems` is one of the pure functions recorded in
|
|
805
|
-
// `tests/__snapshots__/primitives/`, the recording holds a case that passes an EMPTY `shipped`
|
|
806
|
-
// and expects `[]`, and there is no recorder left to re-bless it. So the presence check lives
|
|
807
|
-
// in `countTableProblems`, which is not recorded — beside the read that already knows whether
|
|
808
|
-
// the file could be opened at all, which is where it reads better anyway.
|
|
809
|
-
const s = /(\d+) laws, (\d+) agents, (\d+) skills/.exec(shipped);
|
|
810
|
-
if (s) {
|
|
811
|
-
for (const [got, want, label] of [[s[1], laws, 'laws'], [s[2], agents, 'agents'],
|
|
812
|
-
[s[3], skills, 'skills']]) {
|
|
813
|
-
if (Number(got) !== want) {
|
|
814
|
-
problems.push(`[authoring] SHIPPED.md says '${got} ${label}' but src has ${want}`);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
return problems;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
/** `_build_render._TMPL_SPEC_RE`, as the count gate spells it. */
|
|
822
|
-
const TMPL_SPEC_RE = /\{\{DIR_(AGENTS|SKILLS)\}\}\/([A-Za-z0-9_-]+)\.md/g;
|
|
823
|
-
const LAW_HEADING_RE = /^### \{\{LAW\}\} ([IVXLCDM]+)\b/gm;
|
|
824
|
-
|
|
825
|
-
// The doctrine twins of `LAW_HEADING_RE`, over the UNRENDERED source — `js/inventory.mjs`'s
|
|
826
|
-
// pair of the same names read the rendered form. A citation is the heading minus its `### `,
|
|
827
|
-
// so one regex cannot serve both: a heading is an anchored line, a citation is anywhere.
|
|
828
|
-
const DOCTRINE_HEADING_RE = /^### \{\{DOCTRINE\}\} ([a-z]+) (\d+)\b/gm;
|
|
829
|
-
const DOCTRINE_CITE_RE = /\{\{DOCTRINE\}\}\s+([a-z]+)\s+(\d+)/g;
|
|
830
|
-
// `\{\{DOCTRINE\}\}` and not `DOCTRINE`: `{{DOCTRINES}}` is the SECTION noun and is legal
|
|
831
|
-
// everywhere, including in an invariant that points at the tier as a whole.
|
|
832
|
-
const DOCTRINE_TOKEN_RE = /\{\{DOCTRINE\}\}/;
|
|
833
|
-
const DOC_TOKEN_RE = /\{\{(DOC_[A-Z0-9_]+)\}\}/g;
|
|
834
|
-
|
|
835
|
-
/**
|
|
836
|
-
* A line reported by the doctor that is NOT a failure — `[note] …` rather than `[authoring] …`.
|
|
837
|
-
*
|
|
838
|
-
* ⚠ ONE PRODUCER, AND IT IS THE ONLY THING THAT NEEDED A SECOND CHANNEL. A build with a
|
|
839
|
-
* doctrine pack off is LEGAL, and it leaves body prose citing rules that pack owns. That state
|
|
840
|
-
* has to be visible — a silent dangling citation is how prose and boundary drift apart — but
|
|
841
|
-
* reporting it as a problem would make `doctor` exit 1 over a configuration its owner chose.
|
|
842
|
-
* So `cmdDoctor` prints notes and does not count them.
|
|
843
|
-
*/
|
|
844
|
-
const NOTE = '[note] ';
|
|
845
|
-
export const isDoctorNote = (p) => p.startsWith(NOTE);
|
|
846
|
-
|
|
847
|
-
/**
|
|
848
|
-
* The three-tier constitution's own authoring gates — the doctrine twin of the `LAW_CLASS` /
|
|
849
|
-
* `LAW_META` family, plus the two purity rules the tiering rests on.
|
|
850
|
-
*
|
|
851
|
-
* IT LIVES BESIDE `countTableProblems` RATHER THAN INSIDE IT, and is called from
|
|
852
|
-
* `authoringProblems` beside `registry`/`secret`/`vendorPin`. `countTableProblems` opens by
|
|
853
|
-
* reading `AGENT.md.tmpl` and HARD-RETURNS if that read fails, so an arm placed inside it
|
|
854
|
-
* would go quiet on exactly the tree that is most broken. Nothing here needs the template's
|
|
855
|
-
* tables, and `proseMirrorProblems` — the frozen recording — is untouched either way.
|
|
856
|
-
*
|
|
857
|
-
* ⚠ THE VOCABULARY GATES ARE HERE BECAUSE `themeParityProblems` CANNOT SEE THEM. That check is
|
|
858
|
-
* presence-only and SYMMETRIC over the union of every theme's keys: a key missing from all
|
|
859
|
-
* fourteen is in parity, and a key whose VALUE breaks a parser is not its business at all. So
|
|
860
|
-
* `DOC_*` coverage, the `LEX_I..LEX_IX` equality and the single-word tier noun each need a gate
|
|
861
|
-
* that reads the SOURCE and asks what the source requires.
|
|
862
|
-
*/
|
|
863
|
-
export function constitutionProblems() {
|
|
864
|
-
const problems = [];
|
|
865
|
-
const dir = path.join(SRC, 'doctrines');
|
|
866
|
-
|
|
867
|
-
// ---- the packs themselves: present, contiguous from 1, and filed where they say they are.
|
|
868
|
-
const rules = new Map(); // 'craft.1' -> title-less marker
|
|
869
|
-
for (const pack of PACK_ORDER) {
|
|
870
|
-
const file = path.join(dir, `${pack}.md`);
|
|
871
|
-
let text;
|
|
872
|
-
try { text = readText(file); } catch (e) {
|
|
873
|
-
problems.push(`[authoring] doctrines/${pack}.md is named in PACK_ORDER but unreadable: `
|
|
874
|
-
+ `${e.message} — the build ships the whole catalogue, so a missing pack file breaks `
|
|
875
|
-
+ 'every citation into it, active or not');
|
|
876
|
-
continue;
|
|
877
|
-
}
|
|
878
|
-
const found = [...text.matchAll(DOCTRINE_HEADING_RE)];
|
|
879
|
-
if (!found.length) {
|
|
880
|
-
problems.push(`[authoring] doctrines/${pack}.md carries no '### {{DOCTRINE}} ${pack} <n>' `
|
|
881
|
-
+ 'heading — the pack would render as an empty section');
|
|
882
|
-
continue;
|
|
883
|
-
}
|
|
884
|
-
found.forEach(([, named, n], i) => {
|
|
885
|
-
if (named !== pack) {
|
|
886
|
-
problems.push(`[authoring] doctrines/${pack}.md holds a rule addressed to '${named} `
|
|
887
|
-
+ `${n}' — a rule's pack is read from its heading, so this one is unreachable at `
|
|
888
|
-
+ `'${pack}.${n}' and shadows whatever ${named}.md numbers ${n}`);
|
|
889
|
-
}
|
|
890
|
-
if (Number(n) !== i + 1) {
|
|
891
|
-
problems.push(`[authoring] doctrines/${pack}.md rule ${n} is at position ${i + 1} — `
|
|
892
|
-
+ 'pack ids must run contiguously from 1, or a citation addresses a gap');
|
|
893
|
-
}
|
|
894
|
-
rules.set(`${named}.${Number(n)}`, true);
|
|
895
|
-
});
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// The console's Principle column, keyed off the rules just parsed — so the gate is driven by
|
|
899
|
-
// the SOURCE and a rule added to a pack file is caught the same day, not when someone
|
|
900
|
-
// notices a blank description in the browser.
|
|
901
|
-
problems.push(...doctrineMetaProblems([...rules.keys()]));
|
|
902
|
-
|
|
903
|
-
// ---- every `{{DOCTRINE}} <pack> <n>` anywhere in src/ resolves to a rule that exists.
|
|
904
|
-
//
|
|
905
|
-
// The whole tree, not the template: a skill footer citing `process 9` is as broken as a
|
|
906
|
-
// template one, and it is the shape 339 rewired citations could have left behind.
|
|
907
|
-
const cited = new Map(); // 'process.5' -> [rel, …]
|
|
908
|
-
for (const p of rglob(SRC)) {
|
|
909
|
-
if (!isFile(p) || !(p.endsWith('.md') || p.endsWith('.tmpl'))) continue;
|
|
910
|
-
let text;
|
|
911
|
-
try { text = readText(p); } catch { continue; }
|
|
912
|
-
const rel = path.relative(SRC, p).split(path.sep).join('/');
|
|
913
|
-
// ⚠ AN ALWAYS-ON TIER MAY NEVER CITE A TOGGLEABLE ONE (D2). A `--doctrines craft` build
|
|
914
|
-
// ships invariants that point at rules its AGENT.md does not contain; the ontology is
|
|
915
|
-
// worse still, being the tier that states the order the others sit in. Doctrine->doctrine
|
|
916
|
-
// citations are fine and stay: the full catalogue ships in every bundle.
|
|
917
|
-
if ((rel.startsWith('ontology/') || rel.startsWith('laws/')) && DOCTRINE_TOKEN_RE.test(text)) {
|
|
918
|
-
problems.push(`[authoring] ${rel} cites {{DOCTRINE}} — an always-on tier may never `
|
|
919
|
-
+ 'reference a toggleable one, or a pack-off build ships a rule pointing at text '
|
|
920
|
-
+ 'that is not there. State the point directly instead');
|
|
921
|
-
}
|
|
922
|
-
for (const [, pack, n] of text.matchAll(DOCTRINE_CITE_RE)) {
|
|
923
|
-
const key = `${pack}.${Number(n)}`;
|
|
924
|
-
if (!cited.has(key)) cited.set(key, []);
|
|
925
|
-
cited.get(key).push(rel);
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
for (const [key, where] of [...cited].sort()) {
|
|
929
|
-
if (rules.has(key)) continue;
|
|
930
|
-
problems.push(`[authoring] ${where[0]} cites {{DOCTRINE}} ${key.replace('.', ' ')}, which `
|
|
931
|
-
+ `no pack file defines${where.length > 1 ? ` (and ${where.length - 1} more)` : ''}`);
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
// ---- the vocabulary every theme owes the source.
|
|
935
|
-
const wanted = new Set();
|
|
936
|
-
for (const pack of PACK_ORDER) {
|
|
937
|
-
let text;
|
|
938
|
-
try { text = readText(path.join(dir, `${pack}.md`)); } catch { continue; }
|
|
939
|
-
for (const [, key] of text.matchAll(DOC_TOKEN_RE)) wanted.add(key);
|
|
940
|
-
}
|
|
941
|
-
const lexWant = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'].map((r) => `LEX_${r}`);
|
|
942
|
-
// `_TEMPLATE.json` is included — it is the file `--sync-themes` seeds a new theme from, so a
|
|
943
|
-
// stale key there propagates into every voice added afterwards. `themeFiles()` excludes it.
|
|
944
|
-
const files = [...themeFiles(), path.join(THEMES, '_TEMPLATE.json')];
|
|
945
|
-
for (const f of files) {
|
|
946
|
-
const name = path.basename(f);
|
|
947
|
-
let theme;
|
|
948
|
-
try { theme = parseJson(readText(f)); } catch (e) {
|
|
949
|
-
problems.push(`[authoring] ${name} unreadable: ${e.message}`);
|
|
950
|
-
continue;
|
|
951
|
-
}
|
|
952
|
-
if (!theme || typeof theme !== 'object' || Array.isArray(theme)) continue;
|
|
953
|
-
const keys = Object.keys(theme);
|
|
954
|
-
for (const key of [...wanted].sort()) {
|
|
955
|
-
if (!Object.hasOwn(theme, key)) {
|
|
956
|
-
problems.push(`[authoring] ${name} has no {${key}} — a doctrine file names it, and `
|
|
957
|
-
+ 'theme parity cannot see a key that is missing from every theme at once');
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
for (const key of keys.filter((k) => k.startsWith('DOC_')).sort()) {
|
|
961
|
-
if (!wanted.has(key)) {
|
|
962
|
-
problems.push(`[authoring] ${name} defines {${key}}, which no doctrine file uses — a `
|
|
963
|
-
+ 'dead voice key ships a title for a rule that does not exist');
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
// I1 — an EQUALITY, not a presence check. The renumber left `LEX_XXII`, `LEX_XXIII`,
|
|
967
|
-
// `LEX_XXIV` and `LEX_XXXVI` behind in all fifteen files, and parity was silent because
|
|
968
|
-
// they were absent from nowhere.
|
|
969
|
-
const lex = keys.filter((k) => k.startsWith('LEX_')).sort();
|
|
970
|
-
const stale = lex.filter((k) => !lexWant.includes(k));
|
|
971
|
-
const missing = lexWant.filter((k) => !lex.includes(k));
|
|
972
|
-
for (const k of stale) {
|
|
973
|
-
problems.push(`[authoring] ${name} still defines {${k}} — the invariants are I..IX and `
|
|
974
|
-
+ 'nothing renders this key, so it is a title for a rule that no longer exists');
|
|
975
|
-
}
|
|
976
|
-
for (const k of missing) {
|
|
977
|
-
problems.push(`[authoring] ${name} has no {${k}} — one of the nine invariants would `
|
|
978
|
-
+ 'render with an empty title');
|
|
979
|
-
}
|
|
980
|
-
// M10 — ⚠ BOTH HEADING PARSERS MATCH THE TIER NOUN WITH `\S+`. A two-word value does not
|
|
981
|
-
// error; the heading stops matching and the tier silently parses to nothing. `_TEMPLATE`'s
|
|
982
|
-
// values are descriptive placeholders, so only real voices are held to this.
|
|
983
|
-
if (name === '_TEMPLATE.json') continue;
|
|
984
|
-
for (const key of ['LAW', 'DOCTRINE']) {
|
|
985
|
-
const v = theme[key];
|
|
986
|
-
if (typeof v === 'string' && /\s/.test(v)) {
|
|
987
|
-
problems.push(`[authoring] ${name} spells {${key}} as ${pyRepr(v)} — the heading `
|
|
988
|
-
+ 'parsers match the tier noun with \\S+, so a value with a space makes every '
|
|
989
|
-
+ `${key === 'LAW' ? 'invariant' : 'doctrine rule'} parse to nothing, in silence`);
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
// ---- every `§N` in src/ addresses a section the template actually declares.
|
|
995
|
-
//
|
|
996
|
-
// ⚠ THIS GATE IS NARROW AND SAYS SO. Inserting `## 2. Doctrines` pushed every later section
|
|
997
|
-
// down one, and the sweep that fixed the template's own cross-references stopped at the
|
|
998
|
-
// template — four satellites under `src/skills/` and `src/agents/` kept pointing at the
|
|
999
|
-
// section that used to be there, and they RENDER INTO EVERY BUNDLE. A range check would not
|
|
1000
|
-
// have caught those: §7 still existed, it had just stopped being the Wiki.
|
|
1001
|
-
//
|
|
1002
|
-
// What it does catch is the other half of a renumber — a pointer past the end, which is what
|
|
1003
|
-
// REMOVING a section leaves behind — and it is nearly free. The half it cannot catch is a
|
|
1004
|
-
// judgement about intent, and `docs/extending.md` carries the manual sweep for it. Naming
|
|
1005
|
-
// that limit here is the point: a reader who assumes this gate is total will skip the sweep.
|
|
1006
|
-
const declared = new Set();
|
|
1007
|
-
// Read here rather than borrowing `countTableProblems`' copy: that function hard-returns when
|
|
1008
|
-
// the template is unreadable, and this gate must not inherit a silence it did not choose.
|
|
1009
|
-
let tmpl = '';
|
|
1010
|
-
try { tmpl = readText(path.join(SRC, 'AGENT.md.tmpl')); } catch { tmpl = ''; }
|
|
1011
|
-
for (const [, n] of tmpl.matchAll(/^## (\d+)\.\s/gm)) declared.add(Number(n));
|
|
1012
|
-
if (declared.size) {
|
|
1013
|
-
for (const p of rglob(SRC)) {
|
|
1014
|
-
if (!isFile(p) || !(p.endsWith('.md') || p.endsWith('.tmpl'))) continue;
|
|
1015
|
-
let text;
|
|
1016
|
-
try { text = readText(p); } catch { continue; }
|
|
1017
|
-
const rel = path.relative(SRC, p).split(path.sep).join('/');
|
|
1018
|
-
for (const [, n] of text.matchAll(/§(\d+)/g)) {
|
|
1019
|
-
if (declared.has(Number(n))) continue;
|
|
1020
|
-
problems.push(`[authoring] ${rel} cites AGENT.md §${n}, and the template declares no `
|
|
1021
|
-
+ `such section ${pyRepr([...declared].sort((a, b) => a - b).map(String))} — a `
|
|
1022
|
-
+ 'renumber moved it, and this pointer renders into every bundle');
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
// ---- the build default names packs that exist.
|
|
1028
|
-
let cfgDoctrines = null;
|
|
1029
|
-
try {
|
|
1030
|
-
const cfg = parseJson(readText(CONFIG));
|
|
1031
|
-
if (cfg && typeof cfg === 'object' && Array.isArray(cfg.doctrines)) cfgDoctrines = cfg.doctrines;
|
|
1032
|
-
} catch { /* no config, or unreadable — `configDefaults()` is forgiving here too */ }
|
|
1033
|
-
if (cfgDoctrines) {
|
|
1034
|
-
for (const pack of cfgDoctrines) {
|
|
1035
|
-
if (!PACK_ORDER.includes(pack)) {
|
|
1036
|
-
problems.push(`[authoring] harness.config.json names doctrine pack ${pyRepr(pack)}, `
|
|
1037
|
-
+ `which this checkout does not ship ${pyRepr([...PACK_ORDER])} — every build reading `
|
|
1038
|
-
+ 'that default would fail');
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
// ---- D5, a NOTE and not a problem: what a narrowed default leaves pointing at the shelf.
|
|
1044
|
-
const on = cfgDoctrines ?? [...PACK_ORDER];
|
|
1045
|
-
const dangling = [...cited].filter(([k]) => rules.has(k) && !on.includes(k.split('.')[0]));
|
|
1046
|
-
if (dangling.length) {
|
|
1047
|
-
const packs = [...new Set(dangling.map(([k]) => k.split('.')[0]))].sort();
|
|
1048
|
-
problems.push(`${NOTE}harness.config.json builds ${pyRepr(on)}, and ${dangling.length} `
|
|
1049
|
-
+ `citation(s) in src/ point into ${packs.join(', ')}. Those rules still SHIP — every `
|
|
1050
|
-
+ 'pack file is in the bundle — so the reference resolves on disk; it is the rendered '
|
|
1051
|
-
+ 'AGENT.md that will not contain them. Legal, and recorded here so it is not silent');
|
|
1052
|
-
}
|
|
1053
|
-
return problems;
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
/**
|
|
1057
|
-
* `_harness_build._count_table_problems` — the hand-authored AGENT.md tables and the README
|
|
1058
|
-
* badges, against `src/`.
|
|
1059
|
-
*
|
|
1060
|
-
* The tables must list EXACTLY the spec files (no dead row, no orphaned spec) and each
|
|
1061
|
-
* `agents`/`skills`/`laws`/`themes` badge must equal the real count. This is the
|
|
1062
|
-
* authoring-time guarantee that lets tables, badges and prose stay hand-written without
|
|
1063
|
-
* silently drifting from the source tree.
|
|
1064
|
-
*/
|
|
1065
|
-
export function countTableProblems() {
|
|
1066
|
-
const problems = [];
|
|
1067
|
-
let ttext;
|
|
1068
|
-
try { ttext = readText(path.join(SRC, 'AGENT.md.tmpl')); } catch (e) {
|
|
1069
|
-
return [`[authoring] AGENT.md.tmpl unreadable: ${e.message}`];
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
const linked = { agents: new Set(), skills: new Set() };
|
|
1073
|
-
for (const m of ttext.matchAll(TMPL_SPEC_RE)) {
|
|
1074
|
-
if (m[2] !== '_template') linked[m[1] === 'AGENTS' ? 'agents' : 'skills'].add(m[2]);
|
|
1075
|
-
}
|
|
1076
|
-
for (const folder of ['agents', 'skills']) {
|
|
1077
|
-
const files = srcStems(folder);
|
|
1078
|
-
for (const missing of [...linked[folder]].filter((x) => !files.has(x)).sort()) {
|
|
1079
|
-
problems.push(`[authoring] AGENT.md links ${folder}/${missing}.md but no such spec exists`);
|
|
1080
|
-
}
|
|
1081
|
-
for (const orphan of [...files].filter((x) => !linked[folder].has(x)).sort()) {
|
|
1082
|
-
problems.push(`[authoring] ${folder}/${orphan}.md exists but the AGENT.md table omits it`);
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
const skillFiles = srcStems('skills');
|
|
1087
|
-
for (const missing of [...skillFiles].filter((s) => !has(SKILL_CLASS, s)).sort()) {
|
|
1088
|
-
problems.push(`[authoring] skills/${missing}.md has no category in SKILL_CLASS`);
|
|
1089
|
-
}
|
|
1090
|
-
for (const stale of Object.keys(SKILL_CLASS).filter((s) => !skillFiles.has(s)).sort()) {
|
|
1091
|
-
problems.push(`[authoring] SKILL_CLASS lists '${stale}' but no skills/${stale}.md exists`);
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
const lawsMd = path.join(SRC, 'laws', 'universal.md');
|
|
1095
|
-
const lawNums = isFile(lawsMd)
|
|
1096
|
-
? [...readText(lawsMd).matchAll(LAW_HEADING_RE)].map((m) => m[1]) : [];
|
|
1097
|
-
for (const num of lawNums) {
|
|
1098
|
-
if (!has(LAW_CLASS, num)) {
|
|
1099
|
-
problems.push(`[authoring] laws/universal.md rule ${num} has no class in LAW_CLASS`);
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
// `sorted(LAW_CLASS.items())` — by the Roman numeral as a STRING, which is what Python
|
|
1103
|
-
// sorts here; the messages are re-sorted by `doctorCollect` anyway, so only the set travels.
|
|
1104
|
-
for (const num of Object.keys(LAW_CLASS).sort()) {
|
|
1105
|
-
if (!LAW_CLASSES.includes(LAW_CLASS[num])) {
|
|
1106
|
-
problems.push(`[authoring] LAW_CLASS['${num}'] = '${LAW_CLASS[num]}' is not a known `
|
|
1107
|
-
+ `class ${pyRepr(LAW_CLASSES)}`);
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
problems.push(...lawMetaProblems(lawNums, LAW_CLASS, LAW_CLASSES));
|
|
1111
|
-
|
|
1112
|
-
const counts = {
|
|
1113
|
-
agents: srcStems('agents').size,
|
|
1114
|
-
skills: srcStems('skills').size,
|
|
1115
|
-
laws: lawNums.length,
|
|
1116
|
-
themes: themeFiles().length,
|
|
1117
|
-
// Last on purpose: the badge loop below walks this object in insertion order and the
|
|
1118
|
-
// message sequence is compared byte for byte against the Python implementation.
|
|
1119
|
-
plugins: existsSync(PLUGIN_SRC)
|
|
1120
|
-
? readdirSync(PLUGIN_SRC).filter((f) => f.startsWith('geneseed-') && f.endsWith('.js')).length
|
|
1121
|
-
: 0,
|
|
1122
|
-
};
|
|
1123
|
-
let readme;
|
|
1124
|
-
try { readme = readText(path.join(ROOT, 'README.md')); } catch { return problems; }
|
|
1125
|
-
// ⚠ PRESENCE FIRST, THEN THE VALUE — the same absence arm the SHIPPED triple needed, for the
|
|
1126
|
-
// same reason: `if (m && …)` is green when there is no badge, so deleting a badge silences its
|
|
1127
|
-
// own gate. The `continue` keeps the loop's MESSAGE ORDER intact, which is load-bearing (see
|
|
1128
|
-
// the `plugins`-is-last comment above): a missing badge reports in its own slot rather than
|
|
1129
|
-
// reordering the ones after it.
|
|
1130
|
-
for (const [key, n] of Object.entries(counts)) {
|
|
1131
|
-
const m = new RegExp(`badge/${key}-(\\d+)`).exec(readme);
|
|
1132
|
-
if (!m) {
|
|
1133
|
-
problems.push(`[authoring] README has no ${key} badge — deleting one is how this gate `
|
|
1134
|
-
+ 'goes green while the reader is told nothing');
|
|
1135
|
-
continue;
|
|
1136
|
-
}
|
|
1137
|
-
if (Number(m[1]) !== n) {
|
|
1138
|
-
problems.push(`[authoring] README ${key} badge says ${m[1]} but src has ${n}`);
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
// WHERE THE ONBOARDING COPY LIVES, AND WHY THIS READ MOVED. It used to open the one module
|
|
1143
|
-
// that held the web console's onboarding prose. That prose has since moved into
|
|
1144
|
-
// `docs/web/*.md`, and the counts in it render from `{N_LAWS}` / `{N_AGENTS}` / `{N_SKILLS}`
|
|
1145
|
-
// — so the read was still succeeding against a file the sentences had left, all three arms
|
|
1146
|
-
// below scored zero, and the check looked healthy while gating nothing. A templated count
|
|
1147
|
-
// cannot drift; what these arms still catch is a maintainer typing the NUMBER into a page
|
|
1148
|
-
// instead of the token, which is the drift that reaches a reader.
|
|
1149
|
-
//
|
|
1150
|
-
// MEASURED BEFORE AND AFTER: zero problems either way on the shipped tree, so no recorded
|
|
1151
|
-
// byte moves. `web` stays fail-soft — a missing docs tree is not an authoring fault.
|
|
1152
|
-
let web = '';
|
|
1153
|
-
try {
|
|
1154
|
-
web = globSorted(path.join(ROOT, 'docs', 'web'), (n) => n.endsWith('.md'))
|
|
1155
|
-
.map(readText).join('\n');
|
|
1156
|
-
} catch { web = ''; }
|
|
1157
|
-
// ⚠ NOT FAIL-SOFT, UNLIKE `web` ABOVE, AND THE ASYMMETRY IS THE POINT. A missing `docs/web`
|
|
1158
|
-
// tree is not an authoring fault — a checkout can legitimately lack it. SHIPPED.md is a
|
|
1159
|
-
// tracked, shipped file that this function asserts the CONTENT of, so an unreadable one is
|
|
1160
|
-
// indistinguishable from a deleted claim: swallowing it into `''` used to hand the triple arm
|
|
1161
|
-
// an empty string and turn a hard read failure into a silent pass.
|
|
1162
|
-
// ⚠ NOT FAIL-SOFT, UNLIKE `web` ABOVE, AND THE ASYMMETRY IS THE POINT. A missing `docs/web`
|
|
1163
|
-
// tree is not an authoring fault — a checkout can legitimately lack it. SHIPPED.md is a
|
|
1164
|
-
// tracked, shipped file whose CONTENT this function asserts, so an unreadable one was
|
|
1165
|
-
// indistinguishable from a deleted claim: swallowing it into `''` handed the triple arm an
|
|
1166
|
-
// empty string and turned a hard read failure into a silent pass.
|
|
1167
|
-
//
|
|
1168
|
-
// AND THE PRESENCE OF THE TRIPLE IS CHECKED HERE rather than inside `proseMirrorProblems`,
|
|
1169
|
-
// which is where it belongs by subject and cannot go by construction — that function is
|
|
1170
|
-
// recorded in `tests/__snapshots__/primitives/` with a case that passes an empty `shipped` and
|
|
1171
|
-
// expects `[]`, and nothing can re-bless a recording whose recorder is deleted. This is the
|
|
1172
|
-
// caller, it is not recorded, and it already owns the question "could the file be read".
|
|
1173
|
-
let shipped;
|
|
1174
|
-
try {
|
|
1175
|
-
shipped = readText(path.join(ROOT, 'SHIPPED.md'));
|
|
1176
|
-
} catch (e) {
|
|
1177
|
-
problems.push(`[authoring] SHIPPED.md is unreadable (${e.message}) — the counts it carries `
|
|
1178
|
-
+ 'cannot be checked, and a gate that cannot read its subject must say so');
|
|
1179
|
-
shipped = '';
|
|
1180
|
-
}
|
|
1181
|
-
if (shipped && !/(\d+) laws, (\d+) agents, (\d+) skills/.test(shipped)) {
|
|
1182
|
-
problems.push('[authoring] SHIPPED.md carries no \'N laws, N agents, N skills\' line — '
|
|
1183
|
-
+ 'deleting it is how that gate goes green while the count drifts');
|
|
1184
|
-
}
|
|
1185
|
-
problems.push(...proseMirrorProblems(readme, web, counts, skillFiles, shipped));
|
|
1186
|
-
return problems;
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
/** `_harness_core.LEARN_PROMPT_HEAD`'s extraction regex — one owner, two readers. */
|
|
1190
|
-
const LEARN_PROMPT_RE = /const LEARN_PROMPT_HEAD = `([\s\S]*?)`/;
|
|
1191
|
-
|
|
1192
|
-
/**
|
|
1193
|
-
* `_harness_core._load_learn_prompt_head` — the distil instructions, read out of the plugin.
|
|
1194
|
-
*
|
|
1195
|
-
* The single source of truth is the OpenCode plugin, the artifact that ships to the primary
|
|
1196
|
-
* runtime, so the CLI extracts it at load time rather than carrying a copy. Reproduced here
|
|
1197
|
-
* rather than imported from `js/hooks.mjs`, which this entry may not reach: `learn` spawns the
|
|
1198
|
-
* model CLI, and the allow-list below names ONE call site.
|
|
1199
|
-
*
|
|
1200
|
-
* The fallback matters as much as the extraction. It is what makes the drift arm of
|
|
1201
|
-
* `_authoring_problems` unreachable — both the loaded copy and the checked literal come from
|
|
1202
|
-
* one file through one regex, so a reference can never disagree with itself. A port that
|
|
1203
|
-
* hardcoded the prompt WOULD disagree, which is what
|
|
1204
|
-
* `doctor/the-loaded-copy-follows-the-plugin-rather-than-a-constant` exists to catch.
|
|
1205
|
-
*/
|
|
1206
|
-
function loadLearnPromptHead() {
|
|
1207
|
-
try {
|
|
1208
|
-
const m = LEARN_PROMPT_RE.exec(readText(path.join(PLUGIN_SRC, 'geneseed-learn.js')));
|
|
1209
|
-
if (m) return m[1];
|
|
1210
|
-
} catch { /* OSError — fall through */ }
|
|
1211
|
-
return 'Distil at most one durable, reusable memory from the notes below. '
|
|
1212
|
-
+ 'When in doubt, output exactly: NOTHING.';
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
/**
|
|
1216
|
-
* `_harness_build._authoring_problems` — author-time gates on the source specs and plugins.
|
|
1217
|
-
*
|
|
1218
|
-
* Every agent/skill spec must carry a one-line `>` purpose blockquote as the FIRST content
|
|
1219
|
-
* block after its title, or its `description:` on every host either renders empty or silently
|
|
1220
|
-
* picks up the WRONG line; the learn-prompt literal must stay extractable; and, if node is on
|
|
1221
|
-
* PATH, the plugins must pass `node --check`. Then three source-wide gates: the lifecycle
|
|
1222
|
-
* registry describes exactly what `src/` provides, no credential ships, and every vendored
|
|
1223
|
-
* folder carries an immutable upstream pin.
|
|
1224
|
-
*/
|
|
1225
|
-
export function authoringProblems() {
|
|
1226
|
-
const problems = [];
|
|
1227
|
-
for (const folder of ['agents', 'skills']) {
|
|
1228
|
-
const d = path.join(SRC, folder);
|
|
1229
|
-
if (!isDir(d)) continue;
|
|
1230
|
-
for (const spec of globSorted(d, (n) => n.endsWith('.md') && !n.startsWith('_'))) {
|
|
1231
|
-
let text;
|
|
1232
|
-
try { text = readText(spec); } catch (e) {
|
|
1233
|
-
problems.push(`[authoring] ${folder}/${path.basename(spec)} unreadable: ${e.message}`);
|
|
1234
|
-
continue;
|
|
1235
|
-
}
|
|
1236
|
-
if (!firstBlockquote(text)) {
|
|
1237
|
-
problems.push(`[authoring] ${folder}/${path.basename(spec)} has no '>' purpose line `
|
|
1238
|
-
+ '(its OpenCode description would render empty)');
|
|
1239
|
-
continue;
|
|
1240
|
-
}
|
|
1241
|
-
const reason = descBlockProblem(text);
|
|
1242
|
-
if (reason) {
|
|
1243
|
-
problems.push(`[authoring] ${folder}/${path.basename(spec)}: ${reason} — `
|
|
1244
|
-
+ 'desc_of() would silently extract the wrong description');
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
const plugin = path.join(PLUGIN_SRC, 'geneseed-learn.js');
|
|
1249
|
-
let m = null;
|
|
1250
|
-
try { m = LEARN_PROMPT_RE.exec(readText(plugin)); } catch { m = null; }
|
|
1251
|
-
if (!m) {
|
|
1252
|
-
// "the harness", not `harness.py`: both implementations load this literal, and the file
|
|
1253
|
-
// the old wording named is the one this migration deletes. Moved on both sides at once —
|
|
1254
|
-
// these two strings are byte-compared by the live doctor comparison.
|
|
1255
|
-
problems.push('[authoring] LEARN_PROMPT_HEAD literal not found in '
|
|
1256
|
-
+ 'geneseed-learn.js — the harness would fall back (single source broken)');
|
|
1257
|
-
} else if (m[1] !== loadLearnPromptHead()) {
|
|
1258
|
-
problems.push('[authoring] LEARN_PROMPT_HEAD drifted between geneseed-learn.js '
|
|
1259
|
-
+ "and the harness's loaded copy");
|
|
1260
|
-
}
|
|
1261
|
-
const node = pyWhich('node');
|
|
1262
|
-
if (node) {
|
|
1263
|
-
for (const js of globSorted(PLUGIN_SRC, (n) => n.endsWith('.js'))) {
|
|
1264
|
-
// THE ONE SPAWN. `node --check` and nothing else — see the module header, and
|
|
1265
|
-
// `test_the_cli_spawns_only_a_node_syntax_check`, which names this argv.
|
|
1266
|
-
// `NO_WINDOW` because the reference's `run()` folds `CREATE_NO_WINDOW` into every
|
|
1267
|
-
// CAPTURING spawn and this is one — and because this loop is the burst a user sees:
|
|
1268
|
-
// one console window per plugin, every time the web daemon runs the doctor.
|
|
1269
|
-
const r = spawnSync(node, ['--check', js], { encoding: 'utf-8', ...NO_WINDOW });
|
|
1270
|
-
if (r.status !== 0) {
|
|
1271
|
-
// `(stderr.strip().splitlines() or ["syntax error"])[-1]` — node's LAST line, which
|
|
1272
|
-
// is its version banner rather than the SyntaxError. Faithful, and surprising enough
|
|
1273
|
-
// that the cell asserts the prefix and never the version.
|
|
1274
|
-
const lines = (r.stderr ?? '').trim().split('\n').filter((x) => x !== '');
|
|
1275
|
-
const tail = lines.length ? lines[lines.length - 1] : 'syntax error';
|
|
1276
|
-
problems.push(`[authoring] node --check failed for ${path.basename(js)}: ${tail}`);
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
}
|
|
1280
|
-
problems.push(...registryProblems());
|
|
1281
|
-
problems.push(...secretProblems());
|
|
1282
|
-
problems.push(...vendorPinProblems());
|
|
1283
|
-
problems.push(...constitutionProblems());
|
|
1284
|
-
problems.push(...countTableProblems());
|
|
1285
|
-
return problems;
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
/**
|
|
1289
|
-
* `_harness_build._themes_to_check` — which themes doctor validates.
|
|
1290
|
-
*
|
|
1291
|
-
* An explicit `--theme` wins. Otherwise, unless `--all` forces the maintainer sweep, scope to
|
|
1292
|
-
* the theme THIS host installed so a one-theme user is not buried under the same problem
|
|
1293
|
-
* echoed across all fourteen. Falls back to the full sweep when nothing is installed (a fresh
|
|
1294
|
-
* clone) or the detected theme is unknown, so a maintainer in a clean checkout still gets
|
|
1295
|
-
* full coverage. Pure, and gated as such: `--all` is a 14-theme sweep no cell can afford.
|
|
1296
|
-
*/
|
|
1297
|
-
export function themesToCheck(theme, allThemes, detected, available) {
|
|
1298
|
-
if (theme) return [theme];
|
|
1299
|
-
if (!allThemes && detected && available.includes(detected)) return [detected];
|
|
1300
|
-
return [...available].sort();
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
/** `tempfile.TemporaryDirectory()` — created, handed to `fn`, removed whatever happens. */
|
|
1304
|
-
function withTempDir(fn, prefix = 'geneseed-doctor-') {
|
|
1305
|
-
const tmp = mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
1306
|
-
try { return fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
/**
|
|
1310
|
-
* `contextlib.redirect_stdout(io.StringIO())` — swallow an emit's log, and ONLY stdout.
|
|
1311
|
-
*
|
|
1312
|
-
* The Python redirects stdout and leaves stderr alone, so a WARN from the emit still reaches
|
|
1313
|
-
* the user. Reproducing that exactly is the point: swallowing both would hide
|
|
1314
|
-
* `warnBobGlobalOverProject` and swallowing neither would put ~200 lines of emit summary into
|
|
1315
|
-
* doctor's output. `withPyNewlines` inside `emitGlobalInto` wraps whatever `write` is
|
|
1316
|
-
* installed at call time, so it translates into this buffer and the bytes never leave.
|
|
1317
|
-
*/
|
|
1318
|
-
function swallowStdout(fn) {
|
|
1319
|
-
const real = process.stdout.write.bind(process.stdout);
|
|
1320
|
-
process.stdout.write = () => true;
|
|
1321
|
-
try { return fn(); } finally { process.stdout.write = real; }
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
/**
|
|
1325
|
-
* `_harness_build._global_emit_problems` — validate the opencode-global emit, in BOTH
|
|
1326
|
-
* footprints.
|
|
1327
|
-
*
|
|
1328
|
-
* The RECOMMENDED install, and otherwise a doctor blind spot: the files build and ./Harness
|
|
1329
|
-
* were checked and the global layout never was. The two footprints produce genuinely
|
|
1330
|
-
* different AGENT.md bodies — lean swaps the inlined laws for a digest plus a pointer and
|
|
1331
|
-
* ships a standalone laws file the full emit does not write — so checking one leaves the
|
|
1332
|
-
* other's links and tokens unvalidated, and lean being the default made that the shape most
|
|
1333
|
-
* installs actually run.
|
|
1334
|
-
*/
|
|
1335
|
-
export function globalEmitProblems(themeName) {
|
|
1336
|
-
const problems = [];
|
|
1337
|
-
for (const footprint of ['lean', 'full']) {
|
|
1338
|
-
withTempDir((tmp) => {
|
|
1339
|
-
const cfgDir = path.join(tmp, 'cfg');
|
|
1340
|
-
try {
|
|
1341
|
-
swallowStdout(() => emitGlobalInto('opencode', {
|
|
1342
|
-
theme: themeName, out: path.join(tmp, 'bundle'), cfgDir, footprint,
|
|
1343
|
-
}));
|
|
1344
|
-
} catch (e) {
|
|
1345
|
-
if (e && e.exitCode !== undefined) {
|
|
1346
|
-
problems.push(`[${themeName} global/${footprint}] build failed`);
|
|
1347
|
-
return;
|
|
1348
|
-
}
|
|
1349
|
-
throw e;
|
|
1350
|
-
}
|
|
1351
|
-
problems.push(...checkBuild(`${themeName} global/${footprint}`, cfgDir));
|
|
1352
|
-
});
|
|
1353
|
-
}
|
|
1354
|
-
return problems;
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
/**
|
|
1358
|
-
* `_harness_build._claude_bob_emit_problems` — validate the claude/bob/copilot PER-REPO
|
|
1359
|
-
* emits, which were never checked before.
|
|
1360
|
-
*
|
|
1361
|
-
* That is exactly why the CLAUDE.md/AGENTS.md skill-table dead links shipped unnoticed: the
|
|
1362
|
-
* emits that render straight into a repo were outside doctor's sweep. Scanned with
|
|
1363
|
-
* `validateIsVendored` rather than `isVendoredPath`, because the native per-repo layer nests
|
|
1364
|
-
* skills one level deeper than a bundle does.
|
|
1365
|
-
*/
|
|
1366
|
-
export function claudeBobEmitProblems(themeName) {
|
|
1367
|
-
const problems = [];
|
|
1368
|
-
for (const label of ['claude', 'bob', 'copilot']) {
|
|
1369
|
-
withTempDir((tmp) => {
|
|
1370
|
-
const root = path.join(tmp, 'root');
|
|
1371
|
-
try {
|
|
1372
|
-
swallowStdout(() => emitProjectInto(label, {
|
|
1373
|
-
theme: themeName, out: path.join(root, 'bundle'), root,
|
|
1374
|
-
}));
|
|
1375
|
-
} catch (e) {
|
|
1376
|
-
if (e && e.exitCode !== undefined) {
|
|
1377
|
-
problems.push(`[${themeName} ${label}] build failed`);
|
|
1378
|
-
return;
|
|
1379
|
-
}
|
|
1380
|
-
throw e;
|
|
1381
|
-
}
|
|
1382
|
-
problems.push(...checkBuild(`${themeName} ${label}`, root, validateIsVendored));
|
|
1383
|
-
});
|
|
1384
|
-
}
|
|
1385
|
-
return problems;
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
/**
|
|
1389
|
-
* `_harness_build._doctor_collect` — run every check; return `[themes, problems]`.
|
|
1390
|
-
*
|
|
1391
|
-
* TWO PARAMETERS OF THE PYTHON ARE NOT HERE, and both are absences on purpose. `on_progress`
|
|
1392
|
-
* is the TUI's — it drags ~140 lines of curses drawing behind it and no caller this binary
|
|
1393
|
-
* has can supply one. `groups` is the WEB's, the structured `{check, label, problems}` view
|
|
1394
|
-
* the Doctor page renders, and `_web_actions.py` / `_web_core.py` are its only callers; both
|
|
1395
|
-
* stay Python until P6. Four lines of accumulator with no caller and no cell is unreachable
|
|
1396
|
-
* code that is not part of an asserted partition, which is this port's own criterion for
|
|
1397
|
-
* deleting rather than keeping. P6 adds it with the caller that needs it.
|
|
1398
|
-
*
|
|
1399
|
-
* THE PER-THEME BUILD IS THE DRIVER, IN-PROCESS. The Python shells to `build.py` and reads a
|
|
1400
|
-
* return code; here that is `driverMain`, the route P5e established and the only one the
|
|
1401
|
-
* `child_process` allow-list leaves. `cwd=ROOT` does not travel in-process and does not have
|
|
1402
|
-
* to: `--out` is an absolute path, which is the one input `resolveOut` would have read the
|
|
1403
|
-
* working directory for.
|
|
1404
|
-
*/
|
|
1405
|
-
export function doctorCollect({
|
|
1406
|
-
theme = null, allThemes = false, bundle = null, noBundle = false, groups = null,
|
|
1407
|
-
} = {}) {
|
|
1408
|
-
const available = themeFiles().map(stemOf);
|
|
1409
|
-
if (!available.length) return [[], ['[doctor] no themes found']];
|
|
1410
|
-
|
|
1411
|
-
// `_ran` — the label is the one place each check is NAMED, and since P6b a caller can
|
|
1412
|
-
// pass an array to collect them. `/api/doctor` renders one card per entry, which is the
|
|
1413
|
-
// only reason the structured view exists; `cmd_doctor` passes nothing and gets the flat
|
|
1414
|
-
// list, so the return contract is unchanged either way. `on_progress` is still P7's.
|
|
1415
|
-
const ran = (check, label, probs) => {
|
|
1416
|
-
if (groups !== null) groups.push({ check, label, problems: sortedProblems(probs) });
|
|
1417
|
-
return probs;
|
|
1418
|
-
};
|
|
1419
|
-
|
|
1420
|
-
// Only probe the deployed install when we actually need it (no theme / not --all).
|
|
1421
|
-
const detected = (theme || allThemes) ? null : (installedDefaults().theme ?? null);
|
|
1422
|
-
const themes = themesToCheck(theme, allThemes, detected, available);
|
|
1423
|
-
// Sampled HERE, before the emit loop below — every emit rewrites the hook shim, so a check
|
|
1424
|
-
// placed after them could only ever observe the freshly repaired file and would be dead
|
|
1425
|
-
// code that always reports clean. Reported in its usual slot further down.
|
|
1426
|
-
const shimProbs = shimProblems();
|
|
1427
|
-
let problems = [];
|
|
1428
|
-
withTempDir((tmp) => {
|
|
1429
|
-
for (const themeName of themes) {
|
|
1430
|
-
const out = path.join(tmp, themeName);
|
|
1431
|
-
// capture_output=True: BOTH streams, unlike the emits below, which redirect stdout
|
|
1432
|
-
// only. A failing build's stderr is not doctor's output either — the return code is.
|
|
1433
|
-
const rc = captureBoth(() => driverMain(['--theme', themeName, '--out', out]));
|
|
1434
|
-
if (rc !== 0) {
|
|
1435
|
-
problems = problems.concat(ran('build', `Build scan (${themeName})`,
|
|
1436
|
-
[`[${themeName}] build failed`]));
|
|
1437
|
-
continue;
|
|
1438
|
-
}
|
|
1439
|
-
problems = problems.concat(ran('build', `Build scan (${themeName})`,
|
|
1440
|
-
checkBuild(themeName, out)));
|
|
1441
|
-
problems = problems.concat(ran('global', `Global install (${themeName})`,
|
|
1442
|
-
globalEmitProblems(themeName)));
|
|
1443
|
-
problems = problems.concat(ran('claude_bob',
|
|
1444
|
-
`Claude/Bob/Copilot per-repo emit (${themeName})`, claudeBobEmitProblems(themeName)));
|
|
1445
|
-
}
|
|
1446
|
-
});
|
|
1447
|
-
problems = problems.concat(ran('parity', 'Theme parity', themeParityProblems()));
|
|
1448
|
-
problems = problems.concat(ran('colors', 'Colour themes', colorThemeProblems()));
|
|
1449
|
-
problems = problems.concat(ran('authoring', 'Authoring gates', authoringProblems()));
|
|
1450
|
-
problems = problems.concat(ran('shim', 'Hook shim', shimProbs));
|
|
1451
|
-
// P10c's `cli` check is GONE, and the reason is not that it stopped mattering. It hashed
|
|
1452
|
-
// `rituals/harness.py` and compared that against a digest baked into `cli.json`, to catch a
|
|
1453
|
-
// parser edited without regenerating the table. P2 made the table the OWNED document
|
|
1454
|
-
// (`js/cli-table.json`), so there is no generator to fall behind and no second file to hash
|
|
1455
|
-
// — the digest was a claim about a file this migration deletes. What replaces it is
|
|
1456
|
-
// `tests/test_cli_reference.py`'s argparse-vs-table equality, for as long as the parser is
|
|
1457
|
-
// still here to walk.
|
|
1458
|
-
if (!noBundle) {
|
|
1459
|
-
// `Path(bundle).expanduser().resolve()`, which `pyResolve` already IS — the default is
|
|
1460
|
-
// deliberately NOT resolved, because the Python's `ROOT / "Harness"` is not either and
|
|
1461
|
-
// the resolved spelling is what the messages print.
|
|
1462
|
-
const b = bundle ? pyResolve(bundle) : path.join(ROOT, 'Harness');
|
|
1463
|
-
problems = problems.concat(ran('bundle', 'Committed bundle drift', renderedProblems(b)));
|
|
1464
|
-
}
|
|
1465
|
-
return [themes, sortedUnique(problems)];
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
/** `run(..., capture_output=True)` — both streams into the void, the return code out. */
|
|
1469
|
-
function captureBoth(fn) {
|
|
1470
|
-
const out = process.stdout.write.bind(process.stdout);
|
|
1471
|
-
const err = process.stderr.write.bind(process.stderr);
|
|
1472
|
-
process.stdout.write = () => true;
|
|
1473
|
-
process.stderr.write = () => true;
|
|
1474
|
-
try { return fn(); } finally { process.stdout.write = out; process.stderr.write = err; }
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
/**
|
|
1478
|
-
* `_harness_build.cmd_doctor` — the CLI face.
|
|
1479
|
-
*
|
|
1480
|
-
* With `--theme`, one theme. With none it scopes to the INSTALLED theme, so a one-theme
|
|
1481
|
-
* install is not buried under the same issue repeated across every theme; `--all` is the full
|
|
1482
|
-
* maintainer sweep. The cross-theme parity check runs in every mode.
|
|
1483
|
-
*/
|
|
1484
|
-
export function cmdDoctor(args) {
|
|
1485
|
-
const [themes, collected] = doctorCollect({
|
|
1486
|
-
theme: args.theme, allThemes: Boolean(args.all), bundle: args.bundle,
|
|
1487
|
-
noBundle: Boolean(args.noBundle),
|
|
1488
|
-
});
|
|
1489
|
-
if (!themes.length) {
|
|
1490
|
-
pyPrint(`${collected.length ? collected[0] : '[doctor] no themes found'}\n`);
|
|
1491
|
-
return 1;
|
|
1492
|
-
}
|
|
1493
|
-
// ⚠ NOTES ARE PRINTED AND NOT COUNTED. The only producer is the pack-off citation report in
|
|
1494
|
-
// `constitutionProblems`, and the state it describes is one the install's owner chose: a
|
|
1495
|
-
// build that leaves body prose citing a pack it did not render. Silence there is how prose
|
|
1496
|
-
// and boundary drift apart, and a non-zero exit there is doctor failing a legal
|
|
1497
|
-
// configuration. Nothing in the recorded CLI corpus produces one — the checkout's own
|
|
1498
|
-
// `harness.config.json` names no packs — so the byte-compared output is unmoved.
|
|
1499
|
-
const notes = collected.filter(isDoctorNote);
|
|
1500
|
-
const problems = collected.filter((p) => !isDoctorNote(p));
|
|
1501
|
-
const scoped = !args.theme && !args.all && themes.length === 1;
|
|
1502
|
-
const note = scoped
|
|
1503
|
-
? ` (scoped to installed theme '${themes[0]}'; run with --all to sweep every theme)` : '';
|
|
1504
|
-
for (const n of notes) pyPrint(`${n}\n`);
|
|
1505
|
-
if (problems.length) {
|
|
1506
|
-
pyPrint(`[doctor] ${problems.length} problem(s) across ${themes.length} theme(s):\n`);
|
|
1507
|
-
for (const p of problems) pyPrint(` - ${p}\n`);
|
|
1508
|
-
if (problems.some((p) => p.includes('dead link'))) {
|
|
1509
|
-
pyPrint(' tip: dead links to skills mean your source is incomplete — run '
|
|
1510
|
-
+ '`./geneseed update` (or re-sync src/), then re-check.\n');
|
|
1511
|
-
}
|
|
1512
|
-
if (problems.some((p) => p.startsWith('[themes]') && p.includes('missing key'))) {
|
|
1513
|
-
// ⚠ THE TIP USED TO NAME `./geneseed build --sync-themes`, AND THAT COMMAND ERRORS.
|
|
1514
|
-
// It was argued here on the premise that "`build` forwards its extra arguments to the
|
|
1515
|
-
// generator" — which was true of the reference and is not true of `cmdBuild`
|
|
1516
|
-
// (`js/generate.mjs`), which forwards `--theme` and nothing else. So the front door
|
|
1517
|
-
// answered `unrecognized arguments: --sync-themes` to anyone who followed doctor's own
|
|
1518
|
-
// advice, for as long as the premise went unchecked. The flag belongs to the GENERATOR,
|
|
1519
|
-
// whose binary is `geneseed-build` — which is what `README.md` and `SETUP.md` have said
|
|
1520
|
-
// all along. A hint is a command; if it is not runnable it is decoration.
|
|
1521
|
-
pyPrint(' tip: a theme is missing a key another theme defines — run '
|
|
1522
|
-
+ '`geneseed-build --sync-themes` to fill it from _TEMPLATE.json, '
|
|
1523
|
-
+ 'then restyle the added key(s) and re-check.\n');
|
|
1524
|
-
}
|
|
1525
|
-
if (note) pyPrint(`${note}\n`);
|
|
1526
|
-
return 1;
|
|
1527
|
-
}
|
|
1528
|
-
pyPrint('[doctor] ok — ' + `${themes.length} theme(s) clean: no unresolved tokens, no dead `
|
|
1529
|
-
+ 'links, nothing escapes the bundle; themes in parity; specs carry purpose '
|
|
1530
|
-
+ 'lines; rendered bundle in sync\n');
|
|
1531
|
-
if (note) pyPrint(`${note}\n`);
|
|
1532
|
-
return 0;
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
// --------------------------------------------------------------------------------------
|
|
1536
|
-
// validate — the generator's `--validate-only`, on this binary because it runs the doctor
|
|
1537
|
-
// --------------------------------------------------------------------------------------
|
|
1538
|
-
|
|
1539
|
-
/**
|
|
1540
|
-
* `build._validate_sandbox_problems` — the unresolved-token / dead-link / non-hermetic-link
|
|
1541
|
-
* scan over an already-rendered sandbox tree.
|
|
1542
|
-
*
|
|
1543
|
-
* A NEAR-TWIN OF `checkBuild` ABOVE, AND DELIBERATELY NOT IT, because the reference is two
|
|
1544
|
-
* functions and they differ in three observable ways: the token list is `sorted(set(...))`
|
|
1545
|
-
* here and bare `set(...)` there, the message carries no `[theme]` prefix (the caller adds
|
|
1546
|
-
* `[emit]` instead), and an unreadable file is SKIPPED rather than raised. Python duplicates
|
|
1547
|
-
* the loop because `build.py` cannot import the harness tree; here the two live in one file
|
|
1548
|
-
* and the duplication is eight lines — every primitive underneath (`rglob`, `stripCode`,
|
|
1549
|
-
* `linkProblems`, `readText`) is the shared one, which is the half that could actually drift.
|
|
1550
|
-
*/
|
|
1551
|
-
export function validateSandboxProblems(sandbox) {
|
|
1552
|
-
const out = pyResolve(sandbox);
|
|
1553
|
-
const problems = [];
|
|
1554
|
-
for (const md of rglob(out)) {
|
|
1555
|
-
if (!md.endsWith('.md') || !isFile(md)) continue;
|
|
1556
|
-
const rel = path.relative(out, md);
|
|
1557
|
-
if (validateIsVendored(rel)) continue;
|
|
1558
|
-
let text;
|
|
1559
|
-
// `except (OSError, UnicodeDecodeError): continue` — binary or unreadable, nothing here.
|
|
1560
|
-
try { text = readText(md); } catch { continue; }
|
|
1561
|
-
for (const tok of sortedUnique(text.match(TOKEN_RE) ?? [])) {
|
|
1562
|
-
problems.push(`unresolved token ${tok} in ${rel}`);
|
|
1563
|
-
}
|
|
1564
|
-
problems.push(...linkProblems(md, text, out, rel));
|
|
1565
|
-
}
|
|
1566
|
-
return problems;
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
/**
|
|
1570
|
-
* `subprocess.run(..., capture_output=True, text=True)` around an IN-PROCESS call.
|
|
1571
|
-
*
|
|
1572
|
-
* The reference shells to `harness.py doctor`; this calls `cmdDoctor` directly, and the
|
|
1573
|
-
* difference has to be undone in one place: a child's `capture_output` hands the parent
|
|
1574
|
-
* UNIVERSAL-NEWLINE text, so `r.stdout` holds `\n` on Windows even though the child wrote
|
|
1575
|
-
* `\r\n`. `pyPrint` inside `cmdDoctor` writes `\r\n`. Without the fold, the `.strip()` below
|
|
1576
|
-
* would leave every interior line CRLF and the re-print would double the translation.
|
|
1577
|
-
*/
|
|
1578
|
-
function captureStreams(fn) {
|
|
1579
|
-
const chunks = { out: [], err: [] };
|
|
1580
|
-
const realOut = process.stdout.write.bind(process.stdout);
|
|
1581
|
-
const realErr = process.stderr.write.bind(process.stderr);
|
|
1582
|
-
process.stdout.write = (c) => { chunks.out.push(String(c)); return true; };
|
|
1583
|
-
process.stderr.write = (c) => { chunks.err.push(String(c)); return true; };
|
|
1584
|
-
let code;
|
|
1585
|
-
try {
|
|
1586
|
-
code = fn();
|
|
1587
|
-
} finally {
|
|
1588
|
-
process.stdout.write = realOut;
|
|
1589
|
-
process.stderr.write = realErr;
|
|
1590
|
-
}
|
|
1591
|
-
const decode = (xs) => xs.join('').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
1592
|
-
return { code, out: decode(chunks.out), err: decode(chunks.err) };
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
/**
|
|
1596
|
-
* `build._validate_only` — render + emit the requested target into a throwaway sandbox, run
|
|
1597
|
-
* every validation a real build would gate on, print what WOULD have been written, and return
|
|
1598
|
-
* the exit code. Nothing under the sandbox survives, and no marker, manifest or registry row
|
|
1599
|
-
* is ever written for real.
|
|
1600
|
-
*
|
|
1601
|
-
* WHY IT IS HERE AND NOT ON THE GENERATOR DRIVER, which is where its flag lives. The
|
|
1602
|
-
* source-tree half of the check IS the doctor, and this module starts a process (`node --check`
|
|
1603
|
-
* over the OpenCode plugins). `bin/geneseed.mjs` is under a transitive ban on reaching any
|
|
1604
|
-
* module that can, gated twice — `tests/test_node_cli_parity.py` greps the driver's source and
|
|
1605
|
-
* `tests/test_hook_cli_parity.py` walks its relative imports. Siting the tool on the CLI
|
|
1606
|
-
* binary, which already carries the doctor, crosses it with neither gate amended and without
|
|
1607
|
-
* the dynamic `import()` that would evade the walk.
|
|
1608
|
-
*
|
|
1609
|
-
* THE MARKER GUARANTEE COMES FREE, and that is why `emitProjectInto`/`emitGlobalInto`/
|
|
1610
|
-
* `buildInto` are used rather than `driverMain(['--emit', …])`: the driver writes
|
|
1611
|
-
* `.geneseed-emit`/`.geneseed-footprint` and records an install-registry row AFTER the
|
|
1612
|
-
* dispatch, so routing through it would register a temp directory that is deleted a
|
|
1613
|
-
* millisecond later. The reference calls the emit functions directly for the same reason.
|
|
1614
|
-
*
|
|
1615
|
-
* THE EMIT'S OWN STDOUT IS NOT SWALLOWED. `globalEmitProblems` above wraps its call in
|
|
1616
|
-
* `swallowStdout` and this deliberately does not: `_validate_only` has no `redirect_stdout`,
|
|
1617
|
-
* so the ~200 lines of emit summary ARE part of what a dry run shows the operator.
|
|
1618
|
-
*
|
|
1619
|
-
* NO CELL CAN REACH THIS. `golden.py`'s `_argv` never emitted `--validate-only`, so the flag
|
|
1620
|
-
* was ungated across implementations for the whole port; the gate is the corpus in
|
|
1621
|
-
* `tests/test_maintainer_tools_parity.py`, which runs the reference and this side over the
|
|
1622
|
-
* same flags and compares stdout, stderr and the exit code.
|
|
1623
|
-
*/
|
|
1624
|
-
export function cmdValidate(args) {
|
|
1625
|
-
const problems = [];
|
|
1626
|
-
const scan = withTempDir((tmpRaw) => {
|
|
1627
|
-
// `.resolve()` mirrors `_check_build`'s: on Windows a temp dir can come back in 8.3
|
|
1628
|
-
// short form (`RUNNER~1`) while every link TARGET below is resolved long-form, and
|
|
1629
|
-
// `within` then rejects every relative link rather than only the escaping ones.
|
|
1630
|
-
const tmp = pyResolve(tmpRaw);
|
|
1631
|
-
// With a distinct `--root` the per-repo emits split their output — the bundle under
|
|
1632
|
-
// `out`, the native layer under `root` — so the sandbox bundle nests INSIDE the sandbox
|
|
1633
|
-
// root and the scan covers both layers.
|
|
1634
|
-
const root = args.root ? path.join(tmp, 'root') : path.join(tmp, 'out');
|
|
1635
|
-
const sandbox = args.root ? path.join(root, 'bundle') : root;
|
|
1636
|
-
const cfgDir = path.join(tmp, 'cfg');
|
|
1637
|
-
const emit = args.emit;
|
|
1638
|
-
const opts = { theme: args.theme, footprint: args.footprint };
|
|
1639
|
-
let scanDirs;
|
|
1640
|
-
try {
|
|
1641
|
-
if (emit.endsWith('-global')) {
|
|
1642
|
-
emitGlobalInto(emit.slice(0, -'-global'.length), { ...opts, out: sandbox, cfgDir });
|
|
1643
|
-
scanDirs = [cfgDir];
|
|
1644
|
-
} else if (emit === 'files') {
|
|
1645
|
-
buildInto({ ...opts, out: sandbox });
|
|
1646
|
-
scanDirs = [sandbox];
|
|
1647
|
-
} else {
|
|
1648
|
-
emitProjectInto(emit, { ...opts, out: sandbox, root });
|
|
1649
|
-
scanDirs = [root];
|
|
1650
|
-
}
|
|
1651
|
-
} catch (e) {
|
|
1652
|
-
// `except SystemExit as e` — a DELIBERATE refusal from the render (an unknown theme,
|
|
1653
|
-
// an incomplete source), interpolated as `{e}`.
|
|
1654
|
-
//
|
|
1655
|
-
// THE EXIT CODE AND NOT THE MESSAGE, measured rather than assumed: the reference's
|
|
1656
|
-
// render half is ALREADY this port. `_build_render.build` calls `_build_core.run_node`,
|
|
1657
|
-
// which ends `raise SystemExit(res.get("exit", 1))` — an INTEGER — so every refusal
|
|
1658
|
-
// that crosses the seam arrives here as `SystemExit(1)` and `str(e)` is `'1'`, not the
|
|
1659
|
-
// sentence `load_theme` wrote on stderr on the way out. A port that printed `e.message`
|
|
1660
|
-
// says `unknown theme 'x'` where the reference says `1`. The corpus in
|
|
1661
|
-
// `tests/test_maintainer_tools_parity.py` is what found it; nothing else could.
|
|
1662
|
-
if (e && e.exitCode !== undefined) {
|
|
1663
|
-
pyPrint(`[validate-only] render/emit FAILED for theme '${args.theme}' `
|
|
1664
|
-
+ `emit '${emit}': ${e.exitCode}\n`);
|
|
1665
|
-
return null;
|
|
1666
|
-
}
|
|
1667
|
-
throw e;
|
|
1668
|
-
}
|
|
1669
|
-
|
|
1670
|
-
const written = scanDirs.filter(isDir).flatMap((d) => rglob(d)).filter(isFile)
|
|
1671
|
-
.sort(comparePaths);
|
|
1672
|
-
pyPrint(`[validate-only] theme=${args.theme} emit=${emit} `
|
|
1673
|
-
+ `footprint=${args.footprint}\n`);
|
|
1674
|
-
pyPrint(`[validate-only] would write ${written.length} file(s) under ${args.out}`
|
|
1675
|
-
+ (args.root ? ` (root ${args.root})` : '')
|
|
1676
|
-
+ ' — nothing was actually written (sandboxed).\n');
|
|
1677
|
-
if (args.verbose) {
|
|
1678
|
-
let base = scanDirs[0];
|
|
1679
|
-
for (const p of written) {
|
|
1680
|
-
for (const d of scanDirs) {
|
|
1681
|
-
if (within(p, d)) { base = d; break; }
|
|
1682
|
-
}
|
|
1683
|
-
pyPrint(` would write: ${path.relative(base, p)}\n`);
|
|
1684
|
-
}
|
|
1685
|
-
}
|
|
1686
|
-
for (const d of scanDirs) {
|
|
1687
|
-
for (const p of validateSandboxProblems(d)) problems.push(`[${emit}] ${p}`);
|
|
1688
|
-
}
|
|
1689
|
-
return scanDirs;
|
|
1690
|
-
}, 'geneseed-validate-');
|
|
1691
|
-
if (scan === null) return 1;
|
|
1692
|
-
|
|
1693
|
-
// The source-tree-wide checks (theme parity, authoring gates, AGENT.md table parity,
|
|
1694
|
-
// colour themes) do not depend on --out/--root/--emit at all and already live fully tested
|
|
1695
|
-
// in the doctor, so they run through it rather than being re-derived. The reference SHELLS
|
|
1696
|
-
// to `harness.py doctor --theme T --no-bundle`; one process is what this port has.
|
|
1697
|
-
const doctor = captureStreams(() => cmdDoctor({ theme: args.theme, noBundle: true }));
|
|
1698
|
-
if (pyStripSpace(doctor.out)) pyPrint(`${pyStripSpace(doctor.out)}\n`);
|
|
1699
|
-
if (pyStripSpace(doctor.err)) pyPrintErr(`${pyStripSpace(doctor.err)}\n`);
|
|
1700
|
-
if (doctor.code !== 0) {
|
|
1701
|
-
problems.push(`[doctor] source-tree validation failed for theme '${args.theme}' `
|
|
1702
|
-
+ '(see output above)');
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
if (problems.length) {
|
|
1706
|
-
pyPrint(`[validate-only] ${problems.length} problem(s):\n`);
|
|
1707
|
-
for (const p of problems) pyPrint(` - ${p}\n`);
|
|
1708
|
-
return 1;
|
|
1709
|
-
}
|
|
1710
|
-
pyPrint('[validate-only] ok — would render and emit cleanly, no problems found.\n');
|
|
1711
|
-
return 0;
|
|
1712
|
-
}
|