@feigi/fleet-ctl 3.81.1 → 3.81.3
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/package.json
CHANGED
package/scripts/ledger.mjs
CHANGED
|
@@ -2337,11 +2337,6 @@ async function runSettleFromReport() {
|
|
|
2337
2337
|
const report = read.report;
|
|
2338
2338
|
const invalid = (violation) => die(`${where}: ${read.path} is not a ${reportFamily(member)} report — ${violation}; nothing written`);
|
|
2339
2339
|
if (report.member !== member) invalid(`$.member: ${JSON.stringify(report.member)} is not ${member}`);
|
|
2340
|
-
// An issue number is a positive integer. A negative id writes a row `#-3 …`
|
|
2341
|
-
// that `filed` refuses and `check`'s subjectOf() cannot strip.
|
|
2342
|
-
for (const [k, { issue }] of (report.unrecorded ?? []).entries()) {
|
|
2343
|
-
if (!Number.isInteger(issue) || issue < 1) invalid(`$.unrecorded[${k}].issue: ${JSON.stringify(issue)} is not an issue number`);
|
|
2344
|
-
}
|
|
2345
2340
|
|
|
2346
2341
|
// Every child runs from the ledger's repository with runCheck()'s
|
|
2347
2342
|
// environment, for runCheck()'s reasons: GH_REPO would point gh at another
|
|
@@ -2411,7 +2406,7 @@ async function runSettleFromReport() {
|
|
|
2411
2406
|
let outcome;
|
|
2412
2407
|
if (live.family === "impl") {
|
|
2413
2408
|
if (report.outcome === "pr") {
|
|
2414
|
-
if (report.pr === null
|
|
2409
|
+
if (report.pr === null) invalid('$.pr: null is not a PR number, with outcome "pr"');
|
|
2415
2410
|
if (report.head === null) invalid('$.head: null, with outcome "pr"');
|
|
2416
2411
|
outcome = `PR#${report.pr}`;
|
|
2417
2412
|
const sha = look(`verify-sha.sh ${report.branch} ${report.head}`, "sh", [verifySha, report.branch, report.head], [0, 1]);
|
|
@@ -446,10 +446,17 @@ export function describe(check, live = null) {
|
|
|
446
446
|
switch (check.state) {
|
|
447
447
|
case "clean":
|
|
448
448
|
return null;
|
|
449
|
-
case "dirty":
|
|
450
|
-
// The HEAD entry is text, not a path: quoting it would blur it
|
|
449
|
+
case "dirty": {
|
|
450
|
+
// The HEAD entry is text, not a path: quoting it would blur it, and
|
|
451
|
+
// ignoring cannot clear it, so only a path entry names the ignore remedy.
|
|
452
|
+
// An exclude hides untracked paths alone, and changedPaths reads a
|
|
453
|
+
// baseline entry that vanishes as changed, so the remedy is limited to
|
|
454
|
+
// untracked paths new since the baseline.
|
|
455
|
+
const hasPath = check.changed.some((p) => p !== check.headMove);
|
|
451
456
|
return `MAIN-CHECKOUT-DIRTY ${check.changed.map((p) => (p === check.headMove ? p : shown(p))).join(" ")} — changed since the run's baseline${members}`
|
|
452
|
-
+ ` — dispatch held: resolve the stray paths FIRST, then re-baseline with ${RECORD_COMMAND} (it overwrites, it does not compare)
|
|
457
|
+
+ ` — dispatch held: resolve the stray paths FIRST, then re-baseline with ${RECORD_COMMAND} (it overwrites, it does not compare)`
|
|
458
|
+
+ (hasPath ? " — or, for an untracked path new since the baseline, the maintainer may instead ignore it in .git/info/exclude or the global excludes file: it clears on the next check with no re-baseline, so long as the pattern matches no path the baseline holds; ignoring never clears a tracked or staged file's change" : "");
|
|
459
|
+
}
|
|
453
460
|
case "absent":
|
|
454
461
|
return `MAIN-CHECKOUT-NO-BASELINE no baseline at ${check.baseline} — dispatch held: record it at run start with ${RECORD_COMMAND}`;
|
|
455
462
|
default:
|
package/scripts/report-files.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { readdirSync, readFileSync } from "node:fs";
|
|
16
16
|
import { join } from "node:path";
|
|
17
|
-
import { SCHEMAS, validate } from "./report-schema.mjs";
|
|
17
|
+
import { SCHEMAS, parseReport, validate } from "./report-schema.mjs";
|
|
18
18
|
import { parseMember } from "./ledger-grammar.mjs";
|
|
19
19
|
import { scratchRoot } from "./fleet-dir.mjs";
|
|
20
20
|
|
|
@@ -53,14 +53,9 @@ export function readReportIn(dir, member) {
|
|
|
53
53
|
if (e.code === "ENOENT" || e.code === "ENOTDIR") return { state: "absent", path };
|
|
54
54
|
return { state: "invalid", path, violation: `$: unreadable (${e.code ?? e.message})` };
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
} catch (e) {
|
|
60
|
-
return { state: "invalid", path, violation: `$: not JSON (${e.message})` };
|
|
61
|
-
}
|
|
62
|
-
const violation = validate(SCHEMAS[family], report);
|
|
63
|
-
return violation === null ? { state: "ok", path, report } : { state: "invalid", path, violation };
|
|
56
|
+
const parsed = parseReport(text);
|
|
57
|
+
const violation = parsed.violation ?? validate(SCHEMAS[family], parsed.value);
|
|
58
|
+
return violation === null ? { state: "ok", path, report: parsed.value } : { state: "invalid", path, violation };
|
|
64
59
|
}
|
|
65
60
|
|
|
66
61
|
/** `ok`, `invalid`, `absent`, or `-` when no schema covers the member. */
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
//
|
|
18
18
|
// 0 print: the schema is on stdout. validate: `valid` on stdout
|
|
19
19
|
// 1 validate: `invalid: <violation>` on stdout, naming the FIRST violation;
|
|
20
|
-
// a file that is not JSON is invalid too, as `$: not JSON (...)
|
|
20
|
+
// a file that is not JSON is invalid too, as `$: not JSON (...)`, and so
|
|
21
|
+
// is one that repeats a key, as `$.merged: duplicate key "merged"`
|
|
21
22
|
// 2 usage: no or unknown subcommand, a wrong argument count, an unknown
|
|
22
23
|
// family, any flag; or <file> could not be read; or print could not
|
|
23
24
|
// write the schema to stdout. Refused on stderr, nothing on stdout
|
|
@@ -31,16 +32,21 @@
|
|
|
31
32
|
// their schema names.
|
|
32
33
|
//
|
|
33
34
|
// The validator below is hand-written and covers exactly the keywords these
|
|
34
|
-
// schemas use: `type` (one or a list
|
|
35
|
-
// `
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
35
|
+
// schemas use: `type` (one or a list), `enum`, `pattern`, `minimum` and
|
|
36
|
+
// `maximum` (numbers only: a value that is not a number, `null` included,
|
|
37
|
+
// passes them), `required`, `properties`, `additionalProperties: false` and
|
|
38
|
+
// `items`. A keyword it does not know is not checked, so a schema here that
|
|
39
|
+
// starts using one needs the validator to learn it first. An integer is
|
|
40
|
+
// bounded by the schema, not by `type`: ids by 1 and the largest safe
|
|
41
|
+
// integer, the other integers by the safe range, beyond which JSON.parse has
|
|
42
|
+
// already rounded the number. A duplicate key, compared decoded and at any
|
|
43
|
+
// depth, is a violation named before any schema check. The walk is in a
|
|
44
|
+
// fixed order, which is what makes "the first violation" one answer: at each
|
|
45
|
+
// value `type`, then `enum`, then `pattern`, then `minimum`, then `maximum`;
|
|
46
|
+
// in an object every `required` field in the schema's order, then any
|
|
47
|
+
// unexpected field in the report's order, then each property in the schema's
|
|
48
|
+
// order; in an array each item in turn. A violation is named by its path
|
|
49
|
+
// from the report's root, `$`, as `$.merged[1].sha`.
|
|
44
50
|
//
|
|
45
51
|
// Strict mode. A `task` call can carry this schema as `outputSchema` with
|
|
46
52
|
// `schemaMode: "strict"`. Measured 2026-10-14 under omp/18.10.3, and only on
|
|
@@ -85,7 +91,10 @@ const USAGE = "usage: report-schema.mjs print <family> | validate <family> <file
|
|
|
85
91
|
|
|
86
92
|
const HEX = "^[0-9a-f]{40}$";
|
|
87
93
|
const str = { type: "string" };
|
|
88
|
-
|
|
94
|
+
// An id is a positive safe integer; a negative one writes a ledger row `#-3 …` that `filed` refuses and `check`'s subjectOf() cannot strip.
|
|
95
|
+
const id = { type: "integer", minimum: 1, maximum: Number.MAX_SAFE_INTEGER };
|
|
96
|
+
// A position or a count: the safe range only, so a truthful negative `exit` stays valid.
|
|
97
|
+
const int = { type: "integer", minimum: -Number.MAX_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER };
|
|
89
98
|
const sha = { type: "string", pattern: HEX };
|
|
90
99
|
const strings = { type: "array", items: str };
|
|
91
100
|
|
|
@@ -96,13 +105,13 @@ const listOf = (properties) => ({ type: "array", items: obj(properties) });
|
|
|
96
105
|
// A family's top level: every field required, nothing else allowed.
|
|
97
106
|
const report = (properties) => ({ ...obj(properties), additionalProperties: false });
|
|
98
107
|
|
|
99
|
-
const issues = listOf({ issue:
|
|
108
|
+
const issues = listOf({ issue: id, subject: str });
|
|
100
109
|
|
|
101
110
|
export const SCHEMAS = {
|
|
102
111
|
impl: report({
|
|
103
112
|
member: str,
|
|
104
113
|
outcome: { type: "string", enum: ["pr", "bailed"] },
|
|
105
|
-
pr: { type: ["integer", "null"] },
|
|
114
|
+
pr: { ...id, type: ["integer", "null"] },
|
|
106
115
|
head: { type: ["string", "null"], pattern: HEX },
|
|
107
116
|
branch: str,
|
|
108
117
|
sizing: obj(
|
|
@@ -119,7 +128,7 @@ export const SCHEMAS = {
|
|
|
119
128
|
head: sha,
|
|
120
129
|
branch: str,
|
|
121
130
|
applied: listOf({ bucket: str, index: int }),
|
|
122
|
-
deferred: listOf({ bucket: str, index: int, issue:
|
|
131
|
+
deferred: listOf({ bucket: str, index: int, issue: id }),
|
|
123
132
|
filed: issues,
|
|
124
133
|
unrecorded: issues,
|
|
125
134
|
dispositionsRecord: { type: "string", pattern: "^/" },
|
|
@@ -141,11 +150,11 @@ export const SCHEMAS = {
|
|
|
141
150
|
}),
|
|
142
151
|
"merge-bot": report({
|
|
143
152
|
member: str,
|
|
144
|
-
merged: listOf({ pr:
|
|
145
|
-
held: listOf({ pr:
|
|
146
|
-
conflictHold: { type: "array", items:
|
|
147
|
-
refused: listOf({ pr:
|
|
148
|
-
branchKept: listOf({ pr:
|
|
153
|
+
merged: listOf({ pr: id, sha }),
|
|
154
|
+
held: listOf({ pr: id, behind: id }),
|
|
155
|
+
conflictHold: { type: "array", items: id },
|
|
156
|
+
refused: listOf({ pr: id, reason: str }),
|
|
157
|
+
branchKept: listOf({ pr: id, by: id }),
|
|
149
158
|
}),
|
|
150
159
|
};
|
|
151
160
|
|
|
@@ -172,6 +181,10 @@ export function validate(schema, value, path = "$") {
|
|
|
172
181
|
if (typeof value === "string" && schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) {
|
|
173
182
|
return `${path}: ${show(value)} does not match ${schema.pattern}`;
|
|
174
183
|
}
|
|
184
|
+
if (typeof value === "number") {
|
|
185
|
+
if (schema.minimum !== undefined && value < schema.minimum) return `${path}: ${show(value)} is below minimum ${schema.minimum}`;
|
|
186
|
+
if (schema.maximum !== undefined && value > schema.maximum) return `${path}: ${show(value)} is above maximum ${schema.maximum}`;
|
|
187
|
+
}
|
|
175
188
|
if (isObject(value)) {
|
|
176
189
|
for (const key of schema.required ?? []) {
|
|
177
190
|
if (!Object.hasOwn(value, key)) return `${path}: missing required field ${show(key)}`;
|
|
@@ -196,6 +209,57 @@ export function validate(schema, value, path = "$") {
|
|
|
196
209
|
return null;
|
|
197
210
|
}
|
|
198
211
|
|
|
212
|
+
// The first duplicate key of `text` in text order, as `<path>: duplicate key
|
|
213
|
+
// "<key>"`, or null. `text` is well-formed JSON, so nothing here can meet a
|
|
214
|
+
// malformed token. Keys are compared decoded, as JSON.parse collides them.
|
|
215
|
+
function firstDuplicateKey(text) {
|
|
216
|
+
// One frame per open container: an object's keys so far, an array's index.
|
|
217
|
+
const open = [];
|
|
218
|
+
let next = "$"; // the path of the value that starts next
|
|
219
|
+
let i = 0;
|
|
220
|
+
while (i < text.length) {
|
|
221
|
+
const c = text[i];
|
|
222
|
+
const top = open[open.length - 1];
|
|
223
|
+
if (c === "{") {
|
|
224
|
+
open.push({ path: next, keys: new Set(), wantKey: true });
|
|
225
|
+
} else if (c === "[") {
|
|
226
|
+
open.push({ path: next, index: 0 });
|
|
227
|
+
next = `${next}[0]`;
|
|
228
|
+
} else if (c === "}" || c === "]") {
|
|
229
|
+
open.pop();
|
|
230
|
+
} else if (c === ",") {
|
|
231
|
+
if (top.keys === undefined) next = `${top.path}[${++top.index}]`;
|
|
232
|
+
else top.wantKey = true;
|
|
233
|
+
} else if (c === '"') {
|
|
234
|
+
let end = i + 1;
|
|
235
|
+
while (text[end] !== '"') end += text[end] === "\\" ? 2 : 1;
|
|
236
|
+
if (top?.wantKey) {
|
|
237
|
+
const key = JSON.parse(text.slice(i, end + 1));
|
|
238
|
+
next = `${top.path}.${key}`;
|
|
239
|
+
if (top.keys.has(key)) return `${next}: duplicate key ${show(key)}`;
|
|
240
|
+
top.keys.add(key);
|
|
241
|
+
top.wantKey = false;
|
|
242
|
+
}
|
|
243
|
+
i = end;
|
|
244
|
+
}
|
|
245
|
+
i++;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** `text` read as a report: `{ value }`, or `{ violation }` for text that is
|
|
251
|
+
* not JSON or that repeats a key in any object at any depth. */
|
|
252
|
+
export function parseReport(text) {
|
|
253
|
+
let value;
|
|
254
|
+
try {
|
|
255
|
+
value = JSON.parse(text);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
return { violation: `$: not JSON (${e.message})` };
|
|
258
|
+
}
|
|
259
|
+
const violation = firstDuplicateKey(text);
|
|
260
|
+
return violation === null ? { value } : { violation };
|
|
261
|
+
}
|
|
262
|
+
|
|
199
263
|
function familyOf(name) {
|
|
200
264
|
if (!Object.hasOwn(SCHEMAS, name)) die(`unknown family '${name}' — known: ${Object.keys(SCHEMAS).join(", ")}`);
|
|
201
265
|
return SCHEMAS[name];
|
|
@@ -222,14 +286,8 @@ function main() {
|
|
|
222
286
|
} catch (e) {
|
|
223
287
|
die(`cannot read ${path}: ${e.code ?? e.message}`);
|
|
224
288
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
value = JSON.parse(text);
|
|
228
|
-
} catch (e) {
|
|
229
|
-
writeAll(1, `invalid: $: not JSON (${e.message})\n`);
|
|
230
|
-
process.exit(1);
|
|
231
|
-
}
|
|
232
|
-
const violation = validate(schema, value);
|
|
289
|
+
const parsed = parseReport(text);
|
|
290
|
+
const violation = parsed.violation ?? validate(schema, parsed.value);
|
|
233
291
|
if (violation !== null) {
|
|
234
292
|
writeAll(1, `invalid: ${violation}\n`);
|
|
235
293
|
process.exit(1);
|
|
@@ -75,6 +75,7 @@ older-version baseline and a baseline whose commit is missing from the
|
|
|
75
75
|
repository; Phase 3's tick lines say how each is cleared.
|
|
76
76
|
`main-checkout.mjs --check` prints the same answer
|
|
77
77
|
without a tick.
|
|
78
|
+
On `MAIN-CHECKOUT-DIRTY` the maintainer may instead ignore a stray path that is untracked and new since the baseline: an entry in `.git/info/exclude` or in the global excludes file hides it from the check, and the hold clears on the next tick with no re-record. Ignoring never clears a tracked or staged file's change, which `git status` reports whatever the excludes say, nor a pattern that also matches a path the baseline holds, which then reads as changed. Ignoring is the maintainer's decision, never the controller's: the controller never writes an exclude or a `.gitignore` entry. Keep the pattern narrow, because an ignored path is invisible to this check, so a later member write that matches it is never seen.
|
|
78
79
|
|
|
79
80
|
## Run the pre-flight
|
|
80
81
|
|
|
@@ -129,6 +129,7 @@ The full text of each row `fleet-tick.mjs` prints and what it asks of the contro
|
|
|
129
129
|
checkout and the close-out commit's round trip through a branch; **Fix the
|
|
130
130
|
tooling mid-run** and the commit section of references/close-out.md say how
|
|
131
131
|
each is cleared.
|
|
132
|
+
The maintainer may instead ignore a stray path that is untracked and new since the baseline: an entry in `.git/info/exclude` or in the global excludes file hides it from the check, and the hold clears on the next tick with no re-baseline. Ignoring never clears a tracked or staged file's change, which `git status` reports whatever the excludes say, nor a pattern that also matches a path the baseline holds, which then reads as changed. Ignoring is the maintainer's decision; the controller never writes an exclude or a `.gitignore` entry. Keep the pattern narrow, because an ignored path is invisible to this check, so a member write that matches it is never seen.
|
|
132
133
|
- `MAIN-CHECKOUT-UNKNOWN …` with `HOLD (main checkout unknown)` — the tick
|
|
133
134
|
could not look: a git or hash read failed (`could not look:`), or the
|
|
134
135
|
baseline exists but cannot be read (`baseline unreadable:`). Never clean,
|