bareloop 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +704 -0
- package/README.md +13 -4
- package/bareloop.context.md +351 -32
- package/package.json +1 -1
- package/src/authorflow.js +1280 -0
- package/src/authoring.js +1296 -0
- package/src/authorjob.js +793 -0
- package/src/authorscout.js +706 -0
- package/src/bridges.js +2 -2
- package/src/declaredclose.js +519 -0
- package/src/exits.js +8 -2
- package/src/index.js +72 -0
- package/src/job.js +91 -10
- package/src/kinds.js +1354 -0
- package/src/ledger.js +70 -9
- package/src/plan.js +57 -101
- package/src/planrun.js +554 -75
- package/src/ralph.js +27 -5
- package/src/reuse.js +135 -11
- package/src/root.js +8 -1
- package/src/run.js +29 -3
- package/src/text.js +120 -0
- package/src/validate.js +121 -0
- package/src/workbranch.js +261 -0
- package/types/authorflow.d.ts +720 -0
- package/types/authoring.d.ts +447 -0
- package/types/authorjob.d.ts +241 -0
- package/types/authorscout.d.ts +307 -0
- package/types/declaredclose.d.ts +174 -0
- package/types/exits.d.ts +14 -0
- package/types/index.d.ts +9 -1
- package/types/job.d.ts +14 -2
- package/types/kinds.d.ts +343 -0
- package/types/plan.d.ts +41 -38
- package/types/planrun.d.ts +125 -18
- package/types/ralph.d.ts +17 -0
- package/types/reuse.d.ts +5 -1
- package/types/run.d.ts +23 -2
- package/types/text.d.ts +84 -0
- package/types/validate.d.ts +50 -0
- package/types/workbranch.d.ts +102 -0
package/src/authoring.js
ADDED
|
@@ -0,0 +1,1296 @@
|
|
|
1
|
+
// THE AUTHORING SURFACE — close-authoring v1, module M2 (design record
|
|
2
|
+
// 2026-08-07, FROZEN; gate-2 POC closed 2026-08-08).
|
|
3
|
+
//
|
|
4
|
+
// M1 owns what a stage DOES. This module owns what a declaration may SAY, and
|
|
5
|
+
// whether a given declaration said it legally. Three things live here and they
|
|
6
|
+
// belong together because they are one contract read from three sides:
|
|
7
|
+
//
|
|
8
|
+
// 1. THE KIND CATALOGUE — the entire vocabulary an authoring call has. It
|
|
9
|
+
// parameterises kinds whose implementations we own; a script body, a shell
|
|
10
|
+
// fragment and a new kind are not rejected late, they are INEXPRESSIBLE
|
|
11
|
+
// (D2/D3, the `check-passes(name)` move). `judged-floor` and
|
|
12
|
+
// `human-confirms` are LOCKED MENU ENTRIES: declaring one is a distinct
|
|
13
|
+
// `locked-kind` red at VALIDATION, before any tokens, carrying counted
|
|
14
|
+
// demand (the LOCKED_VERDICTS pattern, src/job.js:520). `harness-loop` is
|
|
15
|
+
// ABSENT — no entry at all, so declaring it is an unknown-kind typo, which
|
|
16
|
+
// is the honest reading of a kind that is out of v1 (gate 1, Result 2).
|
|
17
|
+
//
|
|
18
|
+
// 2. THE TYPES GENRE — the one genre v1 admits (D13). Its template is FROZEN
|
|
19
|
+
// TEXT, byte-identical to the prereg's addendum 3, and its two guard
|
|
20
|
+
// batteries are OURS. D5 is the load-bearing decision of the whole design:
|
|
21
|
+
// the close carries guards the user did not ask for and cannot remove,
|
|
22
|
+
// because the stage that forecloses the easy win is exactly the stage an
|
|
23
|
+
// LLM asked "what would done look like" will not write well (F87 + the RSI
|
|
24
|
+
// rubric-close gotcha). Gate-2 round 1 measured the failure mode precisely:
|
|
25
|
+
// the guard-stripped arm DID invent a guard-SHAPED stage — with 2 of its 4
|
|
26
|
+
// patterns dead letters and both live suppression channels of that patient
|
|
27
|
+
// missed. The shape is cheap; the battery is what was paid for.
|
|
28
|
+
//
|
|
29
|
+
// Round 3 added the two residues addendum 6 hands to this build, and both
|
|
30
|
+
// are OURS TO INJECT, never model-filled:
|
|
31
|
+
// - guard SCOPE. The suppression scan covers EVERY changed file of the
|
|
32
|
+
// genre's extensions. Round 3's arm A narrowed it to the two target
|
|
33
|
+
// files, which reads stricter and is not: a suppression added in a NEW
|
|
34
|
+
// helper file walks straight through a target-file filter.
|
|
35
|
+
// - genre ENV. `MYPYPATH` on a Python patient is the Result-5 fact no
|
|
36
|
+
// user could supply and no model found (round 2 authored `src`, which
|
|
37
|
+
// is wrong). It is expressed HERE as genre data and applied by the flow
|
|
38
|
+
// (M3); a declaration that authors it itself is a red.
|
|
39
|
+
//
|
|
40
|
+
// 3. THE DECLARATION VALIDATOR — the first of D9's three mechanical gates.
|
|
41
|
+
// Schema, kinds, params, the F84 one-population law, F49's static regex
|
|
42
|
+
// reject, the D5 guard equality, and hamr's listing rule: every path the
|
|
43
|
+
// model wrote down must SELECT from the real seed listing. Round 2's arm A
|
|
44
|
+
// invented `src/alertEmail.js` from the user's prose ("the alert email
|
|
45
|
+
// one") and silently reclassified 15 real errors into the wrong
|
|
46
|
+
// population. A path is not a judgment call — it either names something in
|
|
47
|
+
// the tree or it does not.
|
|
48
|
+
//
|
|
49
|
+
// What this module deliberately does NOT do:
|
|
50
|
+
// - it never judges QUALITY. Nothing here asks whether the close is a GOOD
|
|
51
|
+
// close; D9's other two gates (the precheck and the seed-verdict read) plus
|
|
52
|
+
// the user's signature do that, and no LLM validates another LLM's close.
|
|
53
|
+
// - it never enforces the genre's stage SKELETON. The template states it to
|
|
54
|
+
// the author; a close with no work stage is caught by the seed-verdict read
|
|
55
|
+
// (nothing red at seed = nothing to do), which is where round 2's arm B
|
|
56
|
+
// died. A validator that demanded named stages would be authoring them.
|
|
57
|
+
// - it never inspects command ARGS for paths. hamr's rule enumerates path
|
|
58
|
+
// PARAMS; an arg list carries flags, module names and config paths, and a
|
|
59
|
+
// `path-not-in-listing` red on `--strict` would be nonsense. The named
|
|
60
|
+
// consequence, not smoothed: an invented path can still ride in `args`, and
|
|
61
|
+
// the seed-verdict read is what catches it (it did, in round 2).
|
|
62
|
+
//
|
|
63
|
+
// One spelling of everything shared: `normalizeParser` and `regexGroups` are
|
|
64
|
+
// M1's, imported, never re-spelled — the validator and the executor must agree
|
|
65
|
+
// on what a parser MEANS or the one that grades is the one nobody validated.
|
|
66
|
+
// `hasNestedQuantifier` and `globToPrefix` are the shipped primitives from
|
|
67
|
+
// src/validate.js for the same reason (F49 is monotonic-only doctrine and the
|
|
68
|
+
// detector is never touched from here; globToPrefix's collapse order is fence
|
|
69
|
+
// territory, F9).
|
|
70
|
+
//
|
|
71
|
+
// NOTE for M4 wiring: nothing here is exported from src/index.js yet. M1
|
|
72
|
+
// flagged a `runClose` naming collision between the kind executor and
|
|
73
|
+
// src/ralph.js; the public surface is settled once at M4, not piecemeal.
|
|
74
|
+
|
|
75
|
+
import { normalizeParser } from './kinds.js';
|
|
76
|
+
import { isObj, isNonEmptyString, hasNestedQuantifier, globToPrefix } from './validate.js';
|
|
77
|
+
|
|
78
|
+
/** @typedef {{code: string, path: string, detail: string, [k: string]: any}} Red */
|
|
79
|
+
/** @typedef {{required: readonly string[], optional: readonly string[], pathParams: readonly string[], locked?: true, verdictClass: string, shape: string, asserts: string}} KindSpec */
|
|
80
|
+
|
|
81
|
+
// ── 0. THE VERDICT CLASSES ───────────────────────────────────────────────────
|
|
82
|
+
//
|
|
83
|
+
// The user's own answer (PRD v1.57 §1, restoring the 2026-07-21 radio and
|
|
84
|
+
// superseding D4): is your *done* machine-checkable, does it need judgment, or
|
|
85
|
+
// does it need a person. It is a CLOSED SET handed over enumerated, never a
|
|
86
|
+
// prose rule about what a legal answer would look like.
|
|
87
|
+
//
|
|
88
|
+
// TWO COPIES, PINNED BY THE SUITE RATHER THAN IMPORTED. `src/job.js` owns the
|
|
89
|
+
// same menu as the spec's radio (`VERDICT_TYPES`) and imports this module
|
|
90
|
+
// transitively (job → declaredclose → authoring), so importing it back would
|
|
91
|
+
// close a cycle and hand this module a TDZ-undefined constant at load. The suite
|
|
92
|
+
// asserts the two lists are identical, which is the same treatment
|
|
93
|
+
// `CATALOGUE_LIVE_KINDS` and the executor's `LIVE_KINDS` already get.
|
|
94
|
+
|
|
95
|
+
/** the whole menu, in ascending order of what it takes to render the verdict */
|
|
96
|
+
export const VERDICT_CLASSES = Object.freeze(['green', 'soft-green', 'hitl']);
|
|
97
|
+
/** declared-but-locked: selecting one is COUNTED demand, never an admission */
|
|
98
|
+
export const LOCKED_CLASSES = Object.freeze(['soft-green', 'hitl']);
|
|
99
|
+
/** the classes v1 actually builds — exactly one */
|
|
100
|
+
export const LIVE_CLASSES = Object.freeze(VERDICT_CLASSES.filter((c) => !LOCKED_CLASSES.includes(c)));
|
|
101
|
+
|
|
102
|
+
/** the hierarchy as a comparable number. `green` is the FLOOR: a mechanical
|
|
103
|
+
* measurement is the cheapest honest verdict, a judge is above it, a person is
|
|
104
|
+
* above that. Read by the ceiling rule and by nothing else. */
|
|
105
|
+
const CLASS_RANK = Object.freeze({ green: 0, 'soft-green': 1, hitl: 2 });
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* THE CEILING (hamr, 2026-08-08, *"yes to both, go"*): the picked class is a
|
|
109
|
+
* PROMISE, and this is what the composition actually demands.
|
|
110
|
+
*
|
|
111
|
+
* Every kind carries the class of verdict it can honestly render, so a
|
|
112
|
+
* declaration's ceiling is the HIGHEST class any of its stages needs. A pick
|
|
113
|
+
* below that ceiling is a silent UPGRADE — a judgment ruler running under a
|
|
114
|
+
* green promise, which is exactly the fake-hard verdict `CLASS_BY_CLOSE` and
|
|
115
|
+
* `CLASS_BY_VERDICT` exist to stop, defeated from ABOVE by never letting the
|
|
116
|
+
* illegal pair be formed.
|
|
117
|
+
*
|
|
118
|
+
* INERT IN v1 BY CONSTRUCTION, which is why it is cheap now: every LIVE kind is
|
|
119
|
+
* mechanical, so no declaration a v1 catalogue can express reaches above green.
|
|
120
|
+
* It is written before the second class exists so the first soft-green kind is
|
|
121
|
+
* not the thing that discovers the question.
|
|
122
|
+
*
|
|
123
|
+
* A kind the catalogue does not know contributes NOTHING — it already carries an
|
|
124
|
+
* `unknown-kind` red, and guessing a class for it would be deriving the promise
|
|
125
|
+
* from a typo. A catalogue entry with no class at all THROWS: the catalogue is
|
|
126
|
+
* OURS, and failing loudly beats weighing a kind whose honesty nobody stated.
|
|
127
|
+
* @param {any} declaration
|
|
128
|
+
* @param {Record<string, KindSpec>} [catalogue]
|
|
129
|
+
* @returns {{class: string, kind: string, stage: string|null, locked: boolean}|null}
|
|
130
|
+
*/
|
|
131
|
+
export function closeCeiling(declaration, catalogue = KIND_CATALOGUE) {
|
|
132
|
+
const stages = Array.isArray(declaration?.stages) ? declaration.stages : [];
|
|
133
|
+
/** @type {{class: string, kind: string, stage: string|null, locked: boolean}|null} */
|
|
134
|
+
let top = null;
|
|
135
|
+
for (const s of stages) {
|
|
136
|
+
if (!isObj(s) || !isNonEmptyString(s.kind)) continue;
|
|
137
|
+
const spec = catalogue[s.kind];
|
|
138
|
+
if (!spec) continue;
|
|
139
|
+
if (!Object.hasOwn(CLASS_RANK, spec.verdictClass)) {
|
|
140
|
+
throw new Error(`[authoring] catalogue kind "${s.kind}" declares no verdict class — every kind states the class `
|
|
141
|
+
+ `of verdict it can honestly render (one of ${VERDICT_CLASSES.join(', ')})`);
|
|
142
|
+
}
|
|
143
|
+
if (top === null || CLASS_RANK[spec.verdictClass] > CLASS_RANK[top.class]) {
|
|
144
|
+
top = {
|
|
145
|
+
class: spec.verdictClass,
|
|
146
|
+
kind: s.kind,
|
|
147
|
+
stage: isNonEmptyString(s.name) ? s.name : null,
|
|
148
|
+
locked: spec.locked === true,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return top;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── 1. THE KIND CATALOGUE ────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The whole vocabulary, as DATA. `shape` and `asserts` are the strings the
|
|
159
|
+
* authoring prompt renders (M3) — the prompt and this validator must judge the
|
|
160
|
+
* same facts, so they read one catalogue rather than two (the drift class
|
|
161
|
+
* SECRET_PATTERNS exists to prevent, one layer up).
|
|
162
|
+
*
|
|
163
|
+
* `pathParams` names every param that carries a repository path, in dotted
|
|
164
|
+
* form. It is data rather than a switch so hamr's listing rule reaches a new
|
|
165
|
+
* path-bearing param the day one is added, instead of the day someone
|
|
166
|
+
* remembers to widen a condition.
|
|
167
|
+
*
|
|
168
|
+
* `verdictClass` is the class of verdict this kind can HONESTLY render, and it
|
|
169
|
+
* is what makes the user's pick a promise the composer must keep (PRD v1.57 §1).
|
|
170
|
+
* Every live kind is `green` — a command's measurement is the truth — so the
|
|
171
|
+
* ceiling rule is inert in v1 by construction; the two locked kinds carry the
|
|
172
|
+
* classes they belong to so the rule is already correct the day one goes live.
|
|
173
|
+
*
|
|
174
|
+
* Optionality is read off the prereg §5 table, with ONE deliberate divergence
|
|
175
|
+
* from the gate-2 POC validator, named because it reverses a POC behaviour:
|
|
176
|
+
* `pattern-absent-in-diff.scope` is OPTIONAL here and the guard ships without
|
|
177
|
+
* it. Under addendum 6 the guard's scope is GENRE property and the answer is
|
|
178
|
+
* "every changed file of these extensions" — an absent scope is exactly that
|
|
179
|
+
* (M1's `inScope` admits everything when no scope object is present). The POC
|
|
180
|
+
* required it, and required it filled, which is how round 3's arm A came to
|
|
181
|
+
* narrow the scan and call it stricter.
|
|
182
|
+
* @type {Record<string, KindSpec>}
|
|
183
|
+
*/
|
|
184
|
+
export const KIND_CATALOGUE = Object.freeze({
|
|
185
|
+
'command-exit': Object.freeze({
|
|
186
|
+
verdictClass: 'green',
|
|
187
|
+
required: Object.freeze(['cmd', 'args', 'expectExit']),
|
|
188
|
+
optional: Object.freeze(['timeoutMs', 'env']),
|
|
189
|
+
pathParams: Object.freeze([]),
|
|
190
|
+
shape: 'cmd: string, args: string[], expectExit: integer, timeoutMs?: integer, env?: {NAME: value}',
|
|
191
|
+
asserts: 'run cmd/args in the repository; the exit code must equal expectExit.',
|
|
192
|
+
}),
|
|
193
|
+
'count-not-worse': Object.freeze({
|
|
194
|
+
verdictClass: 'green',
|
|
195
|
+
required: Object.freeze(['cmd', 'args', 'parser', 'scope', 'direction', 'baseline']),
|
|
196
|
+
optional: Object.freeze(['timeoutMs', 'env']),
|
|
197
|
+
pathParams: Object.freeze(['scope.includePrefixes', 'scope.excludePrefixes']),
|
|
198
|
+
shape: 'cmd: string, args: string[], '
|
|
199
|
+
+ 'parser: {terms: [{lineMatch: regex-string, capture?: integer, sign: 1 | -1, '
|
|
200
|
+
+ 'aggregate: "first" | "sum", region: "whole-output" | {anchor: regex-string, capture: integer}}]} '
|
|
201
|
+
+ '— or the short form {lineMatch: regex-string, capture?: integer}, '
|
|
202
|
+
+ 'scope: {includePrefixes?: string[], excludePrefixes?: string[]}, '
|
|
203
|
+
+ 'direction: "lower-is-better" | "higher-is-better", baseline: "seed" | 0, timeoutMs?: integer, env?: {NAME: value}',
|
|
204
|
+
asserts: 'run cmd/args; read a NUMBER out of the output with parser, keep only what scope selects, and '
|
|
205
|
+
+ 'require that number to be not-worse than baseline in the stated direction. baseline "seed" is the same '
|
|
206
|
+
+ 'number taken from this run\'s own starting point, measured every run; baseline 0 is literally zero. '
|
|
207
|
+
+ 'You never write a number you measured yourself — you write the rule. '
|
|
208
|
+
+ 'If the tool exits NON-ZERO and its output matches none of your parser\'s terms, the stage stops as a broken '
|
|
209
|
+
+ 'instrument rather than recording zero: a crashed checker reporting nothing is unknown, not clean. Name a '
|
|
210
|
+
+ 'tool that prints its count on a clean run.',
|
|
211
|
+
}),
|
|
212
|
+
'pattern-absent-in-diff': Object.freeze({
|
|
213
|
+
verdictClass: 'green',
|
|
214
|
+
required: Object.freeze(['patterns', 'extensions']),
|
|
215
|
+
optional: Object.freeze(['scope']),
|
|
216
|
+
pathParams: Object.freeze(['scope.includePrefixes', 'scope.excludePrefixes']),
|
|
217
|
+
shape: 'patterns: [{id: string, regex: regex-string}], extensions: string[], '
|
|
218
|
+
+ 'scope?: {includePrefixes?: string[], excludePrefixes?: string[]}',
|
|
219
|
+
asserts: 'none of the patterns may appear in any line this run ADDED, across every file this run changed '
|
|
220
|
+
+ 'with one of the named extensions.',
|
|
221
|
+
}),
|
|
222
|
+
'files-changed': Object.freeze({
|
|
223
|
+
verdictClass: 'green',
|
|
224
|
+
required: Object.freeze(['allowPrefixes', 'requireNonEmpty']),
|
|
225
|
+
optional: Object.freeze([]),
|
|
226
|
+
pathParams: Object.freeze(['allowPrefixes']),
|
|
227
|
+
shape: 'allowPrefixes: string[], requireNonEmpty: true',
|
|
228
|
+
asserts: 'the set of files this run changed must be non-empty and must lie wholly inside allowPrefixes.',
|
|
229
|
+
}),
|
|
230
|
+
// LOCKED: named so declaring one is COUNTED DEMAND rather than an unknown-kind
|
|
231
|
+
// typo (D13 forward-compat point 2). Disclosure is not admission — these do
|
|
232
|
+
// not run in v1, and `close[]` on disk stays predicate-only and shape-enforced
|
|
233
|
+
// (src/job.js:442), so the inexpressibility that is already free stays free.
|
|
234
|
+
'judged-floor': Object.freeze({
|
|
235
|
+
verdictClass: 'soft-green',
|
|
236
|
+
required: Object.freeze([]), optional: Object.freeze([]), pathParams: Object.freeze([]), locked: true,
|
|
237
|
+
shape: 'LOCKED — not available in v1',
|
|
238
|
+
asserts: 'a judged score must clear a floor. Declaring it is recorded as demand; it will not run.',
|
|
239
|
+
}),
|
|
240
|
+
'human-confirms': Object.freeze({
|
|
241
|
+
verdictClass: 'hitl',
|
|
242
|
+
required: Object.freeze([]), optional: Object.freeze([]), pathParams: Object.freeze([]), locked: true,
|
|
243
|
+
shape: 'LOCKED — not available in v1',
|
|
244
|
+
asserts: 'a person renders the verdict. Declaring it is recorded as demand; it will not run.',
|
|
245
|
+
}),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Which kinds can carry an environment at all, read off the CATALOGUE rather
|
|
250
|
+
* than a hand-kept list. ONE spelling, because two consumers must agree
|
|
251
|
+
* EXACTLY: `applyGenreEnv` injects the genre's variable into these stages and
|
|
252
|
+
* nowhere else, and the validator's completeness rule demands it on these
|
|
253
|
+
* stages and nowhere else. A second copy that drifted either way is a hole —
|
|
254
|
+
* stages injected but unchecked, or stages checked for what nothing injects.
|
|
255
|
+
* @param {string} kind @param {Record<string, KindSpec>} [catalogue]
|
|
256
|
+
*/
|
|
257
|
+
export function envCapableKind(kind, catalogue = KIND_CATALOGUE) {
|
|
258
|
+
const spec = catalogue[kind];
|
|
259
|
+
return !!spec && !spec.locked && [...spec.required, ...spec.optional].includes('env');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export const CATALOGUE_KINDS = Object.freeze(Object.keys(KIND_CATALOGUE));
|
|
263
|
+
export const LOCKED_KINDS = Object.freeze(CATALOGUE_KINDS.filter((k) => KIND_CATALOGUE[k].locked));
|
|
264
|
+
/** the catalogue's live half — asserted EQUAL to the executor's own LIVE_KINDS
|
|
265
|
+
* by the suite, both directions: a kind the catalogue offers and the executor
|
|
266
|
+
* cannot run is a close that stops at runtime, and a kind the executor runs
|
|
267
|
+
* that the catalogue never names is a capability nothing can reach. */
|
|
268
|
+
export const CATALOGUE_LIVE_KINDS = Object.freeze(CATALOGUE_KINDS.filter((k) => !KIND_CATALOGUE[k].locked));
|
|
269
|
+
|
|
270
|
+
export const DIRECTIONS = Object.freeze(['lower-is-better', 'higher-is-better']);
|
|
271
|
+
/** D12: the close stores the COUNTING RULE, never the number. "seed" is measured
|
|
272
|
+
* at this run's own seed every run; 0 is literally zero. A frozen constant (the
|
|
273
|
+
* hand-written closes' `SEED_ERRORS 63`) is exactly what this menu retires. */
|
|
274
|
+
export const BASELINES = Object.freeze(['seed', 0]);
|
|
275
|
+
/** a runaway stage list is a red, not a bill — the sibling of M1's MAX_TERMS */
|
|
276
|
+
export const MAX_STAGES = 12;
|
|
277
|
+
|
|
278
|
+
const NAME_RE = /^[a-z][a-z0-9-]{1,48}$/;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* THE DENY FLOOR under a declared command (hamr's ruling, PRD v1.57 §3: *"there
|
|
282
|
+
* should be a floor of all dangerous things… straight out block… no one wants
|
|
283
|
+
* `rm -rf` allowed either ways"*).
|
|
284
|
+
*
|
|
285
|
+
* Four families, and each one is here for a different reason:
|
|
286
|
+
* - SHELL INTERPRETERS. A shell runs anything, so admitting one makes every
|
|
287
|
+
* other entry decoration — the D3 line ("the danger is in the ACTIONS, not
|
|
288
|
+
* the SYNTAX") reappearing at the close's own spawn.
|
|
289
|
+
* - DESTRUCTIVE / SYSTEM commands. The `rm -rf` class the ruling names, plus
|
|
290
|
+
* the machine-level siblings (`dd`, the `mkfs` FAMILY, shutdown/reboot) and
|
|
291
|
+
* privilege escalation.
|
|
292
|
+
* - FETCH-AND-RUN vectors (`curl`, `wget`). A close that downloads its own
|
|
293
|
+
* instrument is a close nobody signed.
|
|
294
|
+
* - INDIRECTION (`env`, `eval`, `exec`, `xargs`). They run a program the
|
|
295
|
+
* declaration never names, which defeats every entry above by construction.
|
|
296
|
+
*
|
|
297
|
+
* It is a FLOOR, and MONOTONIC: names are only ever ADDED, never removed or
|
|
298
|
+
* narrowed (the F49 precedent — closing a false negative is the allowed
|
|
299
|
+
* direction; chasing a false positive is not). A legitimate runner refused here
|
|
300
|
+
* is fixed by declaring the runner, never by shrinking the floor.
|
|
301
|
+
*
|
|
302
|
+
* It is NOT A SANDBOX, and nothing here claims to be one. The local-trust model
|
|
303
|
+
* of v1.41 stands intact and its limitations are written out in PRD v1.57 §3:
|
|
304
|
+
* close children keep the operator's OS user, network egress and file access;
|
|
305
|
+
* `args` are not inspected (`node -e <anything>` is reachable and always was);
|
|
306
|
+
* resource use is bounded by liveness checks, not quotas. The floor narrows
|
|
307
|
+
* blast radius; it does not promise containment.
|
|
308
|
+
*/
|
|
309
|
+
export const DENIED_COMMANDS = Object.freeze([
|
|
310
|
+
// shell interpreters
|
|
311
|
+
'sh', 'bash', 'zsh', 'dash', 'ksh', 'csh', 'tcsh', 'fish',
|
|
312
|
+
// destructive / system
|
|
313
|
+
'rm', 'rmdir', 'dd', 'mkfs', 'shutdown', 'reboot', 'halt', 'poweroff',
|
|
314
|
+
'sudo', 'su', 'chmod', 'chown', 'kill', 'killall', 'pkill',
|
|
315
|
+
// fetch-and-run
|
|
316
|
+
'curl', 'wget',
|
|
317
|
+
// indirection — a program the declaration never named
|
|
318
|
+
'env', 'eval', 'exec', 'xargs',
|
|
319
|
+
]);
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The floor's decision for one declared `cmd`, as a reason or null.
|
|
323
|
+
*
|
|
324
|
+
* NORMALISATION FIRST, because that is where the dodge lives: `/usr/bin/rm`
|
|
325
|
+
* carries the same program as `rm` and only a basename read taken AFTER
|
|
326
|
+
* splitting the path can see it. Case is folded for the same reason.
|
|
327
|
+
*
|
|
328
|
+
* A cmd naming a program OUTSIDE the repository — an absolute path, or one that
|
|
329
|
+
* climbs out with `..` — is refused whatever its basename says. Two reasons, and
|
|
330
|
+
* both are the fail-safe direction: the validator has no repository in hand at
|
|
331
|
+
* every call site (the job validator judges a signed spec with no tree), so
|
|
332
|
+
* "outside" cannot be decided per-machine; and a machine-absolute path inside a
|
|
333
|
+
* SIGNED artefact grades a different machine than the one it was signed on.
|
|
334
|
+
* @param {string} cmd @returns {string|null}
|
|
335
|
+
*/
|
|
336
|
+
export function deniedCommandReason(cmd) {
|
|
337
|
+
const raw = String(cmd ?? '').trim();
|
|
338
|
+
if (raw === '') return null; // shape is the schema walk's business, not the floor's
|
|
339
|
+
const segments = raw.split(/[\\/]/);
|
|
340
|
+
// `/^[a-zA-Z]:/` is the DRIVE-ABSOLUTE spelling, and it is here because
|
|
341
|
+
// `scopeContained` (src/validate.js) already counts it as an escape: two
|
|
342
|
+
// path-escape readings in one codebase that disagree are two instruments, and
|
|
343
|
+
// this was the one that let `C:\Windows\System32\evil.bat` through to the
|
|
344
|
+
// basename floor, which had never heard of it. MONOTONIC — this only ever adds
|
|
345
|
+
// a rejection, which is the direction F49's precedent licenses.
|
|
346
|
+
if (raw.startsWith('/') || raw.startsWith('\\') || /^[a-zA-Z]:/.test(raw) || segments.includes('..')) {
|
|
347
|
+
return `"${raw}" names a program outside the repository — a declared close runs the patient's own tools, `
|
|
348
|
+
+ 'reached relatively or through the project\'s runner, never an absolute path (which grades a different '
|
|
349
|
+
+ 'machine than the one that signed it)';
|
|
350
|
+
}
|
|
351
|
+
const base = /** @type {string} */ (segments.at(-1)).toLowerCase();
|
|
352
|
+
const denied = DENIED_COMMANDS.includes(base) || base.startsWith('mkfs.');
|
|
353
|
+
if (!denied) return null;
|
|
354
|
+
return `"${raw}" resolves to "${base}", which is on the deny floor — shells, destructive and system commands, `
|
|
355
|
+
+ 'fetch-and-run vectors and program indirection are blocked outright (PRD v1.57 §3). A close names the '
|
|
356
|
+
+ 'patient\'s own instrument; it never names a program that can undo the patient';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── 2. THE TYPES GENRE ───────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* THE FROZEN TYPES GENRE TEMPLATE — prereg addendum 3's six policy points,
|
|
363
|
+
* VERBATIM. The suite reads the prereg and compares BYTES, so a paraphrase here
|
|
364
|
+
* fails the build rather than quietly changing what every future close is
|
|
365
|
+
* authored against. It deliberately carries no repo-specific command, path, env
|
|
366
|
+
* value or regex: round 2 measured that stating the LAW still leaves the model
|
|
367
|
+
* to find the VALUE, and that is the axis the build must keep live.
|
|
368
|
+
*/
|
|
369
|
+
export const TYPES_GENRE_TEMPLATE = `1. The graded instrument is the STRICT form of the language's type checker, regardless of what
|
|
370
|
+
the repo's own scripts run. If the repo's script omits strictness, the close adds it.
|
|
371
|
+
2. Tools are invoked so their binaries actually resolve — through the project's own package
|
|
372
|
+
runner or language module runner, never a bare binary name.
|
|
373
|
+
3. The stage skeleton, in first-red-wins order: changed-from-seed (guard) → typecheck (error
|
|
374
|
+
count IN the target scope, baseline 0, lower-is-better) → typecheck-outside (error count
|
|
375
|
+
OUTSIDE the target scope, baseline measured at seed, a ceiling — required whenever the job
|
|
376
|
+
scopes to a subset of the tree; omitted only for whole-tree jobs) → tests-kept (a floor on
|
|
377
|
+
tests that actually EXECUTED, baseline at seed, higher-is-better; a skipped or deselected
|
|
378
|
+
test did not run and must not count) → suite-green (the suite exits clean AND reports zero
|
|
379
|
+
failing tests — two assertions) → no-suppressions (guard).
|
|
380
|
+
4. One population per stage — two structurally different counts never share a stage.
|
|
381
|
+
5. The checker must judge the PATIENT's own tree: if imports could resolve to an installed or
|
|
382
|
+
editable copy elsewhere, the environment is set so they resolve inside the patient.
|
|
383
|
+
6. A number the tool did not report is unknown, never zero.`;
|
|
384
|
+
|
|
385
|
+
export const GENRE_LANGUAGES = Object.freeze(['js', 'python']);
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* THE GENRE, as data. The suppression batteries are the hand-written closes'
|
|
389
|
+
* own `SUPPRESSIONS` tables (`scripts/u-pulselog-close.mjs:35`,
|
|
390
|
+
* `scripts/u-spawner-close.mjs:34`) — operator-owned genre knowledge, minted by
|
|
391
|
+
* paying for live runs. `unknown` is deliberately NOT a JS suppression and not
|
|
392
|
+
* a Python one: it forces narrowing at the use site, which is real typing work.
|
|
393
|
+
*
|
|
394
|
+
* `env` is the addendum-6 residue expressed as data. Python's `MYPYPATH` is
|
|
395
|
+
* load-bearing and was found by probe, never by reading: the aurora patient sets
|
|
396
|
+
* `explicit_package_bases`, so without it a sibling import falls through to an
|
|
397
|
+
* editable install pointing at a DIFFERENT checkout and a cross-module fix is
|
|
398
|
+
* graded against unedited source. It moves no seed number, which is why no
|
|
399
|
+
* seed-verdict read can ever catch its absence — the only place it can live is
|
|
400
|
+
* here, in genre data, injected by the flow.
|
|
401
|
+
*/
|
|
402
|
+
export const TYPES_GENRE = Object.freeze({
|
|
403
|
+
name: 'TYPES',
|
|
404
|
+
template: TYPES_GENRE_TEMPLATE,
|
|
405
|
+
languages: Object.freeze({
|
|
406
|
+
js: Object.freeze({
|
|
407
|
+
suppressions: Object.freeze([
|
|
408
|
+
Object.freeze({ id: 'ts-ignore', regex: '@ts-ignore' }),
|
|
409
|
+
Object.freeze({ id: 'ts-expect-error', regex: '@ts-expect-error' }),
|
|
410
|
+
Object.freeze({ id: 'ts-nocheck', regex: '@ts-nocheck' }),
|
|
411
|
+
Object.freeze({ id: 'eslint-disable', regex: 'eslint-disable' }),
|
|
412
|
+
Object.freeze({ id: 'any', regex: '(?:[{<|(\\[:,]|\\bas)\\s*any\\b|\\bany\\[\\]' }),
|
|
413
|
+
Object.freeze({ id: 'any-star', regex: '@\\w+\\s*\\{\\s*[*?]\\s*\\}' }),
|
|
414
|
+
Object.freeze({ id: 'cast', regex: '@type\\s*\\{.*\\}\\s*\\*\\/\\s*\\(' }),
|
|
415
|
+
]),
|
|
416
|
+
extensions: Object.freeze(['.js', '.mjs', '.cjs']),
|
|
417
|
+
/** no import-resolution hazard on this runner — an EMPTY list, declared,
|
|
418
|
+
* never an absent field that would read as "not looked into" (F59) */
|
|
419
|
+
env: Object.freeze([]),
|
|
420
|
+
}),
|
|
421
|
+
python: Object.freeze({
|
|
422
|
+
suppressions: Object.freeze([
|
|
423
|
+
Object.freeze({ id: 'ignore', regex: '#\\s*type:\\s*ignore' }),
|
|
424
|
+
Object.freeze({ id: 'any', regex: '\\bAny\\b' }),
|
|
425
|
+
Object.freeze({ id: 'cast', regex: '\\bcast\\s*\\(' }),
|
|
426
|
+
Object.freeze({ id: 'noqa', regex: '#\\s*noqa' }),
|
|
427
|
+
Object.freeze({ id: 'mypy-disable', regex: '#\\s*mypy:\\s*disable' }),
|
|
428
|
+
]),
|
|
429
|
+
extensions: Object.freeze(['.py']),
|
|
430
|
+
env: Object.freeze([
|
|
431
|
+
Object.freeze({
|
|
432
|
+
name: 'MYPYPATH',
|
|
433
|
+
from: 'sourcePrefixes',
|
|
434
|
+
join: ':',
|
|
435
|
+
why: 'mypy must name the PATIENT\'s modules: without it a sibling import resolves through an editable '
|
|
436
|
+
+ 'install to a different checkout, and a cross-module fix is graded against unedited source (Result 5, '
|
|
437
|
+
+ 'found by probe). It moves no seed number, so no seed-verdict read can see its absence.',
|
|
438
|
+
}),
|
|
439
|
+
]),
|
|
440
|
+
}),
|
|
441
|
+
}),
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
/** @param {string} lang */
|
|
445
|
+
function language(lang) {
|
|
446
|
+
const l = TYPES_GENRE.languages[/** @type {'js'|'python'} */ (lang)];
|
|
447
|
+
// An unknown language THROWS rather than returning an empty battery: a genre
|
|
448
|
+
// we have not admitted must not read as "this genre needs no guards" (F59's
|
|
449
|
+
// shape — `{}` meaning "the scout did not complete", never "no facts needed").
|
|
450
|
+
if (!l) throw new Error(`no TYPES genre data for language "${lang}" — one of ${GENRE_LANGUAGES.join(', ')}`);
|
|
451
|
+
return l;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* THE GUARD BATTERY, KEYED BY VERDICT CLASS (hamr's ruling, PRD v1.57 §2).
|
|
456
|
+
*
|
|
457
|
+
* D5's battery was genre-keyed data. Under class-keyed interviews it has no
|
|
458
|
+
* genre to hang from, and the ruling gives it the same home the interview took:
|
|
459
|
+
* **the battery attaches to the verdict class the user picked** — not to a
|
|
460
|
+
* problem-genre label, and not per catalogue KIND.
|
|
461
|
+
*
|
|
462
|
+
* WHY THE CLASS. What a battery is FOR is the class of dishonesty the class of
|
|
463
|
+
* VERDICT admits. A green job is a job whose *done* is claimed to be
|
|
464
|
+
* machine-checkable, so it carries the green battery whatever problem it came
|
|
465
|
+
* from — that is the anti-suppression obligation the F87 genre earned, and it
|
|
466
|
+
* must not travel with a label the interview no longer asks for.
|
|
467
|
+
*
|
|
468
|
+
* ATTACHMENT POINT vs CONTENTS, and the split is the whole implementation:
|
|
469
|
+
* - the battery's STRUCTURE and its un-removability key off the CLASS. That
|
|
470
|
+
* is this table: which guards exist, what they assert, which slot is the
|
|
471
|
+
* model's (`fill`), and which params are ours.
|
|
472
|
+
* - the battery's CONTENTS fill in at COMPOSITION (`compose`), because a
|
|
473
|
+
* suppression pattern, a file extension and a checker's escape hatch belong
|
|
474
|
+
* to the TOOL the declaration names, and nothing before composition knows
|
|
475
|
+
* that tool. `classGuards` resolves them from the language data below.
|
|
476
|
+
*
|
|
477
|
+
* A LOCKED class carries `guards: null`, not `[]`. Absent is never empty (F59):
|
|
478
|
+
* `[]` would read as "this class needs no guards", which is exactly the sentence
|
|
479
|
+
* D5 exists to make unsayable. Nothing ever reaches it — admission refuses a
|
|
480
|
+
* locked class before its battery is asked for — and `classGuards` throws rather
|
|
481
|
+
* than hand back nothing if anything ever does.
|
|
482
|
+
* @type {Record<string, {locked: boolean, guards: readonly any[]|null}>}
|
|
483
|
+
*/
|
|
484
|
+
export const CLASS_BATTERIES = Object.freeze({
|
|
485
|
+
green: Object.freeze({
|
|
486
|
+
locked: false,
|
|
487
|
+
guards: Object.freeze([
|
|
488
|
+
Object.freeze({
|
|
489
|
+
name: 'changed-from-seed',
|
|
490
|
+
kind: 'files-changed',
|
|
491
|
+
params: Object.freeze({ requireNonEmpty: true }),
|
|
492
|
+
compose: Object.freeze([]),
|
|
493
|
+
fill: Object.freeze(['allowPrefixes']),
|
|
494
|
+
}),
|
|
495
|
+
Object.freeze({
|
|
496
|
+
name: 'no-suppressions',
|
|
497
|
+
kind: 'pattern-absent-in-diff',
|
|
498
|
+
// NO scope: the scan covers every changed file of these extensions. A
|
|
499
|
+
// narrowing here is the round-3 arm A defect and is a red, not a taste.
|
|
500
|
+
params: Object.freeze({}),
|
|
501
|
+
compose: Object.freeze(['patterns', 'extensions']),
|
|
502
|
+
fill: Object.freeze([]),
|
|
503
|
+
}),
|
|
504
|
+
]),
|
|
505
|
+
}),
|
|
506
|
+
'soft-green': Object.freeze({ locked: true, guards: null }),
|
|
507
|
+
hitl: Object.freeze({ locked: true, guards: null }),
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* How each COMPOSED param resolves from the tool the declaration names. One
|
|
512
|
+
* entry per composable param name, so a battery that grows a slot fails loudly
|
|
513
|
+
* here rather than shipping an unresolved one.
|
|
514
|
+
* @type {Record<string, (l: any) => any>}
|
|
515
|
+
*/
|
|
516
|
+
const COMPOSE_FROM_TOOL = Object.freeze({
|
|
517
|
+
patterns: (/** @type {any} */ l) => l.suppressions.map((/** @type {any} */ p) => ({ ...p })),
|
|
518
|
+
extensions: (/** @type {any} */ l) => [...l.extensions],
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The picked class's mandatory guards, FULLY ENUMERATED (D13 forward-compat
|
|
523
|
+
* point 3: an omittable-with-a-default field is the omitted-`tools` shape, where
|
|
524
|
+
* widening changes what runs without changing what was signed — so the guards
|
|
525
|
+
* are stored spelled out, and the hash is taken over the resolved form).
|
|
526
|
+
*
|
|
527
|
+
* `fill` names the ONLY slot the model supplies. `changed-from-seed`'s
|
|
528
|
+
* allowPrefixes is the job's own target scope and cannot be ours; everything
|
|
529
|
+
* else — including `no-suppressions`' absent scope — is.
|
|
530
|
+
*
|
|
531
|
+
* AN EMPTY BATTERY IS IMPOSSIBLE for a known class and a known language, and
|
|
532
|
+
* that is enforced rather than asserted: an unknown class, a locked class, an
|
|
533
|
+
* unknown language, a battery that resolved to nothing, and a composed param
|
|
534
|
+
* that resolved to nothing all THROW. F59's shape at the load-bearing decision
|
|
535
|
+
* of the design — a suppression scan with no patterns reads clean exactly like
|
|
536
|
+
* one that scanned correctly, and it lands in a SIGNED artefact.
|
|
537
|
+
*
|
|
538
|
+
* Returns a fresh deep copy each call: a frozen constant handed to a caller that
|
|
539
|
+
* edits it is one shared battery away from a silently weakened guard.
|
|
540
|
+
* @param {{verdictType: string, lang: string}} o
|
|
541
|
+
* @returns {{name: string, kind: string, params: Record<string, any>, compose: string[], fill: string[]}[]}
|
|
542
|
+
*/
|
|
543
|
+
export function classGuards({ verdictType, lang }) {
|
|
544
|
+
const battery = Object.hasOwn(CLASS_BATTERIES, String(verdictType)) ? CLASS_BATTERIES[String(verdictType)] : null;
|
|
545
|
+
if (!battery) {
|
|
546
|
+
throw new Error(`no guard battery for verdict class "${verdictType}" — one of ${VERDICT_CLASSES.join(', ')}`);
|
|
547
|
+
}
|
|
548
|
+
if (battery.locked || battery.guards === null) {
|
|
549
|
+
throw new Error(`the "${verdictType}" guard battery is LOCKED — that class is declared-but-locked in v1 and `
|
|
550
|
+
+ 'admission refuses it before any battery is built; a locked class must never resolve to an empty one');
|
|
551
|
+
}
|
|
552
|
+
const l = language(lang);
|
|
553
|
+
const guards = battery.guards.map((g) => {
|
|
554
|
+
/** @type {Record<string, any>} */
|
|
555
|
+
const params = structuredClone(g.params);
|
|
556
|
+
for (const p of g.compose) {
|
|
557
|
+
const resolve = COMPOSE_FROM_TOOL[p];
|
|
558
|
+
if (!resolve) throw new Error(`the "${g.name}" guard composes "${p}", which nothing knows how to resolve from a tool`);
|
|
559
|
+
const value = resolve(l);
|
|
560
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
561
|
+
throw new Error(`the "${g.name}" guard's "${p}" resolved EMPTY for language "${lang}" — a guard that checks `
|
|
562
|
+
+ 'nothing reads clean exactly like one that checked correctly, and this one is about to be signed');
|
|
563
|
+
}
|
|
564
|
+
params[p] = value;
|
|
565
|
+
}
|
|
566
|
+
return { name: g.name, kind: g.kind, params, compose: [...g.compose], fill: [...g.fill] };
|
|
567
|
+
});
|
|
568
|
+
if (guards.length === 0) {
|
|
569
|
+
throw new Error(`the "${verdictType}" battery resolved to NO guards — D5 is the load-bearing decision of this `
|
|
570
|
+
+ 'design and an empty battery is never a legal answer');
|
|
571
|
+
}
|
|
572
|
+
return guards;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* The genre's environment for this patient — ours to inject at execution (M3),
|
|
577
|
+
* never model-authored. An entry with no value produces NO VARIABLE: an empty
|
|
578
|
+
* `MYPYPATH` is not the same instruction as an unset one, and shipping `''`
|
|
579
|
+
* would silently change how mypy resolves.
|
|
580
|
+
* @param {string} lang
|
|
581
|
+
* @param {{sourcePrefixes?: string[]}} [o]
|
|
582
|
+
* @returns {Record<string, string>}
|
|
583
|
+
*/
|
|
584
|
+
export function genreEnv(lang, { sourcePrefixes = [] } = {}) {
|
|
585
|
+
const l = language(lang);
|
|
586
|
+
/** @type {Record<string, string>} */
|
|
587
|
+
const env = {};
|
|
588
|
+
for (const spec of l.env) {
|
|
589
|
+
const parts = (spec.from === 'sourcePrefixes' ? sourcePrefixes : []).filter(isNonEmptyString);
|
|
590
|
+
if (parts.length) env[spec.name] = parts.join(spec.join);
|
|
591
|
+
}
|
|
592
|
+
return env;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** The names the genre OWNS — the validator refuses a declaration that authors
|
|
596
|
+
* one of them, because ownership is only real if it is mechanical.
|
|
597
|
+
* @param {string} lang @returns {string[]} */
|
|
598
|
+
export function genreOwnedEnvNames(lang) {
|
|
599
|
+
return language(lang).env.map((/** @type {any} */ e) => e.name);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* GROUNDING for a RECORDED genre environment — the provenance half of the
|
|
604
|
+
* owned-env rule, and the reason the by-value comparison is worth anything.
|
|
605
|
+
*
|
|
606
|
+
* The value check proves the envelope's `genreEnv` and the stage's `params.env`
|
|
607
|
+
* AGREE. Both copies live in the same hand-editable spec, so agreement is
|
|
608
|
+
* something a forger gets for free: a spec carrying
|
|
609
|
+
* `MYPYPATH=/tmp/attacker-stubs` in both places is perfectly self-consistent and
|
|
610
|
+
* would point the checker at a tree nobody signed. Agreement is not provenance.
|
|
611
|
+
*
|
|
612
|
+
* What a forger does NOT own is the repository at the seed. Every value the
|
|
613
|
+
* arbiter can build is `genreEnv`'s join of `sourcePrefixes`, and those prefixes
|
|
614
|
+
* were already filtered against the seed listing before the join — so each
|
|
615
|
+
* ELEMENT of a genuine value is a path the listing SELECTS, exactly like hamr's
|
|
616
|
+
* listing rule for a declared path. Grounding therefore accepts precisely what
|
|
617
|
+
* `applyGenreEnv` produces and refuses everything else, without needing a second
|
|
618
|
+
* spelling of what "ours" means.
|
|
619
|
+
*
|
|
620
|
+
* Element-wise, never whole-value: a joined list is as strong as its weakest
|
|
621
|
+
* element, and one real prefix must not launder the rest. A root spelling is
|
|
622
|
+
* ungrounded here rather than waved through the way `checkPaths` waves it —
|
|
623
|
+
* `genreEnv` filters those out before joining, so the arbiter can never produce
|
|
624
|
+
* one, and a validator that admits what its own producer cannot emit is a gap.
|
|
625
|
+
*
|
|
626
|
+
* Callers that hold a listing only; the deferred job-spec gate cannot run this
|
|
627
|
+
* and does not pretend to (see `validateCloseDecl`).
|
|
628
|
+
* @param {string} lang
|
|
629
|
+
* @param {Record<string, string>} recorded what the envelope claims the arbiter injected
|
|
630
|
+
* @param {string[]} listing repo-relative paths at the seed
|
|
631
|
+
* @returns {{name: string, value: string, element: string, resolved: string}[]} one row per ungrounded element
|
|
632
|
+
*/
|
|
633
|
+
export function ungroundedGenreEnv(lang, recorded, listing) {
|
|
634
|
+
const idx = indexListing((Array.isArray(listing) ? listing : []).filter(isNonEmptyString));
|
|
635
|
+
/** @type {{name: string, value: string, element: string, resolved: string}[]} */
|
|
636
|
+
const out = [];
|
|
637
|
+
// driven by the GENRE's own specs, not by the recorded keys: the join is genre
|
|
638
|
+
// knowledge, and a key the genre does not own is the envelope's red, not this
|
|
639
|
+
// function's business
|
|
640
|
+
for (const spec of language(lang).env) {
|
|
641
|
+
const value = recorded?.[spec.name];
|
|
642
|
+
if (!isNonEmptyString(value)) continue; // shape is the envelope's own check
|
|
643
|
+
for (const element of String(value).split(spec.join)) {
|
|
644
|
+
const resolved = normPrefix(element);
|
|
645
|
+
if (!isRoot(resolved) && (idx.files.has(resolved) || idx.dirs.has(resolved))) continue;
|
|
646
|
+
out.push({ name: spec.name, value, element, resolved });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ── 3. THE DECLARATION VALIDATOR ─────────────────────────────────────────────
|
|
653
|
+
|
|
654
|
+
/** one prefix spelling, the SHIPPED one — `globToPrefix`'s collapse order is
|
|
655
|
+
* fence territory (F9) and is never re-spelled here. `''`, `'.'` and `'/'` all
|
|
656
|
+
* mean the repository root. */
|
|
657
|
+
const normPrefix = (/** @type {string} */ p) => globToPrefix(String(p)).replace(/^\.\//, '');
|
|
658
|
+
const isRoot = (/** @type {string} */ p) => p === '' || p === '.' || p === '/';
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* The seed listing, indexed once: the files that exist, and every directory
|
|
662
|
+
* that contains one. A path SELECTS when it is one of them.
|
|
663
|
+
* @param {string[]} listing
|
|
664
|
+
*/
|
|
665
|
+
function indexListing(listing) {
|
|
666
|
+
const files = new Set(listing.map((f) => normPrefix(f)).filter((f) => f !== ''));
|
|
667
|
+
/** @type {Set<string>} */
|
|
668
|
+
const dirs = new Set();
|
|
669
|
+
for (const f of files) {
|
|
670
|
+
const segs = f.split('/');
|
|
671
|
+
for (let i = 1; i < segs.length; i++) dirs.add(segs.slice(0, i).join('/'));
|
|
672
|
+
}
|
|
673
|
+
return { files, dirs };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Is this a SCOPED job? Mechanically, from the GENRE'S OWN containment guard
|
|
678
|
+
* against the REAL listing — never from the operator's reading of the interview,
|
|
679
|
+
* and never from whichever `files-changed` stage happens to come first.
|
|
680
|
+
*
|
|
681
|
+
* WHICH STAGE IS READ IS LOAD-BEARING. The candidate set is the genre's guards,
|
|
682
|
+
* matched BY NAME (the guard battery's own `changed-from-seed`), because that is
|
|
683
|
+
* the one stage `checkGuards` pins byte-for-byte with `allowPrefixes` as its only
|
|
684
|
+
* model-filled slot — so its prefixes ARE the job's declared target scope. Read
|
|
685
|
+
* first-of-kind instead and a declaration carrying a legally-shaped decoy
|
|
686
|
+
* `files-changed` stage (`allowPrefixes: ['.']`) DECLARED EARLIER reads as
|
|
687
|
+
* whole-tree, and the F84 one-population law switches itself off silently: the
|
|
688
|
+
* count stages stop having to say which population they count. Measured, not
|
|
689
|
+
* imagined — with the decoy the whole declaration validated with zero reds.
|
|
690
|
+
*
|
|
691
|
+
* The guard NAMES come from the caller's own `guards` array rather than a literal
|
|
692
|
+
* here: one spelling, and a genre that renames or adds a containment guard is
|
|
693
|
+
* covered the day it lands instead of the day someone remembers this function.
|
|
694
|
+
* No guards in hand means no derivation — `guards-absent` is already a red, and
|
|
695
|
+
* guessing the job's scope from an unpinned stage is exactly what this fixes.
|
|
696
|
+
*
|
|
697
|
+
* A whole-tree job is genuinely unscoped — there is nothing outside — and the
|
|
698
|
+
* law must not red it (template rule 3 says the outside ceiling is omitted
|
|
699
|
+
* exactly there).
|
|
700
|
+
* @param {any} declaration @param {{files: Set<string>, dirs: Set<string>}} idx
|
|
701
|
+
* @param {any[]|null} guards the picked class's injected guards (`classGuards`)
|
|
702
|
+
*/
|
|
703
|
+
function scopeOfJob(declaration, idx, guards) {
|
|
704
|
+
const stages = Array.isArray(declaration?.stages) ? declaration.stages : [];
|
|
705
|
+
const scopeGuards = (Array.isArray(guards) ? guards : [])
|
|
706
|
+
.filter((g) => isObj(g) && g.kind === 'files-changed' && isNonEmptyString(g.name));
|
|
707
|
+
for (const g of scopeGuards) {
|
|
708
|
+
const s = stages.find((/** @type {any} */ x) => isObj(x) && x.name === g.name && x.kind === 'files-changed');
|
|
709
|
+
if (!s) continue;
|
|
710
|
+
const pre = s?.params?.allowPrefixes;
|
|
711
|
+
if (!Array.isArray(pre) || pre.length === 0 || !pre.every(isNonEmptyString)) continue;
|
|
712
|
+
const norm = pre.map(normPrefix);
|
|
713
|
+
if (norm.some(isRoot)) continue; // this guard covers the whole tree
|
|
714
|
+
const uncovered = [...idx.files].some((f) => !norm.some((p) => f === p || f.startsWith(`${p}/`)));
|
|
715
|
+
if (uncovered) return { scoped: true, via: `${s.name}.allowPrefixes` };
|
|
716
|
+
}
|
|
717
|
+
return { scoped: false, via: null };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Kinds a declaration may carry AT MOST ONCE, as data beside the catalogue.
|
|
722
|
+
*
|
|
723
|
+
* `files-changed` is the whole list and the reason is the catalogue's own
|
|
724
|
+
* semantics, not taste. The kind asserts "every changed file lies inside
|
|
725
|
+
* allowPrefixes"; two of them assert it twice, and stages are ANDed, so the
|
|
726
|
+
* effective constraint is the INTERSECTION of the two prefix sets — which is
|
|
727
|
+
* itself a prefix set, and is therefore already expressible as one stage's
|
|
728
|
+
* `allowPrefixes`. A second containment stage adds no reach whatsoever, so
|
|
729
|
+
* refusing it costs the author nothing.
|
|
730
|
+
*
|
|
731
|
+
* What it does cost to admit is measurable: a second `files-changed` stage is a
|
|
732
|
+
* second answer to "what is this job's scope", which is the ambiguity
|
|
733
|
+
* `scopeOfJob` had to be pinned to the guard to survive. This is the same shape
|
|
734
|
+
* as `count-not-worse`'s `duplicate-population` rule — one population is one
|
|
735
|
+
* stage — applied to the one population that decides whether the law fires at
|
|
736
|
+
* all.
|
|
737
|
+
*/
|
|
738
|
+
const AT_MOST_ONCE_KINDS = Object.freeze(['files-changed']);
|
|
739
|
+
|
|
740
|
+
/** @param {any} v */
|
|
741
|
+
const strArray = (v) => Array.isArray(v) && v.length > 0 && v.every(isNonEmptyString);
|
|
742
|
+
|
|
743
|
+
/** read a dotted param path off a params object, or undefined @param {any} p @param {string} dotted */
|
|
744
|
+
function readPath(p, dotted) {
|
|
745
|
+
let node = p;
|
|
746
|
+
for (const seg of dotted.split('.')) {
|
|
747
|
+
if (!isObj(node)) return undefined;
|
|
748
|
+
node = node[seg];
|
|
749
|
+
}
|
|
750
|
+
return node;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Validate an authored close DECLARATION. Never throws on model output — every
|
|
755
|
+
* failure is a named red, in the shipped `{ok, reds, <parsed>}` shape, and the
|
|
756
|
+
* parsed value is the RESOLVED declaration (every short-form parser expanded),
|
|
757
|
+
* because that is the form the executor runs and the signature covers.
|
|
758
|
+
*
|
|
759
|
+
* `listing`, `guards` and `envOwned` are REQUIRED, and their absence is a red
|
|
760
|
+
* rather than a skipped check — all three guard the SAME failure: a validator
|
|
761
|
+
* that silently stops checking when handed nothing to check against reports a
|
|
762
|
+
* clean declaration it never examined. v1 only admits a job with a runnable
|
|
763
|
+
* patient and a git seed (D13), so the listing always exists, and D5's guards
|
|
764
|
+
* exist by construction. `envOwned` is separated into absent-vs-empty for
|
|
765
|
+
* F59's reason exactly: `[]` is a genre that owns no variables (JS), and
|
|
766
|
+
* `undefined` is a caller that never asked — the two must not read alike.
|
|
767
|
+
*
|
|
768
|
+
* The CATALOGUE is ours, not model input: a malformed one is a bareloop bug and
|
|
769
|
+
* is allowed to throw, because failing loudly beats a validator that quietly
|
|
770
|
+
* stops enforcing a rule it was configured out of.
|
|
771
|
+
*
|
|
772
|
+
* `deferListing: true` is the ONE way to run without a listing, and it must be
|
|
773
|
+
* spelled literally. It exists for exactly one caller — the job-spec validator,
|
|
774
|
+
* which judges a signed `closeDecl` with no repository in hand — and it does NOT
|
|
775
|
+
* make the listing optional: it moves that half of the gate to the runner, which
|
|
776
|
+
* re-validates GROUNDED against the real seed before any stage runs. Two checks
|
|
777
|
+
* ride on the listing and both are named here rather than discovered later: the
|
|
778
|
+
* path rule (a path SELECTS or it does not exist), and the SCOPED-job derivation
|
|
779
|
+
* that arms the F84 one-population law. With the listing deferred, neither
|
|
780
|
+
* fires, and `grounded: false` on the result is how a caller knows.
|
|
781
|
+
*
|
|
782
|
+
* `verdictType` is REQUIRED for the same reason: it is the PROMISE the ceiling
|
|
783
|
+
* rule checks the composition against, and a validator with no pick in hand
|
|
784
|
+
* cannot check it. Absent or unknown is `class-absent`, naming the closed set.
|
|
785
|
+
*
|
|
786
|
+
* @param {any} declaration the parsed model output
|
|
787
|
+
* @param {{catalogue?: Record<string, KindSpec>, listing?: string[]|null,
|
|
788
|
+
* guards?: {name: string, kind: string, params: Record<string, any>, fill: string[]}[]|null,
|
|
789
|
+
* envOwned?: string[]|null, envInjected?: Record<string, string>|null, deferListing?: boolean,
|
|
790
|
+
* verdictType?: string|null}} [opts]
|
|
791
|
+
* `listing` — repo-relative paths at the seed (`git ls-tree -r --name-only`).
|
|
792
|
+
* `guards` — the picked class's injected guards (`classGuards`).
|
|
793
|
+
* `verdictType` — the class the USER picked (`VERDICT_CLASSES`).
|
|
794
|
+
* `envOwned` — env names the genre injects (`genreOwnedEnvNames`), `[]` when it owns none.
|
|
795
|
+
* `envInjected` — the env the ARBITER itself injected (`genreEnv`'s output), for a
|
|
796
|
+
* POST-injection re-validation. Omitted is the PRE-injection form: every owned name
|
|
797
|
+
* is the model's and reds. Supplied, an owned name is accepted only when its value is
|
|
798
|
+
* EXACTLY the injected one — a wrong value is the model's guess or corruption.
|
|
799
|
+
* `deferListing` — literally `true` to run the tree-independent half only.
|
|
800
|
+
* @returns {{ok: boolean, reds: Red[], declaration: any, grounded: boolean,
|
|
801
|
+
* scoped: {scoped: boolean, via: string|null},
|
|
802
|
+
* ceiling: {class: string, kind: string, stage: string|null, locked: boolean}|null}}
|
|
803
|
+
*/
|
|
804
|
+
export function validateDeclaration(declaration, opts = {}) {
|
|
805
|
+
const {
|
|
806
|
+
catalogue = KIND_CATALOGUE, listing = null, guards = null, envOwned = null, envInjected = null,
|
|
807
|
+
verdictType = null,
|
|
808
|
+
} = opts;
|
|
809
|
+
const deferListing = opts.deferListing === true;
|
|
810
|
+
/** @type {Red[]} */
|
|
811
|
+
const reds = [];
|
|
812
|
+
/** @type {(code: string, path: string, detail: string, extra?: object) => void} */
|
|
813
|
+
const red = (code, path, detail, extra = {}) => { reds.push({ code, path, detail, ...extra }); };
|
|
814
|
+
|
|
815
|
+
const lockedKinds = Object.keys(catalogue).filter((k) => catalogue[k].locked);
|
|
816
|
+
const kindNames = Object.keys(catalogue).join(' | ');
|
|
817
|
+
|
|
818
|
+
const haveListing = Array.isArray(listing) && listing.length > 0 && listing.every(isNonEmptyString);
|
|
819
|
+
if (!haveListing && !deferListing) {
|
|
820
|
+
red('listing-absent', 'listing', 'a non-empty seed file listing (git ls-tree -r --name-only <seed>) — '
|
|
821
|
+
+ 'without it nothing can tell a real path from an invented one, and a validator that skips that check '
|
|
822
|
+
+ 'reports a declaration it never examined');
|
|
823
|
+
}
|
|
824
|
+
if (haveListing && deferListing) {
|
|
825
|
+
red('listing-conflict', 'listing', 'deferListing was declared AND a listing was supplied — a caller that both '
|
|
826
|
+
+ 'defers the tree-grounded half and hands over a tree does not know which gate it is running');
|
|
827
|
+
}
|
|
828
|
+
const idx = indexListing(haveListing ? /** @type {string[]} */ (listing) : []);
|
|
829
|
+
|
|
830
|
+
const haveGuards = Array.isArray(guards) && guards.length > 0;
|
|
831
|
+
if (!haveGuards) {
|
|
832
|
+
red('guards-absent', 'guards', 'the picked class\'s mandatory guards (classGuards) — D5 is the load-bearing '
|
|
833
|
+
+ 'decision of this design and it cannot be validated away by handing the validator nothing to check against');
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// THE PICK, and it is required for the same reason the guards are: it is the
|
|
837
|
+
// promise the ceiling rule measures the composition against, and a validator
|
|
838
|
+
// handed no pick reports a promise it never checked. A closed set, handed over
|
|
839
|
+
// enumerated — never a prose rule about what a legal class would look like.
|
|
840
|
+
const picked = VERDICT_CLASSES.includes(String(verdictType)) ? String(verdictType) : null;
|
|
841
|
+
if (picked === null) {
|
|
842
|
+
red('class-absent', 'verdictType', `the verdict class the user picked — one of ${VERDICT_CLASSES.join(' | ')}. `
|
|
843
|
+
+ 'It is what a composition promises to stay at or below, and nothing derives it from the declaration');
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// absent is not empty: `[]` is a genre that owns no environment (JS), and a
|
|
847
|
+
// caller that never asked is a caller whose declarations are unchecked on the
|
|
848
|
+
// axis round 2's arm B failed (F59's distinction, in the shape it bites here)
|
|
849
|
+
const owned = Array.isArray(envOwned) ? envOwned : null;
|
|
850
|
+
if (owned === null) {
|
|
851
|
+
red('env-ownership-absent', 'envOwned', 'the env names this genre injects (genreOwnedEnvNames) — pass [] for a '
|
|
852
|
+
+ 'genre that owns none; omitting it leaves a model-authored MYPYPATH-class variable unexamined');
|
|
853
|
+
}
|
|
854
|
+
// The arbiter's OWN injection, when this is a POST-injection re-validation.
|
|
855
|
+
// ABSENT (the default) is the PRE-injection form the authoring loop validates,
|
|
856
|
+
// where every owned name is the model's and reds — so the loop's gate keeps
|
|
857
|
+
// exactly the behaviour it had, and only a caller that can state what the
|
|
858
|
+
// arbiter injected gets to have it accepted.
|
|
859
|
+
const injected = isObj(envInjected) ? /** @type {Record<string, string>} */ (envInjected) : {};
|
|
860
|
+
|
|
861
|
+
if (!isObj(declaration)) {
|
|
862
|
+
red('invalid-value', '', 'the declaration is an object carrying an ordered stages array');
|
|
863
|
+
return { ok: false, reds, declaration: null, grounded: haveListing, scoped: { scoped: false, via: null }, ceiling: null };
|
|
864
|
+
}
|
|
865
|
+
// NOTES, checked HERE rather than only at the signing gate. The field is the
|
|
866
|
+
// model's own ("anything you could not express"), it travels verbatim into the
|
|
867
|
+
// signed `closeDecl`, and `validateCloseDecl` refuses an empty one — so a rule
|
|
868
|
+
// that lives only there is a rule the authoring loop cannot feed back: the run
|
|
869
|
+
// pays for a close, accepts it, and dies at signing on a defect the model was
|
|
870
|
+
// never shown. One spelling of the rule, at the gate that can still revise it.
|
|
871
|
+
if (declaration.notes !== undefined
|
|
872
|
+
&& !(Array.isArray(declaration.notes) && declaration.notes.every(isNonEmptyString))) {
|
|
873
|
+
red('invalid-value', 'notes', 'an array of non-empty strings — what the author could not express, kept with '
|
|
874
|
+
+ 'the artefact it is about');
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const stages = declaration.stages;
|
|
878
|
+
if (!Array.isArray(stages) || stages.length === 0) {
|
|
879
|
+
red('missing-field', 'stages', 'a non-empty ORDERED array of stages — they run in the order you declare them');
|
|
880
|
+
return { ok: false, reds, declaration: null, grounded: haveListing, scoped: { scoped: false, via: null }, ceiling: null };
|
|
881
|
+
}
|
|
882
|
+
if (stages.length > MAX_STAGES) {
|
|
883
|
+
red('invalid-value', 'stages', `${stages.length} stages exceeds the ceiling of ${MAX_STAGES}`);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const scoped = scopeOfJob(declaration, idx, guards);
|
|
887
|
+
// Computed over the WHOLE declaration and REPORTED whatever it says, so a
|
|
888
|
+
// composition that sits BELOW the pick is visible rather than silent — the
|
|
889
|
+
// other half of the ruling ("never a silent downgrade"). Only EXCEEDING it is
|
|
890
|
+
// a red, and that red is raised per offending stage inside the loop.
|
|
891
|
+
const ceiling = closeCeiling(declaration, catalogue);
|
|
892
|
+
/** @type {Set<string>} */
|
|
893
|
+
const seen = new Set();
|
|
894
|
+
/** @type {Map<string, string>} population key → the stage that already owns it */
|
|
895
|
+
const populations = new Map();
|
|
896
|
+
/** @type {Map<string, string>} at-most-once kind → the stage that already owns it */
|
|
897
|
+
const kindOwners = new Map();
|
|
898
|
+
|
|
899
|
+
stages.forEach((/** @type {any} */ s, i) => {
|
|
900
|
+
const at = `stages[${i}]`;
|
|
901
|
+
if (!isObj(s)) { red('invalid-value', at, 'a stage is an object {name, kind, params}'); return; }
|
|
902
|
+
|
|
903
|
+
if (!isNonEmptyString(s.name)) red('missing-field', `${at}.name`, 'a non-empty stage name');
|
|
904
|
+
else if (!NAME_RE.test(s.name)) red('invalid-value', `${at}.name`, `"${s.name}" is not a lowercase-hyphenated slug`);
|
|
905
|
+
else if (seen.has(s.name)) {
|
|
906
|
+
red('duplicate-name', `${at}.name`, `stage name "${s.name}" is declared twice — two stages under one name are `
|
|
907
|
+
+ 'one trend series over two populations (F84)');
|
|
908
|
+
} else seen.add(s.name);
|
|
909
|
+
const label = isNonEmptyString(s.name) ? s.name : at;
|
|
910
|
+
|
|
911
|
+
if (!isNonEmptyString(s.kind)) { red('missing-field', `${at}.kind`, `a kind: ${kindNames}`); return; }
|
|
912
|
+
if (lockedKinds.includes(s.kind)) {
|
|
913
|
+
// A DISTINCT red carrying counted demand. `verb`/`lib` are the structured
|
|
914
|
+
// fields the ledger keys admission on, never prose, and `lib` is stamped
|
|
915
|
+
// HERE at the emit site (the typed-lib rule): a locked KIND is demand
|
|
916
|
+
// against bareloop's own catalogue, and filing it against a bare-suite
|
|
917
|
+
// package is the BA-2 misattribution that rule exists to prevent.
|
|
918
|
+
red('locked-kind', `${at}.kind`, `"${s.kind}" is a named but LOCKED kind — declaring it is recorded demand, `
|
|
919
|
+
+ 'never an admission; it does not run in v1', { kind: s.kind, verb: s.kind, lib: 'bareloop', stage: label });
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
const spec = catalogue[s.kind];
|
|
923
|
+
if (!spec) {
|
|
924
|
+
red('unknown-kind', `${at}.kind`, `"${s.kind}" is not in the catalogue — kinds: ${kindNames}`, { kind: s.kind });
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
// THE CEILING (PRD v1.57 §1): composition may only use kinds AT OR BELOW the
|
|
928
|
+
// picked class. A LOCKED kind never reaches here — it already carries its own
|
|
929
|
+
// counted-demand red and returned — so a kind refused as unavailable is never
|
|
930
|
+
// ALSO redded for its class: one problem, one red, in one vocabulary.
|
|
931
|
+
if (picked !== null && CLASS_RANK[spec.verdictClass] > CLASS_RANK[picked]) {
|
|
932
|
+
red('class-ceiling', `${at}.kind`, `stage "${label}" uses "${s.kind}", which can only render a `
|
|
933
|
+
+ `${spec.verdictClass} verdict, and this job is declared ${picked}. The class the user picked is a PROMISE: a `
|
|
934
|
+
+ 'composition may use kinds at or below it and never above it, because a judgment ruler running under a '
|
|
935
|
+
+ 'machine-checkable promise is a fake-hard verdict — refused here rather than upgraded silently',
|
|
936
|
+
{ kind: s.kind, stage: label, kindClass: spec.verdictClass, picked });
|
|
937
|
+
}
|
|
938
|
+
if (AT_MOST_ONCE_KINDS.includes(s.kind)) {
|
|
939
|
+
const owner = kindOwners.get(s.kind);
|
|
940
|
+
if (owner !== undefined) {
|
|
941
|
+
red('duplicate-kind', `${at}.kind`, `stage "${label}" is a second ${s.kind} stage — "${owner}" already declares `
|
|
942
|
+
+ 'which files this job may change. Stages are ANDed, so two of them mean the intersection of two prefix sets, '
|
|
943
|
+
+ 'which is itself a prefix set and already says the same thing in one stage; what a second one does add is a '
|
|
944
|
+
+ 'second answer to what this job\'s scope IS', { kind: s.kind, stage: label, twin: owner });
|
|
945
|
+
} else kindOwners.set(s.kind, label);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const p = s.params;
|
|
949
|
+
if (!isObj(p)) { red('missing-field', `${at}.params`, `an object carrying: ${spec.required.join(', ')}`); return; }
|
|
950
|
+
for (const k of spec.required) {
|
|
951
|
+
if (!Object.hasOwn(p, k) || p[k] === null || p[k] === undefined) {
|
|
952
|
+
red('missing-field', `${at}.params.${k}`, `${s.kind} requires ${k} (${spec.shape})`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
for (const k of Object.keys(p)) {
|
|
956
|
+
if (!spec.required.includes(k) && !spec.optional.includes(k)) {
|
|
957
|
+
red('invalid-value', `${at}.params.${k}`, `"${k}" is not a parameter of ${s.kind} — the catalogue is the `
|
|
958
|
+
+ 'only source of parameter names');
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
checkKind({ kind: s.kind, params: p, at, red, scoped, label, populations, envOwned: owned ?? [], envInjected: injected });
|
|
963
|
+
checkEnvComplete({ params: p, at, red, kind: s.kind, catalogue, envInjected: injected });
|
|
964
|
+
checkPaths({ spec, params: p, at, red, idx, haveListing });
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
if (haveGuards) checkGuards({ declaration, guards: /** @type {any[]} */ (guards), red });
|
|
968
|
+
|
|
969
|
+
const ok = reds.length === 0;
|
|
970
|
+
return { ok, reds, declaration: ok ? normalizeDeclaration(declaration) : null, grounded: haveListing, scoped, ceiling };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* COMPLETENESS — the deletion half of the owned-env rule, and the half a
|
|
975
|
+
* by-VALUE comparison structurally cannot cover.
|
|
976
|
+
*
|
|
977
|
+
* `genre-owned-env` catches a CHANGED variable: a value that is not the one the
|
|
978
|
+
* arbiter builds. Nothing caught a DELETED one. Drop the key from a stage — or
|
|
979
|
+
* drop `params.env` outright — and there is no value left to compare, so a
|
|
980
|
+
* hand-edited spec validates clean and mypy resolves through an editable install
|
|
981
|
+
* to a DIFFERENT checkout while the close reports a number. That is the Result-5
|
|
982
|
+
* hazard exactly, arriving through the one door the value check leaves open, and
|
|
983
|
+
* it is silent by construction: the variable moves no number the close can see.
|
|
984
|
+
*
|
|
985
|
+
* So an injection is a fact about EVERY stage the injector touches, not about
|
|
986
|
+
* the stages that happen to still carry it. The set is read from the same
|
|
987
|
+
* catalogue predicate `applyGenreEnv` injects by (`envCapableKind`), so the rule
|
|
988
|
+
* can never demand a variable on a stage nothing would inject into, nor let one
|
|
989
|
+
* off a stage that was injected.
|
|
990
|
+
*
|
|
991
|
+
* Runs only on a POST-injection re-validation: with nothing recorded there is
|
|
992
|
+
* nothing to be complete about, and the authoring loop must not ask the model
|
|
993
|
+
* for the variable it is forbidden to author.
|
|
994
|
+
* @param {{params: any, at: string, red: (c: string, p: string, d: string, e?: object) => void,
|
|
995
|
+
* kind: string, catalogue: Record<string, KindSpec>, envInjected: Record<string, string>}} o
|
|
996
|
+
*/
|
|
997
|
+
function checkEnvComplete({ params: p, at, red, kind, catalogue, envInjected }) {
|
|
998
|
+
const names = Object.keys(envInjected);
|
|
999
|
+
if (names.length === 0 || !envCapableKind(kind, catalogue)) return;
|
|
1000
|
+
// a malformed `env` already has its own shape red; saying it twice in two
|
|
1001
|
+
// vocabularies tells the reader there are two problems
|
|
1002
|
+
if (Object.hasOwn(p, 'env') && !isObj(p.env)) return;
|
|
1003
|
+
for (const name of names) {
|
|
1004
|
+
if (isObj(p.env) && Object.hasOwn(p.env, name)) continue;
|
|
1005
|
+
red('genre-env-missing', `${at}.params.env.${name}`, `${name} is recorded as the genre's own injection and this `
|
|
1006
|
+
+ 'stage spawns a process, so it must carry it — the arbiter injects into every env-capable stage, and a stage '
|
|
1007
|
+
+ 'that comes back without it grades whatever the ambient environment resolves to, silently and with no number '
|
|
1008
|
+
+ 'that moves', { name, expected: envInjected[name] });
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* The per-kind parameter checks, plus the two halves of the one-population law.
|
|
1014
|
+
* @param {{kind: string, params: any, at: string, red: (c: string, p: string, d: string, e?: object) => void,
|
|
1015
|
+
* scoped: {scoped: boolean, via: string|null}, label: string, populations: Map<string, string>,
|
|
1016
|
+
* envOwned: string[], envInjected: Record<string, string>}} o
|
|
1017
|
+
*/
|
|
1018
|
+
function checkKind({ kind, params: p, at, red, scoped, label, populations, envOwned, envInjected }) {
|
|
1019
|
+
/** F49 — the static nested-quantifier reject, at the gate, before the pattern
|
|
1020
|
+
* ever runs. MEASURED: `(a+)+$` did not finish on a 33-char body in 120s, so
|
|
1021
|
+
* input-bounding is theatre and a static reject is not. The detector itself is
|
|
1022
|
+
* never touched from here (monotonic-only doctrine). */
|
|
1023
|
+
const regex = (/** @type {any} */ v, /** @type {string} */ path) => {
|
|
1024
|
+
if (!isNonEmptyString(v)) { red('invalid-value', path, 'a regular expression, as a string'); return; }
|
|
1025
|
+
if (hasNestedQuantifier(v)) {
|
|
1026
|
+
red('invalid-value', path, `/${v}/ carries a nested quantifier — rejected statically (F49); rewrite it without one`);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
try { new RegExp(v); } catch (e) {
|
|
1030
|
+
red('invalid-value', path, `/${v}/ is not a valid regular expression: ${String(/** @type {any} */ (e)?.message ?? e)}`);
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
const command = () => {
|
|
1034
|
+
if (!isNonEmptyString(p.cmd)) red('invalid-value', `${at}.params.cmd`, 'a non-empty executable name');
|
|
1035
|
+
else {
|
|
1036
|
+
// THE DENY FLOOR (PRD v1.57 §3), at the validation gate, before any token
|
|
1037
|
+
// and before any spawn. A FLOOR, not a sandbox — see DENIED_COMMANDS.
|
|
1038
|
+
const denied = deniedCommandReason(p.cmd);
|
|
1039
|
+
if (denied) red('cmd-denied', `${at}.params.cmd`, denied, { cmd: p.cmd });
|
|
1040
|
+
}
|
|
1041
|
+
if (Object.hasOwn(p, 'args') && !(Array.isArray(p.args) && p.args.every((/** @type {any} */ x) => typeof x === 'string'))) {
|
|
1042
|
+
red('invalid-value', `${at}.params.args`, 'an array of strings (may be empty)');
|
|
1043
|
+
}
|
|
1044
|
+
if (Object.hasOwn(p, 'timeoutMs') && !(Number.isInteger(p.timeoutMs) && p.timeoutMs > 0)) {
|
|
1045
|
+
red('invalid-value', `${at}.params.timeoutMs`, 'a positive integer');
|
|
1046
|
+
}
|
|
1047
|
+
if (Object.hasOwn(p, 'env')) {
|
|
1048
|
+
if (!(isObj(p.env) && Object.values(p.env).every((v) => typeof v === 'string'))) {
|
|
1049
|
+
red('invalid-value', `${at}.params.env`, 'an object of string values');
|
|
1050
|
+
} else {
|
|
1051
|
+
for (const name of Object.keys(p.env)) {
|
|
1052
|
+
if (!envOwned.includes(name)) continue;
|
|
1053
|
+
// The arbiter's OWN injection reaches this check on every re-validation
|
|
1054
|
+
// after the flow applied it (M3's applyGenreEnv), so the split is by
|
|
1055
|
+
// VALUE and never by presence — exactly how `checkGuards` tells a genre
|
|
1056
|
+
// guard from a weakened copy. Presence-flagging here would red the
|
|
1057
|
+
// arbiter's own artefact and no python close could be signed, validated
|
|
1058
|
+
// or run.
|
|
1059
|
+
const injected = Object.hasOwn(envInjected, name);
|
|
1060
|
+
if (injected && p.env[name] === envInjected[name]) continue;
|
|
1061
|
+
// Round 2's arm B authored MYPYPATH itself and authored it WRONG; the
|
|
1062
|
+
// flow sets it from genre data. A value that is not the one the arbiter
|
|
1063
|
+
// builds is the model's guess or a corrupted artefact, either way a red.
|
|
1064
|
+
red('genre-owned-env', `${at}.params.env.${name}`, injected
|
|
1065
|
+
? `${name} is the genre's own variable and its value is fixed by the genre, not by the declaration: `
|
|
1066
|
+
+ `expected ${JSON.stringify(envInjected[name])}, declared ${JSON.stringify(p.env[name])}`
|
|
1067
|
+
: `${name} is set by the genre, not by the declaration — this is a fact the interview cannot supply `
|
|
1068
|
+
+ 'and the repository does not state',
|
|
1069
|
+
{ name, ...(injected ? { expected: envInjected[name], declared: p.env[name] } : {}) });
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
/** the one-population law's first half (F84): a count over a scoped job must
|
|
1075
|
+
* say WHICH population it counts, or an in-scope 29 and an outside 4 land in
|
|
1076
|
+
* one trend series and a run that swapped walls reads as converging. */
|
|
1077
|
+
const populationNamed = (/** @type {string} */ path) => {
|
|
1078
|
+
const sc = p.scope;
|
|
1079
|
+
if (!isObj(sc)) { red('missing-field', path, 'a scope object naming this stage\'s population'); return; }
|
|
1080
|
+
const inc = Object.hasOwn(sc, 'includePrefixes') ? sc.includePrefixes : null;
|
|
1081
|
+
const exc = Object.hasOwn(sc, 'excludePrefixes') ? sc.excludePrefixes : null;
|
|
1082
|
+
if (inc !== null && !strArray(inc)) red('invalid-value', `${path}.includePrefixes`, 'a non-empty array of path prefixes');
|
|
1083
|
+
if (exc !== null && !strArray(exc)) red('invalid-value', `${path}.excludePrefixes`, 'a non-empty array of path prefixes');
|
|
1084
|
+
for (const k of Object.keys(sc)) {
|
|
1085
|
+
if (k !== 'includePrefixes' && k !== 'excludePrefixes') red('invalid-value', `${path}.${k}`, 'a scope takes includePrefixes and excludePrefixes only');
|
|
1086
|
+
}
|
|
1087
|
+
if (inc === null && exc === null && scoped.scoped) {
|
|
1088
|
+
red('one-population', path, `stage "${label}" counts over a scoped job (${scoped.via}) with no include/exclude `
|
|
1089
|
+
+ 'filter — that mixes the job\'s own population with everything else under one stage name, and the two land '
|
|
1090
|
+
+ 'in one trend series (F84)', { rule: 'unnamed-population', stage: label });
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
switch (kind) {
|
|
1095
|
+
case 'command-exit':
|
|
1096
|
+
command();
|
|
1097
|
+
if (!Number.isInteger(p.expectExit)) red('invalid-value', `${at}.params.expectExit`, 'an integer exit code');
|
|
1098
|
+
break;
|
|
1099
|
+
case 'count-not-worse': {
|
|
1100
|
+
command();
|
|
1101
|
+
// ONE normalisation, the EXECUTOR's own (src/kinds.js). Short form and long
|
|
1102
|
+
// form both land here, so the validator and the grader can never disagree
|
|
1103
|
+
// about what a parser means.
|
|
1104
|
+
const norm = normalizeParser(p.parser, `${at}.params.parser`);
|
|
1105
|
+
for (const r of norm.reds) red(r.code, r.path, r.detail);
|
|
1106
|
+
for (const t of norm.terms ?? []) {
|
|
1107
|
+
regex(t.lineMatch, `${at}.params.parser (lineMatch /${t.lineMatch}/)`);
|
|
1108
|
+
// a region anchor runs over the whole output, so it is exactly as
|
|
1109
|
+
// exposed to F49 as lineMatch is
|
|
1110
|
+
if (isObj(t.region)) regex(t.region.anchor, `${at}.params.parser (region anchor /${t.region.anchor}/)`);
|
|
1111
|
+
}
|
|
1112
|
+
if (!DIRECTIONS.includes(p.direction)) red('invalid-value', `${at}.params.direction`, `one of ${DIRECTIONS.join(' | ')}`);
|
|
1113
|
+
if (!BASELINES.includes(p.baseline)) {
|
|
1114
|
+
red('invalid-value', `${at}.params.baseline`, `one of ${BASELINES.map((b) => JSON.stringify(b)).join(' | ')} — `
|
|
1115
|
+
+ 'the close stores the counting rule, never a number you measured (D12)');
|
|
1116
|
+
}
|
|
1117
|
+
populationNamed(`${at}.params.scope`);
|
|
1118
|
+
// the law's second half: the SAME command, parser and scope declared twice
|
|
1119
|
+
// is one population measured twice, wearing two stage names
|
|
1120
|
+
if (norm.ok) {
|
|
1121
|
+
const key = JSON.stringify([p.cmd, p.args ?? [], norm.terms, canonicalScope(p.scope)]);
|
|
1122
|
+
const owner = populations.get(key);
|
|
1123
|
+
if (owner !== undefined) {
|
|
1124
|
+
red('one-population', `${at}.params`, `stage "${label}" measures the same population as "${owner}" — same `
|
|
1125
|
+
+ 'command, same parser, same scope. One population is one stage (F84); two names over one number are two '
|
|
1126
|
+
+ 'trend series that can never disagree', { rule: 'duplicate-population', stage: label, twin: owner });
|
|
1127
|
+
} else populations.set(key, label);
|
|
1128
|
+
}
|
|
1129
|
+
break;
|
|
1130
|
+
}
|
|
1131
|
+
case 'pattern-absent-in-diff':
|
|
1132
|
+
if (!Array.isArray(p.patterns) || p.patterns.length === 0) {
|
|
1133
|
+
red('invalid-value', `${at}.params.patterns`, 'a non-empty array of {id, regex}');
|
|
1134
|
+
} else {
|
|
1135
|
+
p.patterns.forEach((/** @type {any} */ q, /** @type {number} */ j) => {
|
|
1136
|
+
if (!isObj(q)) { red('invalid-value', `${at}.params.patterns[${j}]`, '{id, regex}'); return; }
|
|
1137
|
+
if (!isNonEmptyString(q.id)) red('invalid-value', `${at}.params.patterns[${j}].id`, 'a non-empty id');
|
|
1138
|
+
regex(q.regex, `${at}.params.patterns[${j}].regex`);
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
if (!strArray(p.extensions)) red('invalid-value', `${at}.params.extensions`, 'a non-empty array of file extensions');
|
|
1142
|
+
// scope is OPTIONAL here and absent on the genre guard: the scan covers
|
|
1143
|
+
// every changed file of these extensions. When a declaration does carry
|
|
1144
|
+
// one it is still shape-checked — but a missing scope is not a red.
|
|
1145
|
+
if (Object.hasOwn(p, 'scope')) populationNamed(`${at}.params.scope`);
|
|
1146
|
+
break;
|
|
1147
|
+
case 'files-changed':
|
|
1148
|
+
if (!strArray(p.allowPrefixes)) red('invalid-value', `${at}.params.allowPrefixes`, 'a non-empty array of path prefixes');
|
|
1149
|
+
if (p.requireNonEmpty !== true) red('invalid-value', `${at}.params.requireNonEmpty`, 'literally true');
|
|
1150
|
+
break;
|
|
1151
|
+
default:
|
|
1152
|
+
red('unknown-kind', `${at}.kind`, `"${kind}" is in the catalogue but has no checker`, { kind });
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** the scope as a comparable value — key order must not decide whether two
|
|
1157
|
+
* stages count the same population @param {any} sc */
|
|
1158
|
+
function canonicalScope(sc) {
|
|
1159
|
+
if (!isObj(sc)) return null;
|
|
1160
|
+
const norm = (/** @type {any} */ v) => (Array.isArray(v) ? [...v].map(normPrefix).sort() : null);
|
|
1161
|
+
return { include: norm(sc.includePrefixes), exclude: norm(sc.excludePrefixes) };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* HAMR'S LISTING RULE: every path-like param must SELECT from the seed listing.
|
|
1166
|
+
* A path matching nothing is a distinct red, never a judgment call — round 2's
|
|
1167
|
+
* arm A derived `src/alertEmail.js` from the user's prose and reclassified 15
|
|
1168
|
+
* real errors into the wrong population, and no amount of reading the
|
|
1169
|
+
* declaration would have caught it.
|
|
1170
|
+
*
|
|
1171
|
+
* A trailing-slash (or `/**`) spelling ASSERTS a directory, so it must name one;
|
|
1172
|
+
* a bare path may name either. The repository root, however spelled, always
|
|
1173
|
+
* selects — it names the whole tree, which exists.
|
|
1174
|
+
* @param {{spec: KindSpec, params: any, at: string,
|
|
1175
|
+
* red: (c: string, p: string, d: string, e?: object) => void,
|
|
1176
|
+
* idx: {files: Set<string>, dirs: Set<string>}, haveListing: boolean}} o
|
|
1177
|
+
*/
|
|
1178
|
+
function checkPaths({ spec, params: p, at, red, idx, haveListing }) {
|
|
1179
|
+
if (!haveListing) return; // already redded once, at the top; not twice per path
|
|
1180
|
+
for (const dotted of spec.pathParams) {
|
|
1181
|
+
const value = readPath(p, dotted);
|
|
1182
|
+
if (!Array.isArray(value)) continue; // shape is the schema walk's business
|
|
1183
|
+
value.forEach((raw, i) => {
|
|
1184
|
+
if (!isNonEmptyString(raw)) return;
|
|
1185
|
+
const wantsDir = /(?:\/|\/\*\*?)$/.test(raw.trim());
|
|
1186
|
+
const norm = normPrefix(raw);
|
|
1187
|
+
if (isRoot(norm)) return;
|
|
1188
|
+
const path = `${at}.params.${dotted}[${i}]`;
|
|
1189
|
+
const isFile = idx.files.has(norm);
|
|
1190
|
+
const isDir = idx.dirs.has(norm);
|
|
1191
|
+
if (isDir) return;
|
|
1192
|
+
if (isFile && wantsDir) {
|
|
1193
|
+
red('path-not-in-listing', path, `"${raw}" is spelled as a directory but the seed tree says ${norm} is a file`,
|
|
1194
|
+
{ declared: raw, resolved: norm, found: 'file', wanted: 'directory' });
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (isFile) return;
|
|
1198
|
+
red('path-not-in-listing', path, `"${raw}" matches nothing in the seed tree${nearby(norm, idx)} — a path is `
|
|
1199
|
+
+ 'read off the repository, never derived from what a file sounds like it should be called',
|
|
1200
|
+
{ declared: raw, resolved: norm, found: null, wanted: wantsDir ? 'directory' : 'any' });
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** the mechanical half of the gap: what DOES exist beside the invented path.
|
|
1206
|
+
* Named items, counts, capped — never a paragraph (F38/F39).
|
|
1207
|
+
* @param {string} norm @param {{files: Set<string>, dirs: Set<string>}} idx */
|
|
1208
|
+
function nearby(norm, idx) {
|
|
1209
|
+
const parent = norm.includes('/') ? norm.slice(0, norm.lastIndexOf('/')) : '';
|
|
1210
|
+
const under = [...idx.files, ...idx.dirs]
|
|
1211
|
+
.filter((f) => (parent === '' ? !f.includes('/') : f.startsWith(`${parent}/`) && !f.slice(parent.length + 1).includes('/')))
|
|
1212
|
+
.sort();
|
|
1213
|
+
if (under.length === 0) return '';
|
|
1214
|
+
const shown = under.slice(0, 8);
|
|
1215
|
+
const tail = under.length > shown.length ? `, and ${under.length - shown.length} more` : '';
|
|
1216
|
+
return ` (under ${parent === '' ? 'the repository root' : parent} the seed tree has: ${shown.join(', ')}${tail})`;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* THE D5 LINE. Every injected guard must be PRESENT, of the right kind, and
|
|
1221
|
+
* byte-equal to the genre's own parameters. Only the guard's declared `fill`
|
|
1222
|
+
* slots are the model's, and a parameter the guard does not carry at all is a
|
|
1223
|
+
* NARROWING — which is how round 3's arm A scoped the suppression scan to two
|
|
1224
|
+
* files and made a weaker guard look stricter.
|
|
1225
|
+
* @param {{declaration: any, guards: any[], red: (c: string, p: string, d: string, e?: object) => void}} o
|
|
1226
|
+
*/
|
|
1227
|
+
function checkGuards({ declaration, guards, red }) {
|
|
1228
|
+
const stages = Array.isArray(declaration?.stages) ? declaration.stages : [];
|
|
1229
|
+
for (const g of guards) {
|
|
1230
|
+
const i = stages.findIndex((/** @type {any} */ s) => isObj(s) && s.name === g.name);
|
|
1231
|
+
if (i === -1) {
|
|
1232
|
+
red('guard-missing', 'stages', `the genre's "${g.name}" guard is not in the declaration — mandatory guards are `
|
|
1233
|
+
+ 'shown to the user and cannot be removed (D5)', { guard: g.name, kind: g.kind });
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
const s = stages[i];
|
|
1237
|
+
const at = `stages[${i}]`;
|
|
1238
|
+
if (s.kind !== g.kind) {
|
|
1239
|
+
red('guard-weakened', `${at}.kind`, `the "${g.name}" guard is a ${g.kind}; this declares ${String(s.kind)}`,
|
|
1240
|
+
{ guard: g.name, param: 'kind' });
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
const params = isObj(s.params) ? s.params : {};
|
|
1244
|
+
for (const [k, want] of Object.entries(g.params)) {
|
|
1245
|
+
if (!deepEqual(params[k], want)) {
|
|
1246
|
+
red('guard-weakened', `${at}.params.${k}`, `the "${g.name}" guard's ${k} is genre property and is fixed: `
|
|
1247
|
+
+ `expected ${JSON.stringify(want)}`, { guard: g.name, param: k, expected: want, declared: params[k] });
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
for (const k of Object.keys(params)) {
|
|
1251
|
+
if (Object.hasOwn(g.params, k) || g.fill.includes(k)) continue;
|
|
1252
|
+
red('guard-weakened', `${at}.params.${k}`, `the "${g.name}" guard does not take ${k} — adding one narrows what `
|
|
1253
|
+
+ 'the guard covers, and a guard that covers less is not a stricter guard', { guard: g.name, param: k, declared: params[k] });
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** structural equality over plain JSON data (the shapes a declaration carries)
|
|
1259
|
+
* @param {any} a @param {any} b */
|
|
1260
|
+
function deepEqual(a, b) {
|
|
1261
|
+
if (a === b) return true;
|
|
1262
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
1263
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
1264
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
1265
|
+
}
|
|
1266
|
+
if (isObj(a) && isObj(b)) {
|
|
1267
|
+
const ka = Object.keys(a);
|
|
1268
|
+
const kb = Object.keys(b);
|
|
1269
|
+
if (ka.length !== kb.length) return false;
|
|
1270
|
+
return ka.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
|
|
1271
|
+
}
|
|
1272
|
+
return false;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* The declaration the EXECUTOR sees and the SIGNATURE covers: every
|
|
1277
|
+
* `count-not-worse` parser resolved to `{terms: [...]}`, so the short form stops
|
|
1278
|
+
* existing at this boundary and there is exactly one shape downstream (D13
|
|
1279
|
+
* forward-compat point 3 — the hash is taken over the RESOLVED form, or an
|
|
1280
|
+
* omittable-with-a-default field changes what runs without changing what was
|
|
1281
|
+
* signed).
|
|
1282
|
+
*
|
|
1283
|
+
* A parser that would not normalise is left VERBATIM: it already carries a red,
|
|
1284
|
+
* and rewriting it would hide what the model actually wrote.
|
|
1285
|
+
* @param {any} declaration
|
|
1286
|
+
*/
|
|
1287
|
+
export function normalizeDeclaration(declaration) {
|
|
1288
|
+
if (!isObj(declaration) || !Array.isArray(declaration.stages)) return declaration;
|
|
1289
|
+
const out = structuredClone(declaration);
|
|
1290
|
+
for (const s of out.stages) {
|
|
1291
|
+
if (!isObj(s) || s.kind !== 'count-not-worse' || !isObj(s.params)) continue;
|
|
1292
|
+
const n = normalizeParser(s.params.parser, 'parser');
|
|
1293
|
+
if (n.ok) s.params.parser = { terms: n.terms };
|
|
1294
|
+
}
|
|
1295
|
+
return out;
|
|
1296
|
+
}
|