@feigi/fleet-ctl 3.81.0 → 3.81.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.81.0",
3
+ "version": "3.81.2",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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 || report.pr < 1) invalid(`$.pr: ${JSON.stringify(report.pr)} is not a PR number, with outcome "pr"`);
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]);
@@ -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
- let report;
57
- try {
58
- report = JSON.parse(text);
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; `integer` is a whole number), `enum`,
35
- // `required`, `properties`, `additionalProperties: false`, `items` and
36
- // `pattern`. A keyword it does not know is not checked, so a schema here
37
- // that starts using one needs the validator to learn it first. It walks in a
38
- // fixed order, which is what makes "the first violation" one answer: at
39
- // each value `type`, then `enum`, then `pattern`; in an object every
40
- // `required` field in the schema's order, then any unexpected field in the
41
- // report's order, then each property in the schema's order; in an array each
42
- // item in turn. A violation is named by its path from the report's root, `$`,
43
- // as `$.merged[1].sha`.
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
- const int = { type: "integer" };
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: int, subject: str });
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: int }),
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: int, sha }),
145
- held: listOf({ pr: int, behind: int }),
146
- conflictHold: { type: "array", items: int },
147
- refused: listOf({ pr: int, reason: str }),
148
- branchKept: listOf({ pr: int, by: int }),
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
- let value;
226
- try {
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);