geneseed 4.4.1 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +6 -6
- package/SHIPPED.md +1 -1
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +3 -2
- package/adapters/opencode/plugins/geneseed-context.js +195 -52
- package/adapters/opencode/plugins/geneseed-guard.js +42 -29
- package/adapters/opencode/plugins/geneseed-learn.js +1 -1
- package/docs/concepts/enforcement-table.md +9 -3
- package/docs/concepts/hooks.md +3 -0
- package/docs/extending.md +1 -1
- package/docs/guides/install-clone.md +1 -1
- package/docs/guides/install.md +2 -2
- package/docs/reference/plugin-context.md +2 -0
- package/docs/reference/troubleshoot-doctor.md +31 -0
- package/docs/reference/troubleshoot-install.md +5 -5
- package/docs/understand/enforced-vs-asked.md +1 -0
- package/harness.config.json +1 -1
- package/js/README.md +8 -5
- package/js/build/bundle.mjs +11 -2
- package/js/build/driver.mjs +1 -10
- package/js/build/emit-claude.mjs +7 -22
- package/js/build/emit-common.mjs +16 -9
- package/js/build/emit-opencode.mjs +5 -11
- package/js/build/generate.mjs +1 -1
- package/js/cli-table.json +15 -0
- package/js/hosts/hooks-context.mjs +1 -4
- package/js/hosts/hooks-done.mjs +186 -0
- package/js/hosts/hooks-learn.mjs +9 -4
- package/js/hosts/hooks.mjs +27 -8
- package/js/hosts/hosts.mjs +2 -0
- package/js/hosts/installs.mjs +47 -11
- package/js/hosts/native.mjs +5 -13
- package/js/hosts/opencode.mjs +37 -22
- package/js/hosts/owned.mjs +19 -5
- package/js/hosts/settings.mjs +17 -11
- package/js/hosts/shim.mjs +24 -10
- package/js/hosts/usage.mjs +4 -3
- package/js/inspect/checks-authoring.mjs +9 -7
- package/js/inspect/checks-constitution.mjs +2 -2
- package/js/inspect/checks-probe.mjs +292 -0
- package/js/inspect/checks-repo.mjs +13 -54
- package/js/inspect/cost.mjs +5 -6
- package/js/inspect/doctor.mjs +29 -3
- package/js/inspect/scan.mjs +9 -5
- package/js/inspect/scorecard-svg.mjs +3 -3
- package/js/inspect/scorecard-yaml.mjs +407 -0
- package/js/inspect/scorecard.mjs +7 -405
- package/js/inspect/status.mjs +14 -13
- package/js/lib/node-floor.mjs +4 -3
- package/js/maintain/migrate.mjs +2 -3
- package/js/maintain/uninstall.mjs +20 -26
- package/js/web/actions.mjs +2 -2
- package/js/web/api.mjs +36 -24
- package/js/web/gates-api.mjs +5 -8
- package/js/web/usage-api.mjs +14 -17
- package/package.json +2 -2
- package/src/skills/rule.md +11 -2
- package/web/dist/assets/{Activity-BZE6REef.js → Activity-Dj0izJwj.js} +1 -1
- package/web/dist/assets/{ActivityDetail-DCB8Cr2W.js → ActivityDetail-wjq5W02K.js} +1 -1
- package/web/dist/assets/Dashboard-UMbgHbzF.js +2 -0
- package/web/dist/assets/{Docs-D30CiqYy.js → Docs-D3ABE_ds.js} +3 -3
- package/web/dist/assets/{GreenhouseView-Djt-4oO5.js → GreenhouseView-C238LWBD.js} +1 -1
- package/web/dist/assets/{Installs-B3bw5ckh.js → Installs-XSm0a_zQ.js} +2 -2
- package/web/dist/assets/JournalView-BUj4Ff-6.js +1 -0
- package/web/dist/assets/{Laws-B9day2FV.js → Laws-yGBJFKgY.js} +1 -1
- package/web/dist/assets/{Library-BpIrLH-O.js → Library-CkaPVUYU.js} +1 -1
- package/web/dist/assets/{Loops-CxH14kyE.js → Loops-CexwOARr.js} +1 -1
- package/web/dist/assets/{OperatorHudView-otOz6Gha.js → OperatorHudView-D5qTQat0.js} +1 -1
- package/web/dist/assets/{Personal-abL6089y.js → Personal-B_Vx9a7a.js} +1 -1
- package/web/dist/assets/Tabs-YZbGqWPW.js +1 -0
- package/web/dist/assets/drawable-BoQnwVpr.js +1 -0
- package/web/dist/assets/index-BmbWeoTM.js +2 -0
- package/web/dist/assets/{index-CO-9wMJY.css → index-BoG1M8Xy.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/Dashboard-B4Drq5wp.js +0 -2
- package/web/dist/assets/JournalView-OWhx9YKg.js +0 -1
- package/web/dist/assets/Tabs-DjWN0i7A.js +0 -1
- package/web/dist/assets/drawable-C2ZsBZCv.js +0 -1
- package/web/dist/assets/index-CdpLpPQN.js +0 -2
package/js/inspect/cost.mjs
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Four parts, each the thing the host loads before the user types:
|
|
8
8
|
* - `carrier` the root instruction file (`AGENT.md`, `CLAUDE.md`, Bob's `rules/geneseed.md` or
|
|
9
|
-
* `AGENTS.md`), CRLF
|
|
10
|
-
*
|
|
9
|
+
* `AGENTS.md`), CRLF folded to LF as the smoke test folds it, so the figure and
|
|
10
|
+
* `CARRIER_CEILING` are the same measurement. `readText` also folds a lone CR,
|
|
11
|
+
* which the smoke test does not, so the two agree on any carrier without one.
|
|
11
12
|
* - `skills` the name + description every native skill lists (aliases included: they are
|
|
12
13
|
* SKILL.md files the host lists like any other).
|
|
13
14
|
* - `agents` the same for native agents; Bob has none, its agents table is in the carrier.
|
|
@@ -30,8 +31,6 @@ import { isFile, readText } from '../lib/fs.mjs';
|
|
|
30
31
|
*/
|
|
31
32
|
export const CARRIER_CEILING = { full: 62_219, lean: 39_088 };
|
|
32
33
|
|
|
33
|
-
const decode = (p) => readText(p).split('\r\n').join('\n');
|
|
34
|
-
|
|
35
34
|
/**
|
|
36
35
|
* A frontmatter value as the host shows it. The emit writes descriptions JSON-escaped
|
|
37
36
|
* (`—` for one em dash), and `frontmatter()` has already stripped the quotes, so the
|
|
@@ -48,7 +47,7 @@ function fmValue(raw) {
|
|
|
48
47
|
/** name + description of one spec file; `fallbackName` for agents, whose name is the file stem. */
|
|
49
48
|
function listedChars(file, fallbackName) {
|
|
50
49
|
let fm;
|
|
51
|
-
try { [fm] = frontmatter(
|
|
50
|
+
try { [fm] = frontmatter(readText(file)); } catch { return 0; }
|
|
52
51
|
const name = fmValue(fm.get('name')) || fallbackName;
|
|
53
52
|
return name.length + fmValue(fm.get('description')).length;
|
|
54
53
|
}
|
|
@@ -61,7 +60,7 @@ function listedChars(file, fallbackName) {
|
|
|
61
60
|
export function alwaysOnCost({ host, root, dir, footprint }) {
|
|
62
61
|
const carrierPath = carriersFor(host).map((rel) => path.join(root, rel)).find(isFile);
|
|
63
62
|
if (!carrierPath) return null;
|
|
64
|
-
const carrier =
|
|
63
|
+
const carrier = readText(carrierPath).length;
|
|
65
64
|
|
|
66
65
|
let skills = 0;
|
|
67
66
|
const skillsDir = path.join(dir, 'skills');
|
package/js/inspect/doctor.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
import { validateIsVendored } from '../hosts/native.mjs';
|
|
40
40
|
import { printOut } from '../lib/fs.mjs';
|
|
41
41
|
import { authoringProblems, loopProblems } from './checks-authoring.mjs';
|
|
42
|
+
import { gateProbe, guardProbe } from './checks-probe.mjs';
|
|
42
43
|
import {
|
|
43
44
|
checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
|
|
44
45
|
} from './checks-build.mjs';
|
|
@@ -188,6 +189,7 @@ export function claudeBobEmitProblems(themeName) {
|
|
|
188
189
|
*/
|
|
189
190
|
export function doctorCollect({
|
|
190
191
|
theme = null, allThemes = false, bundle = null, noBundle = false, groups = null,
|
|
192
|
+
probe = false, probed = null, extraProbeProblems = [],
|
|
191
193
|
} = {}) {
|
|
192
194
|
const available = themeFiles().map(stemOf);
|
|
193
195
|
if (!available.length) return [[], ['[doctor] no themes found']];
|
|
@@ -208,6 +210,11 @@ export function doctorCollect({
|
|
|
208
210
|
// placed after them could only ever observe the freshly repaired file and would be dead
|
|
209
211
|
// code that always reports clean. Reported in its usual slot further down.
|
|
210
212
|
const shimProbs = shimProblems();
|
|
213
|
+
// `--probe` (js/inspect/checks-probe.mjs) runs the installed gates THROUGH the shim, so it is
|
|
214
|
+
// sampled here for the same reason: after the emits it could only meet a repaired shim.
|
|
215
|
+
// `probed` collects the gates that behaved, for `cmdDoctor`'s summary line.
|
|
216
|
+
const probeRun = probe ? gateProbe() : null;
|
|
217
|
+
if (probeRun && probed) probed.push(...probeRun.probed);
|
|
211
218
|
let problems = [];
|
|
212
219
|
withTempDir((tmp) => {
|
|
213
220
|
for (const themeName of themes) {
|
|
@@ -234,9 +241,12 @@ export function doctorCollect({
|
|
|
234
241
|
problems = problems.concat(ran('loops', 'Loop catalogue', loopProblems()));
|
|
235
242
|
problems = problems.concat(ran('shim', 'Hook shim', shimProbs));
|
|
236
243
|
// Windows: a Claude install's hooks emitted for Git Bash on a machine that no longer has it
|
|
237
|
-
// misbehave under PowerShell
|
|
238
|
-
//
|
|
244
|
+
// misbehave under PowerShell — context/learn fail open, and the two gates fail CLOSED
|
|
245
|
+
// instead (`onFailure: "block"`). The reverse direction is a note.
|
|
239
246
|
problems = problems.concat(ran('hook_shell', 'Hook shell', hookShellProblems()));
|
|
247
|
+
if (probeRun) {
|
|
248
|
+
problems = problems.concat(ran('probe', 'Gate probe', [...probeRun.problems, ...extraProbeProblems]));
|
|
249
|
+
}
|
|
240
250
|
// A machine check like the shim's: OPENCODE_CONFIG_DIR adds a dir, it does not replace one.
|
|
241
251
|
const shadow = opencodeShadowedInstall();
|
|
242
252
|
problems = problems.concat(ran('opencode_dirs', 'OpenCode config dirs', shadow
|
|
@@ -281,9 +291,19 @@ function captureBoth(fn) {
|
|
|
281
291
|
* maintainer sweep. The cross-theme parity check runs in every mode.
|
|
282
292
|
*/
|
|
283
293
|
export function cmdDoctor(args) {
|
|
294
|
+
// `--probe` alone is asynchronous: the OpenCode guard is probed by importing the installed
|
|
295
|
+
// plugin (`guardProbe`), and `import()` has no synchronous form. Every other path stays
|
|
296
|
+
// synchronous, because `validate` and `setup` call this and read its number directly.
|
|
297
|
+
if (args.probe) return guardProbe().then((guard) => doctorReport(args, guard));
|
|
298
|
+
return doctorReport(args, null);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function doctorReport(args, guard) {
|
|
302
|
+
const probed = guard ? [...guard.probed] : [];
|
|
284
303
|
const [themes, collected] = doctorCollect({
|
|
285
304
|
theme: args.theme, allThemes: Boolean(args.all), bundle: args.bundle,
|
|
286
|
-
noBundle: Boolean(args.noBundle),
|
|
305
|
+
noBundle: Boolean(args.noBundle), probe: Boolean(args.probe), probed,
|
|
306
|
+
extraProbeProblems: guard ? guard.problems : [],
|
|
287
307
|
});
|
|
288
308
|
if (!themes.length) {
|
|
289
309
|
printOut(`${collected.length ? collected[0] : '[doctor] no themes found'}\n`);
|
|
@@ -303,6 +323,12 @@ export function cmdDoctor(args) {
|
|
|
303
323
|
const note = scoped
|
|
304
324
|
? ` (scoped to installed theme '${themes[0]}'; run with --all to sweep every theme)` : '';
|
|
305
325
|
for (const n of notes) printOut(`${n}\n`);
|
|
326
|
+
// The probe's line on success, and the limit it cannot see past, said every time.
|
|
327
|
+
if (args.probe) {
|
|
328
|
+
printOut(`[probe] ${probed.length} gate(s) refused a destructive call and let a safe one `
|
|
329
|
+
+ `through${probed.length ? `: ${probed.join(', ')}` : ''} — under this terminal's `
|
|
330
|
+
+ 'environment, not the host app\'s\n');
|
|
331
|
+
}
|
|
306
332
|
if (problems.length) {
|
|
307
333
|
printOut(`[doctor] ${problems.length} problem(s) across ${themes.length} theme(s):\n`);
|
|
308
334
|
for (const p of problems) printOut(` - ${p}\n`);
|
package/js/inspect/scan.mjs
CHANGED
|
@@ -14,15 +14,13 @@ import { SRC } from '../build/source.mjs';
|
|
|
14
14
|
import { comparePaths, within } from '../lib/paths.mjs';
|
|
15
15
|
import { isDir, isFile } from '../lib/fs.mjs';
|
|
16
16
|
import { has } from '../lib/json.mjs';
|
|
17
|
-
import { FENCE_RE, INLINE_CODE_RE
|
|
17
|
+
import { FENCE_RE, INLINE_CODE_RE } from '../lib/text.mjs';
|
|
18
18
|
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
|
19
19
|
|
|
20
20
|
// `isDir`/`isFile`/`has` are owned by `js/lib` now (single owner across `hosts/`, `build/`,
|
|
21
21
|
// `inspect/` and `web/`) — re-exported here so this module's own existing callers keep
|
|
22
|
-
// importing them from `./scan.mjs` unchanged.
|
|
23
|
-
|
|
24
|
-
// answer and cannot import this module (it would cycle back through `../build/source.mjs` above).
|
|
25
|
-
export { isDir, isFile, has, blankCodeSpans };
|
|
22
|
+
// importing them from `./scan.mjs` unchanged.
|
|
23
|
+
export { isDir, isFile, has };
|
|
26
24
|
|
|
27
25
|
// --------------------------------------------------------------------------------------
|
|
28
26
|
// _harness_core's scanning primitives
|
|
@@ -106,3 +104,9 @@ export function withTempDir(fn, prefix = 'geneseed-doctor-') {
|
|
|
106
104
|
try { return fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
107
105
|
}
|
|
108
106
|
|
|
107
|
+
/** `withTempDir` for an async body: the dir outlives the promise, then goes. */
|
|
108
|
+
export async function withTempDirAsync(fn, prefix = 'geneseed-doctor-') {
|
|
109
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
110
|
+
try { return await fn(tmp); } finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
111
|
+
}
|
|
112
|
+
|
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
* on every machine, which is what lets doctor compare them. Colours follow the viewer's theme
|
|
13
13
|
* through a `prefers-color-scheme` block, which GitHub honours inside an `<img>` SVG.
|
|
14
14
|
*/
|
|
15
|
-
import { round1 } from './scorecard.mjs';
|
|
15
|
+
import { computeGrade, round1 } from './scorecard.mjs';
|
|
16
16
|
|
|
17
|
-
/** The grade band a percentage falls in — the
|
|
18
|
-
const band = (pct) => (pct
|
|
17
|
+
/** The grade band a percentage falls in — the overall grade's own thresholds, never a copy. */
|
|
18
|
+
const band = (pct) => computeGrade(pct).toLowerCase();
|
|
19
19
|
|
|
20
20
|
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
21
21
|
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The YAML subset `geneseed scorecard` reads CI configs with — PyYAML's YAML 1.1 behaviour for
|
|
3
|
+
* the shapes upstream's `ci_parser.py` meets, and Python's `str()`/`bool()` over the result.
|
|
4
|
+
*
|
|
5
|
+
* WHY ITS OWN FILE: it is a parser, not a port of a scorecard module — nothing in it knows what
|
|
6
|
+
* a CI job is. `scorecard.mjs` re-exports its public names, so callers keep one import site.
|
|
7
|
+
* The `ponytail:` note on `parseYamlSubset` states its ceiling.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
class YamlError extends Error {}
|
|
11
|
+
|
|
12
|
+
/** A flow mapping (`{a: b}`), kept as its source text — the subset does not look inside one. */
|
|
13
|
+
export class FlowMap {
|
|
14
|
+
constructor(raw) { this.raw = raw; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const NULLS = new Set(['', '~', 'null', 'Null', 'NULL']);
|
|
18
|
+
const TRUES = new Set(['yes', 'Yes', 'YES', 'true', 'True', 'TRUE', 'on', 'On', 'ON']);
|
|
19
|
+
const FALSES = new Set(['no', 'No', 'NO', 'false', 'False', 'FALSE', 'off', 'Off', 'OFF']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* PyYAML's YAML 1.1 resolver for a plain scalar: null, the eighteen booleans (`on` is `True`,
|
|
23
|
+
* which is why upstream looks the GitHub trigger key up under both `"on"` and `True`), ints and
|
|
24
|
+
* floats. Everything else stays a string.
|
|
25
|
+
*/
|
|
26
|
+
function resolvePlain(s) {
|
|
27
|
+
if (NULLS.has(s)) return null;
|
|
28
|
+
if (TRUES.has(s)) return true;
|
|
29
|
+
if (FALSES.has(s)) return false;
|
|
30
|
+
if (/^[-+]?(0|[1-9][0-9_]*)$/.test(s)) return Number(s.replaceAll('_', ''));
|
|
31
|
+
if (/^[-+]?([0-9][0-9_]*)?\.[0-9_]*([eE][-+][0-9]+)?$/.test(s) && /[0-9]/.test(s)) {
|
|
32
|
+
return Number(s.replaceAll('_', ''));
|
|
33
|
+
}
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `str(value)` for what the subset produces. A collection prints as Python's `repr`, because
|
|
39
|
+
* that is the command text upstream matches: `- echo "a: b"` in a GitLab script is a MAPPING
|
|
40
|
+
* to YAML, and `str()` of it is `{'echo "a': 'b"'}`.
|
|
41
|
+
*/
|
|
42
|
+
export function pyStr(v) {
|
|
43
|
+
if (typeof v === 'string') return v;
|
|
44
|
+
if (v === null || v === undefined) return 'None';
|
|
45
|
+
if (v === true) return 'True';
|
|
46
|
+
if (v === false) return 'False';
|
|
47
|
+
if (v instanceof FlowMap) return v.raw;
|
|
48
|
+
if (Array.isArray(v)) return `[${v.map(pyRepr).join(', ')}]`;
|
|
49
|
+
if (v instanceof Map) return `{${[...v].map(([k, x]) => `${pyRepr(k)}: ${pyRepr(x)}`).join(', ')}}`;
|
|
50
|
+
return String(v);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pyRepr(v) {
|
|
54
|
+
if (typeof v !== 'string') return pyStr(v);
|
|
55
|
+
const q = v.includes("'") && !v.includes('"') ? '"' : "'";
|
|
56
|
+
const body = v.replace(/[\\\n\r\t]/g, (c) => ({ '\\': '\\\\', '\n': '\\n', '\r': '\\r', '\t': '\\t' })[c])
|
|
57
|
+
.replaceAll(q, `\\${q}`);
|
|
58
|
+
return `${q}${body}${q}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `bool(value)` — `allow_failure: {exit_codes: [1]}` and `"false"` are both TRUE upstream. */
|
|
62
|
+
export function pyTruthy(v) {
|
|
63
|
+
if (v === null || v === undefined || v === false) return false;
|
|
64
|
+
if (typeof v === 'number') return v !== 0;
|
|
65
|
+
if (typeof v === 'string' || Array.isArray(v)) return v.length > 0;
|
|
66
|
+
if (v instanceof Map) return v.size > 0;
|
|
67
|
+
if (v instanceof FlowMap) return v.raw.replace(/\s/g, '') !== '{}';
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Fold the line breaks inside a multi-line flow or quoted scalar: one break is a space. */
|
|
72
|
+
function foldBreaks(s) {
|
|
73
|
+
return s.replace(/[ \t]*\n([ \t]*\n)*[ \t]*/g, (m) => {
|
|
74
|
+
const n = m.split('\n').length - 1;
|
|
75
|
+
return n === 1 ? ' ' : '\n'.repeat(n - 1);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ESCAPES = { n: '\n', t: '\t', r: '\r', 0: '\0', '"': '"', '\\': '\\', '/': '/', ' ': ' ', e: '\x1b', a: '\x07', b: '\b', f: '\f', v: '\v', N: '\x85', _: '\xa0' };
|
|
80
|
+
|
|
81
|
+
function unescapeDouble(s) {
|
|
82
|
+
return s.replace(/\\(x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|.)/gs, (m, e) => {
|
|
83
|
+
if (e.length > 1) return String.fromCodePoint(parseInt(e.slice(1), 16));
|
|
84
|
+
if (e in ESCAPES) return ESCAPES[e];
|
|
85
|
+
throw new YamlError(`unknown escape \\${e}`);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Index just past the closing quote of the quoted scalar starting at `s[0]`, or -1. */
|
|
90
|
+
function quoteEnd(s) {
|
|
91
|
+
const q = s[0];
|
|
92
|
+
for (let i = 1; i < s.length; i += 1) {
|
|
93
|
+
if (q === '"' && s[i] === '\\') { i += 1; continue; }
|
|
94
|
+
if (s[i] === q) {
|
|
95
|
+
if (q === "'" && s[i + 1] === "'") { i += 1; continue; }
|
|
96
|
+
return i + 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return -1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function quotedValue(span) {
|
|
103
|
+
const body = span.slice(1, -1);
|
|
104
|
+
if (span[0] === "'") return foldBreaks(body).replaceAll("''", "'");
|
|
105
|
+
// A `\` before a line break escapes the break itself: no space is folded in for it.
|
|
106
|
+
return unescapeDouble(foldBreaks(body.replace(/(?<!\\)\\\n[ \t]*/g, '')));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Index just past the bracket that closes `s[0]`, honouring quotes; -1 while still open. */
|
|
110
|
+
function flowEnd(s) {
|
|
111
|
+
let depth = 0;
|
|
112
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
113
|
+
const c = s[i];
|
|
114
|
+
if ((c === '"' || c === "'") && /^$|[\s[{,]/.test(s[i - 1] ?? '')) {
|
|
115
|
+
const e = quoteEnd(s.slice(i));
|
|
116
|
+
if (e < 0) return -1;
|
|
117
|
+
i += e - 1;
|
|
118
|
+
} else if (c === '[' || c === '{') depth += 1;
|
|
119
|
+
else if (c === ']' || c === '}') { depth -= 1; if (depth === 0) return i + 1; }
|
|
120
|
+
}
|
|
121
|
+
return -1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A flow sequence of scalars becomes an array; a flow mapping anywhere stays opaque. */
|
|
125
|
+
function parseFlow(src) {
|
|
126
|
+
if (src[0] === '{') return new FlowMap(src);
|
|
127
|
+
const items = [];
|
|
128
|
+
let i = 1;
|
|
129
|
+
while (i < src.length - 1) {
|
|
130
|
+
while (/[\s,]/.test(src[i]) && i < src.length - 1) i += 1;
|
|
131
|
+
if (i >= src.length - 1) break;
|
|
132
|
+
const rest = src.slice(i, -1);
|
|
133
|
+
let end;
|
|
134
|
+
if (rest[0] === '[' || rest[0] === '{') {
|
|
135
|
+
end = flowEnd(rest);
|
|
136
|
+
items.push(parseFlow(rest.slice(0, end)));
|
|
137
|
+
} else if (rest[0] === '"' || rest[0] === "'") {
|
|
138
|
+
end = quoteEnd(rest);
|
|
139
|
+
items.push(quotedValue(rest.slice(0, end)));
|
|
140
|
+
} else {
|
|
141
|
+
end = rest.search(/,|$/);
|
|
142
|
+
items.push(resolvePlain(foldBreaks(rest.slice(0, end)).trim()));
|
|
143
|
+
}
|
|
144
|
+
i += end;
|
|
145
|
+
}
|
|
146
|
+
return items;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The CI-config YAML subset upstream's checks read, parsed into Maps, arrays and scalars.
|
|
151
|
+
*
|
|
152
|
+
* Returns the document, or `undefined` where PyYAML's `safe_load` would RAISE — and upstream
|
|
153
|
+
* then drops the whole file (`_load_yaml` answers `None`), so a raise is behaviour to keep, not
|
|
154
|
+
* an error to paper over. Raised here: a second document, a local tag such as GitLab's
|
|
155
|
+
* `!reference` (unknown to `safe_load`), a plain value holding `: `, a tab used as indentation.
|
|
156
|
+
*
|
|
157
|
+
* Handles block mappings and sequences (including a sequence at its key's own indent, and a
|
|
158
|
+
* mapping that starts on the `- ` line), `|`/`>` block scalars with chomping and indentation
|
|
159
|
+
* indicators, plain scalars (multi-line, ` #` comments), single- and double-quoted scalars
|
|
160
|
+
* (escapes, multi-line), flow sequences of scalars, `&anchor` prefixes and `!!` tags.
|
|
161
|
+
*
|
|
162
|
+
* ponytail: a subset, not YAML. Its ceiling: flow MAPPINGS are kept opaque (`FlowMap`), so a
|
|
163
|
+
* job or rule written `{...}` is invisible; aliases (`*x`) and merge keys (`<<: *x`) are not
|
|
164
|
+
* resolved — an alias reads as the string `*x` — which is the likeliest real miss on GitLab
|
|
165
|
+
* files that share `before_script` through anchors; `? ` complex keys are refused;
|
|
166
|
+
* multi-document files are refused as PyYAML refuses them. Upgrade path: vendor a YAML parser
|
|
167
|
+
* under `js/vendor/` (CLAUDE.md: never install one) once a scored repo needs any of those.
|
|
168
|
+
*/
|
|
169
|
+
export function parseYamlSubset(text) {
|
|
170
|
+
try {
|
|
171
|
+
return new YamlParser(text).document();
|
|
172
|
+
} catch (e) {
|
|
173
|
+
if (e instanceof YamlError) return undefined;
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
class YamlParser {
|
|
179
|
+
constructor(text) {
|
|
180
|
+
this.raw = text.replace(/^\uFEFF/, '').split('\n');
|
|
181
|
+
this.lines = this.raw.map((r) => {
|
|
182
|
+
const indent = r.length - r.replace(/^ +/, '').length;
|
|
183
|
+
const text = r.slice(indent).replace(/\s+$/, '');
|
|
184
|
+
// A tab where indentation goes is a YAML error — raised by `peek`, on content lines only.
|
|
185
|
+
return { indent, text: text.replace(/^\t+/, ''), tab: text.startsWith('\t') };
|
|
186
|
+
});
|
|
187
|
+
this.pos = 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
document() {
|
|
191
|
+
let started = false;
|
|
192
|
+
let i = 0;
|
|
193
|
+
for (; i < this.lines.length; i += 1) {
|
|
194
|
+
const { indent, text } = this.lines[i];
|
|
195
|
+
if (indent === 0 && /^---(\s|$)/.test(text)) {
|
|
196
|
+
if (started) throw new YamlError('a second document');
|
|
197
|
+
started = true;
|
|
198
|
+
this.lines[i] = { indent: 0, text: '' };
|
|
199
|
+
} else if (indent === 0 && /^\.\.\.(\s|$)/.test(text)) {
|
|
200
|
+
this.lines.length = i;
|
|
201
|
+
break;
|
|
202
|
+
} else if (indent === 0 && text.startsWith('%') && !started) {
|
|
203
|
+
this.lines[i] = { indent: 0, text: '' };
|
|
204
|
+
} else if (text && !text.startsWith('#')) {
|
|
205
|
+
started = true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const value = this.node(0);
|
|
209
|
+
if (this.peek() !== null) throw new YamlError('content after the document');
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Index of the next line that is neither blank nor a comment, or null. */
|
|
214
|
+
peek() {
|
|
215
|
+
for (let i = this.pos; i < this.lines.length; i += 1) {
|
|
216
|
+
const { text } = this.lines[i];
|
|
217
|
+
if (text && !text.startsWith('#')) {
|
|
218
|
+
if (this.lines[i].tab) throw new YamlError('a tab as indentation');
|
|
219
|
+
this.pos = i;
|
|
220
|
+
return i;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
this.pos = this.lines.length;
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
node(minIndent) {
|
|
228
|
+
const i = this.peek();
|
|
229
|
+
if (i === null || this.lines[i].indent < minIndent) return null;
|
|
230
|
+
const { indent, text } = this.lines[i];
|
|
231
|
+
if (isSeqItem(text)) return this.seq(indent);
|
|
232
|
+
if (splitKey(text)) return this.map(indent);
|
|
233
|
+
this.pos = i + 1;
|
|
234
|
+
return this.value(text, indent - 1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The value of `key:` with nothing after it — or of a bare `-`. */
|
|
238
|
+
nested(indent, sameIndentSeq) {
|
|
239
|
+
const i = this.peek();
|
|
240
|
+
if (i === null) return null;
|
|
241
|
+
const ln = this.lines[i];
|
|
242
|
+
if (sameIndentSeq && ln.indent === indent && isSeqItem(ln.text)) return this.seq(indent);
|
|
243
|
+
return ln.indent > indent ? this.node(ln.indent) : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
map(indent) {
|
|
247
|
+
const m = new Map();
|
|
248
|
+
for (let i = this.peek(); i !== null; i = this.peek()) {
|
|
249
|
+
const ln = this.lines[i];
|
|
250
|
+
if (ln.indent < indent || (ln.indent === indent && isSeqItem(ln.text))) break;
|
|
251
|
+
if (ln.indent > indent) throw new YamlError('bad indentation');
|
|
252
|
+
const kv = splitKey(ln.text);
|
|
253
|
+
if (!kv) throw new YamlError('expected a key');
|
|
254
|
+
this.pos = i + 1;
|
|
255
|
+
// Python dicts keep the FIRST position of a duplicate key and the LAST value; so do Maps.
|
|
256
|
+
m.set(kv.key, kv.rest === '' || kv.rest.startsWith('#')
|
|
257
|
+
? this.nested(indent, true) : this.value(kv.rest, indent));
|
|
258
|
+
}
|
|
259
|
+
return m;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
seq(indent) {
|
|
263
|
+
const arr = [];
|
|
264
|
+
for (let i = this.peek(); i !== null; i = this.peek()) {
|
|
265
|
+
const ln = this.lines[i];
|
|
266
|
+
if (ln.indent < indent || !isSeqItem(ln.text)) break;
|
|
267
|
+
if (ln.indent > indent) throw new YamlError('bad indentation');
|
|
268
|
+
const after = ln.text.slice(1);
|
|
269
|
+
const body = after.trimStart();
|
|
270
|
+
if (body === '' || body.startsWith('#')) {
|
|
271
|
+
this.pos = i + 1;
|
|
272
|
+
arr.push(this.nested(indent, false));
|
|
273
|
+
} else {
|
|
274
|
+
// `- key: v` opens a mapping whose keys sit at the column `key` starts in: re-read the
|
|
275
|
+
// rest of this line as a node of its own at that column.
|
|
276
|
+
const col = indent + 1 + (after.length - body.length);
|
|
277
|
+
this.lines[i] = { indent: col, text: body };
|
|
278
|
+
arr.push(this.node(col));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return arr;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** A value that starts on the current line, after `key: ` or `- `. */
|
|
285
|
+
value(rest, parentIndent) {
|
|
286
|
+
let s = rest;
|
|
287
|
+
for (;;) {
|
|
288
|
+
const anchor = /^&\S+\s*/.exec(s);
|
|
289
|
+
if (anchor) { s = s.slice(anchor[0].length); continue; }
|
|
290
|
+
const tag = /^!(\S*)\s*/.exec(s);
|
|
291
|
+
if (tag) {
|
|
292
|
+
if (!tag[1].startsWith('!')) throw new YamlError(`unknown tag !${tag[1]}`);
|
|
293
|
+
s = s.slice(tag[0].length);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
if (s === '' || s.startsWith('#')) return this.nested(parentIndent, false);
|
|
299
|
+
const c = s[0];
|
|
300
|
+
if (c === '|' || c === '>') return this.block(s, parentIndent);
|
|
301
|
+
if (c === '"' || c === "'" || c === '[' || c === '{') {
|
|
302
|
+
let span = s;
|
|
303
|
+
let end = c === '[' || c === '{' ? flowEnd(span) : quoteEnd(span);
|
|
304
|
+
while (end < 0 && this.pos < this.lines.length) {
|
|
305
|
+
span += `\n${this.raw[this.pos].trim()}`;
|
|
306
|
+
this.pos += 1;
|
|
307
|
+
end = c === '[' || c === '{' ? flowEnd(span) : quoteEnd(span);
|
|
308
|
+
}
|
|
309
|
+
if (end < 0) throw new YamlError('an unterminated scalar');
|
|
310
|
+
const tail = span.slice(end).trim();
|
|
311
|
+
if (tail && !tail.startsWith('#')) throw new YamlError('text after a closed scalar');
|
|
312
|
+
return c === '[' || c === '{' ? parseFlow(span.slice(0, end)) : quotedValue(span.slice(0, end));
|
|
313
|
+
}
|
|
314
|
+
if (c === '*') return s.replace(/\s+#.*$/, '');
|
|
315
|
+
if (c === '@' || c === '`') throw new YamlError('a reserved indicator');
|
|
316
|
+
return resolvePlain(this.plain(s, parentIndent));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** A plain scalar and its continuation lines, folded. */
|
|
320
|
+
plain(first, parentIndent) {
|
|
321
|
+
const parts = [first.replace(/\s+#.*$/, '')];
|
|
322
|
+
let blanks = 0;
|
|
323
|
+
if (!/\s#/.test(first)) {
|
|
324
|
+
for (let j = this.pos; j < this.lines.length; j += 1) {
|
|
325
|
+
const { indent, text } = this.lines[j];
|
|
326
|
+
if (!text) { blanks += 1; continue; }
|
|
327
|
+
if (text.startsWith('#') || indent <= parentIndent) break;
|
|
328
|
+
parts.push(blanks ? '\n'.repeat(blanks) : ' ', text.replace(/\s+#.*$/, ''));
|
|
329
|
+
blanks = 0;
|
|
330
|
+
this.pos = j + 1;
|
|
331
|
+
if (/\s#/.test(text)) break;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const out = parts.join('');
|
|
335
|
+
if (/:(\s|$)/.test(out)) throw new YamlError('mapping values are not allowed here');
|
|
336
|
+
return out.trim();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
block(header, parentIndent) {
|
|
340
|
+
const h = /^([|>])([1-9]?)([+-]?)([1-9]?)\s*(#.*)?$/.exec(header);
|
|
341
|
+
if (!h) throw new YamlError('a bad block scalar header');
|
|
342
|
+
const digit = Number(h[2] || h[4] || 0);
|
|
343
|
+
const chomp = h[3];
|
|
344
|
+
let contentIndent = digit ? parentIndent + digit : null;
|
|
345
|
+
const lines = [];
|
|
346
|
+
let j = this.pos;
|
|
347
|
+
for (; j < this.raw.length && j < this.lines.length; j += 1) {
|
|
348
|
+
const r = this.raw[j];
|
|
349
|
+
if (r.trim() === '') { lines.push(''); continue; }
|
|
350
|
+
const ind = r.length - r.replace(/^ +/, '').length;
|
|
351
|
+
if (contentIndent === null) {
|
|
352
|
+
if (ind <= parentIndent) break;
|
|
353
|
+
contentIndent = ind;
|
|
354
|
+
}
|
|
355
|
+
if (ind < contentIndent) break;
|
|
356
|
+
lines.push(r.slice(contentIndent));
|
|
357
|
+
}
|
|
358
|
+
this.pos = j;
|
|
359
|
+
let trailing = 0;
|
|
360
|
+
while (lines.length && lines[lines.length - 1] === '') { lines.pop(); trailing += 1; }
|
|
361
|
+
let body;
|
|
362
|
+
if (h[1] === '|') {
|
|
363
|
+
body = lines.join('\n');
|
|
364
|
+
} else {
|
|
365
|
+
body = '';
|
|
366
|
+
let prevMore = null;
|
|
367
|
+
let empties = 0;
|
|
368
|
+
for (const l of lines) {
|
|
369
|
+
if (l === '') { empties += 1; continue; }
|
|
370
|
+
const more = /^[ \t]/.test(l);
|
|
371
|
+
if (prevMore === null) body += '\n'.repeat(empties);
|
|
372
|
+
else if (!more && !prevMore) body += empties ? '\n'.repeat(empties) : ' ';
|
|
373
|
+
else body += '\n'.repeat(empties + 1);
|
|
374
|
+
body += l;
|
|
375
|
+
prevMore = more;
|
|
376
|
+
empties = 0;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (chomp === '-' || body === '') return chomp === '+' ? body + '\n'.repeat(trailing) : body;
|
|
380
|
+
return chomp === '+' ? `${body}\n${'\n'.repeat(trailing)}` : `${body}\n`;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function isSeqItem(text) {
|
|
385
|
+
return text === '-' || /^-[ \t]/.test(text);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** `key: rest` → `{ key, rest }`, or null when the line is not a mapping entry. */
|
|
389
|
+
function splitKey(text) {
|
|
390
|
+
if (isSeqItem(text) || text[0] === '[' || text[0] === '{') return null;
|
|
391
|
+
if (text.startsWith('? ')) throw new YamlError('a complex key');
|
|
392
|
+
if (text[0] === '"' || text[0] === "'") {
|
|
393
|
+
const end = quoteEnd(text);
|
|
394
|
+
if (end < 0) return null;
|
|
395
|
+
const m = /^\s*:(\s|$)/.exec(text.slice(end));
|
|
396
|
+
if (!m) return null;
|
|
397
|
+
return { key: quotedValue(text.slice(0, end)), rest: text.slice(end + m[0].length).trim() };
|
|
398
|
+
}
|
|
399
|
+
const m = /:(\s|$)/.exec(text);
|
|
400
|
+
if (!m) return null;
|
|
401
|
+
const hash = text.search(/\s#/);
|
|
402
|
+
if (hash >= 0 && hash < m.index) return null;
|
|
403
|
+
const raw = text.slice(0, m.index).trimEnd();
|
|
404
|
+
const key = resolvePlain(raw);
|
|
405
|
+
// Keys stay strings except the booleans and null, which `data.get(True)` must be able to find.
|
|
406
|
+
return { key: typeof key === 'number' ? raw : key, rest: text.slice(m.index + 1).trim() };
|
|
407
|
+
}
|