|
| 1 | +#!/usr/bin/env node |
| 2 | +// #9499: every `agent-regate-pr` producer must carry `prCreatedAt`. |
| 3 | +// |
| 4 | +// `jobClaimSortKey` (src/selfhost/queue-common.ts) sorts regate jobs by the PR's own `createdAt` ascending — |
| 5 | +// the ONE real oldest-first ordering mechanism the queue has. A producer that omits `prCreatedAt` falls back |
| 6 | +// to `LEGACY_AGENT_REGATE_SORT_BASE_MS + prNumber` (~9.5e11), which sorts AHEAD of every genuinely older 2026 |
| 7 | +// PR (~1.78e12). So an omission does not degrade the ordering — it INVERTS it, silently, for that producer's |
| 8 | +// jobs, and five of eight producers had done exactly that. |
| 9 | +// |
| 10 | +// A type-level guard cannot express this: `prCreatedAt` is legitimately optional on `JobMessage` (a producer |
| 11 | +// that truly has no PR record must still be able to enqueue), so making it required would break the |
| 12 | +// deliberate exceptions rather than catch the accidental ones. This check reads the producer sites instead |
| 13 | +// and requires each to either pass the field or be explicitly allowlisted with a reason — the same |
| 14 | +// "an exception must be stated, not inferred from absence" shape as check-dead-source-files.ts's entry points. |
| 15 | +import { readFileSync, readdirSync } from "node:fs"; |
| 16 | +import { fileURLToPath } from "node:url"; |
| 17 | + |
| 18 | +const SCAN_ROOTS = ["src"] as const; |
| 19 | +const SOURCE_PATTERN = /(?<!\.d)\.ts$/; |
| 20 | +const EXCLUDED_SEGMENT = /(?:^|\/)(?:node_modules|dist|dist-test)(?:\/|$)/; |
| 21 | + |
| 22 | +/** Hard ceiling on how far a producer's object literal may be scanned, purely so a malformed/unbalanced file |
| 23 | + * cannot make this walk the rest of the module. The real bound is the literal's own closing brace — see |
| 24 | + * {@link producerObjectText}. */ |
| 25 | +const PRODUCER_SCAN_CEILING_LINES = 60; |
| 26 | + |
| 27 | +/** |
| 28 | + * Producers that deliberately omit `prCreatedAt`, each with the reason. Keyed `file:marker`, where the marker |
| 29 | + * is a distinctive substring of the producer's own `deliveryId` so the entry survives line-number churn. |
| 30 | + */ |
| 31 | +const ALLOWED_OMISSIONS: ReadonlyMap<string, string> = new Map([ |
| 32 | + [ |
| 33 | + "src/api/routes.ts:manual-regate:", |
| 34 | + "The maintainer-triggered manual re-gate route enqueues at priority 99 to jump the queue ON PURPOSE — an operator asking for one PR now is exactly the case oldest-first should not apply to. It also has no PR record in hand (the body carries only repoFullName + prNumber).", |
| 35 | + ], |
| 36 | +]); |
| 37 | + |
| 38 | +export type RegateSortKeyViolation = { file: string; line: number; snippet: string }; |
| 39 | + |
| 40 | +function defaultListSourceFiles(root: string): string[] { |
| 41 | + try { |
| 42 | + return readdirSync(root, { recursive: true }) |
| 43 | + .map(String) |
| 44 | + .filter((entry) => SOURCE_PATTERN.test(entry) && !EXCLUDED_SEGMENT.test(entry)) |
| 45 | + .map((entry) => `${root}/${entry}`); |
| 46 | + } catch { |
| 47 | + return []; |
| 48 | + } |
| 49 | +} |
| 50 | + |
| 51 | +/** |
| 52 | + * Pure over its inputs: finds every `type: "agent-regate-pr"` producer whose enqueued object does not carry |
| 53 | + * `prCreatedAt` within the following {@link PRODUCER_WINDOW_LINES} lines, minus the allowlisted exceptions. |
| 54 | + * `listSourceFiles`/`readFile` are injectable so tests can simulate a fresh offender without touching the tree. |
| 55 | + */ |
| 56 | +export function findRegateSortKeyViolations( |
| 57 | + options: { |
| 58 | + roots?: readonly string[]; |
| 59 | + listSourceFiles?: (root: string) => string[]; |
| 60 | + readFile?: (file: string) => string; |
| 61 | + allowedOmissions?: ReadonlyMap<string, string>; |
| 62 | + } = {}, |
| 63 | +): RegateSortKeyViolation[] { |
| 64 | + const { |
| 65 | + roots = SCAN_ROOTS, |
| 66 | + listSourceFiles = defaultListSourceFiles, |
| 67 | + readFile = (file: string) => readFileSync(file, "utf8"), |
| 68 | + allowedOmissions = ALLOWED_OMISSIONS, |
| 69 | + } = options; |
| 70 | + |
| 71 | + const violations: RegateSortKeyViolation[] = []; |
| 72 | + for (const root of roots) { |
| 73 | + for (const file of listSourceFiles(root)) { |
| 74 | + const lines = readFile(file).split("\n"); |
| 75 | + for (const [index, line] of lines.entries()) { |
| 76 | + if (!line.includes('type: "agent-regate-pr"')) continue; |
| 77 | + const window = producerObjectText(lines, index); |
| 78 | + if (window.includes("prCreatedAt")) continue; |
| 79 | + const allowed = [...allowedOmissions.keys()].some((key) => { |
| 80 | + const [allowedFile, marker] = splitAllowKey(key); |
| 81 | + return allowedFile === file && marker !== "" && window.includes(marker); |
| 82 | + }); |
| 83 | + if (allowed) continue; |
| 84 | + violations.push({ file, line: index + 1, snippet: line.trim() }); |
| 85 | + } |
| 86 | + } |
| 87 | + } |
| 88 | + return violations.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file))); |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * The text of the object literal that OWNS the `type: "agent-regate-pr"` line at `startIndex`, bounded by that |
| 93 | + * literal's own closing brace rather than a fixed line count. |
| 94 | + * |
| 95 | + * A fixed window is subtly wrong here and produced a real false negative while this check was being written: |
| 96 | + * two producers sitting within a few lines of each other let the FIRST one's `prCreatedAt` satisfy the scan |
| 97 | + * for the SECOND one's, so removing a field from one of them was not caught. Tracking brace depth means each |
| 98 | + * producer is judged on its own literal and nothing else. |
| 99 | + */ |
| 100 | +function producerObjectText(lines: readonly string[], startIndex: number): string { |
| 101 | + const collected: string[] = []; |
| 102 | + let depth = 0; |
| 103 | + for (let i = startIndex; i < Math.min(lines.length, startIndex + PRODUCER_SCAN_CEILING_LINES); i += 1) { |
| 104 | + const line = lines[i] ?? ""; |
| 105 | + collected.push(line); |
| 106 | + for (const char of line) { |
| 107 | + if (char === "{") depth += 1; |
| 108 | + else if (char === "}") depth -= 1; |
| 109 | + } |
| 110 | + // Depth goes negative at the `}` that closes the literal this `type:` line sits inside — that line is the |
| 111 | + // last one belonging to this producer. |
| 112 | + if (depth < 0) break; |
| 113 | + } |
| 114 | + return collected.join("\n"); |
| 115 | +} |
| 116 | + |
| 117 | +/** Split `path/to/file.ts:marker-text` on the LAST colon that precedes the marker — a marker may itself |
| 118 | + * contain colons (`manual-regate:`), so a naive split on the first or last colon gets it wrong. */ |
| 119 | +function splitAllowKey(key: string): [string, string] { |
| 120 | + const boundary = key.indexOf(".ts:"); |
| 121 | + if (boundary === -1) return [key, ""]; |
| 122 | + return [key.slice(0, boundary + ".ts".length), key.slice(boundary + ".ts:".length)]; |
| 123 | +} |
| 124 | + |
| 125 | +function main(): void { |
| 126 | + const violations = findRegateSortKeyViolations(); |
| 127 | + if (violations.length === 0) { |
| 128 | + process.stdout.write("agent-regate-pr sort keys: OK\n"); |
| 129 | + return; |
| 130 | + } |
| 131 | + process.stderr.write(`Found ${violations.length} agent-regate-pr producer(s) missing prCreatedAt (#9499):\n`); |
| 132 | + for (const violation of violations) { |
| 133 | + process.stderr.write(` ${violation.file}:${violation.line} — ${violation.snippet}\n`); |
| 134 | + } |
| 135 | + process.stderr.write( |
| 136 | + "\nAn omitted prCreatedAt does not merely lose the ordering — it INVERTS it: jobClaimSortKey falls back to\n" + |
| 137 | + "LEGACY_AGENT_REGATE_SORT_BASE_MS + prNumber (~9.5e11), which sorts ahead of every real 2026 PR (~1.78e12).\n" + |
| 138 | + "Pass the PR's createdAt, or — if the producer genuinely must jump the queue — add it to ALLOWED_OMISSIONS\n" + |
| 139 | + "in scripts/check-regate-sort-key.ts with the reason.\n", |
| 140 | + ); |
| 141 | + process.exit(1); |
| 142 | +} |
| 143 | + |
| 144 | +if (process.argv[1] === fileURLToPath(import.meta.url)) main(); |
0 commit comments