squadrant 0.19.1 → 0.19.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/dist/index.js +498 -120
- package/dist/index.js.map +1 -1
- package/dist/squadrantd.js +525 -100
- package/dist/squadrantd.js.map +1 -1
- package/package.json +1 -1
- package/scripts/control-event-table.mjs +227 -0
package/package.json
CHANGED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generates docs/generated/control-events.md: every ControlEvent variant vs its
|
|
3
|
+
// producers (emit sites) and consumers (state-machine case, reduce.ts allowlist,
|
|
4
|
+
// telegram formatter), plus registered LifecycleSource count vs CLAUDE.md's claim.
|
|
5
|
+
//
|
|
6
|
+
// `--check` regenerates the table in memory and exits non-zero if it differs
|
|
7
|
+
// from what's on disk, OR if a ControlEvent variant has zero shipped producers
|
|
8
|
+
// and is not in KNOWN_ZERO_PRODUCER below. That's what makes a new zombie event
|
|
9
|
+
// or doc drift fail `pnpm test` instead of sitting unnoticed.
|
|
10
|
+
//
|
|
11
|
+
// Plain Node, no dependencies. See docs/specs/2026-08-29-event-architecture-design.md §1.5, §11.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs";
|
|
14
|
+
import { join, relative } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
18
|
+
const CONTROL_TYPES_FILE = join(ROOT, "packages/shared/src/types/control.ts");
|
|
19
|
+
const STATE_MACHINE_FILE = join(ROOT, "packages/core/src/state-machine.ts");
|
|
20
|
+
const REDUCE_FILE = join(ROOT, "packages/core/src/daemon/reduce.ts");
|
|
21
|
+
const TELEGRAM_FORMAT_FILE = join(ROOT, "packages/core/src/telegram/format.ts");
|
|
22
|
+
const SQUADRANTD_FILE = join(ROOT, "packages/cli/src/squadrantd.ts");
|
|
23
|
+
const CLAUDE_MD_FILE = join(ROOT, "CLAUDE.md");
|
|
24
|
+
const OUT_FILE = join(ROOT, "docs/generated/control-events.md");
|
|
25
|
+
const PACKAGES_DIR = join(ROOT, "packages");
|
|
26
|
+
|
|
27
|
+
// Variants verified (2026-08-29 event-architecture design, §1.5) to have a
|
|
28
|
+
// state-machine case, a reduce.ts allowlist entry, and (for task.idle) a
|
|
29
|
+
// telegram formatter — but zero shipped producers. Removing an entry here
|
|
30
|
+
// without giving it a real producer is a lie; adding one back is a real
|
|
31
|
+
// regression. Either way this list must be a deliberate edit, not silent.
|
|
32
|
+
const KNOWN_ZERO_PRODUCER = ["heartbeat", "task.idle", "task.reconcile-failed"];
|
|
33
|
+
|
|
34
|
+
function escapeRe(s) {
|
|
35
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function walkSourceFiles(dir, out = []) {
|
|
39
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
40
|
+
if (entry.name === "__tests__" || entry.name === "dist" || entry.name === "node_modules") continue;
|
|
41
|
+
const full = join(dir, entry.name);
|
|
42
|
+
if (entry.isDirectory()) walkSourceFiles(full, out);
|
|
43
|
+
else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx"))) out.push(full);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractVariants(controlTypesText) {
|
|
49
|
+
const variants = [];
|
|
50
|
+
const re = /^\s*\|\s*\{\s*type:\s*"([^"]+)"/gm;
|
|
51
|
+
let m;
|
|
52
|
+
while ((m = re.exec(controlTypesText))) variants.push(m[1]);
|
|
53
|
+
return variants;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Producer = a line constructing `{ type: "<variant>" ... }` as an object
|
|
57
|
+
// literal (an emit site). Excludes the union definition itself and
|
|
58
|
+
// `Extract<ControlEvent, { type: "..." }>` type-level narrowing, which reads
|
|
59
|
+
// like construction but declares nothing.
|
|
60
|
+
function findProducers(files, variant) {
|
|
61
|
+
const re = new RegExp(`type:\\s*["']${escapeRe(variant)}["']`);
|
|
62
|
+
const hits = [];
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
if (file === CONTROL_TYPES_FILE) continue;
|
|
65
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
const line = lines[i];
|
|
68
|
+
if (line.includes("Extract<")) continue;
|
|
69
|
+
if (re.test(line)) hits.push(`${relative(ROOT, file)}:${i + 1}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return hits;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hasCase(text, variant) {
|
|
76
|
+
return new RegExp(`case\\s+["']${escapeRe(variant)}["']\\s*:`).test(text);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extractKnownEventTypes(reduceText) {
|
|
80
|
+
const m = reduceText.match(/KNOWN_EVENT_TYPES[^=]*=\s*new Set\(\[([\s\S]*?)\]\)/);
|
|
81
|
+
if (!m) throw new Error("KNOWN_EVENT_TYPES not found in reduce.ts");
|
|
82
|
+
return new Set([...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function extractLifecycleSources(squadrantdText) {
|
|
86
|
+
const block = squadrantdText.match(/ctx\.lifecycleSources\s*=\s*\[([\s\S]*?)\];/);
|
|
87
|
+
if (!block) throw new Error("ctx.lifecycleSources assignment not found in squadrantd.ts");
|
|
88
|
+
const varNames = [...block[1].matchAll(/(\w+)/g)].map((x) => x[1]);
|
|
89
|
+
return varNames.map((varName) => {
|
|
90
|
+
const decl = squadrantdText.match(new RegExp(`const\\s+${escapeRe(varName)}\\s*=\\s*new\\s+(\\w+)\\(`));
|
|
91
|
+
return decl ? decl[1] : varName;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractClaudeMdClaim(claudeMdText) {
|
|
96
|
+
const m = claudeMdText.match(/(\d+)\s+`LifecycleSource`\s+implementations/);
|
|
97
|
+
return m ? Number(m[1]) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildTable() {
|
|
101
|
+
const controlTypesText = readFileSync(CONTROL_TYPES_FILE, "utf8");
|
|
102
|
+
const stateMachineText = readFileSync(STATE_MACHINE_FILE, "utf8");
|
|
103
|
+
const reduceText = readFileSync(REDUCE_FILE, "utf8");
|
|
104
|
+
const telegramText = readFileSync(TELEGRAM_FORMAT_FILE, "utf8");
|
|
105
|
+
const squadrantdText = readFileSync(SQUADRANTD_FILE, "utf8");
|
|
106
|
+
const claudeMdText = existsSync(CLAUDE_MD_FILE) ? readFileSync(CLAUDE_MD_FILE, "utf8") : "";
|
|
107
|
+
|
|
108
|
+
const variants = extractVariants(controlTypesText);
|
|
109
|
+
const sourceFiles = walkSourceFiles(PACKAGES_DIR);
|
|
110
|
+
const knownEventTypes = extractKnownEventTypes(reduceText);
|
|
111
|
+
|
|
112
|
+
const rows = variants.map((variant) => {
|
|
113
|
+
const producers = findProducers(sourceFiles, variant);
|
|
114
|
+
const zombie = producers.length === 0;
|
|
115
|
+
const allowedZombie = KNOWN_ZERO_PRODUCER.includes(variant);
|
|
116
|
+
return {
|
|
117
|
+
variant,
|
|
118
|
+
producers,
|
|
119
|
+
stateMachine: hasCase(stateMachineText, variant),
|
|
120
|
+
reduceAllowlist: knownEventTypes.has(variant),
|
|
121
|
+
telegramFormatter: hasCase(telegramText, variant),
|
|
122
|
+
zombie,
|
|
123
|
+
allowedZombie,
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const unauthorizedZombies = rows.filter((r) => r.zombie && !r.allowedZombie).map((r) => r.variant);
|
|
128
|
+
const staleAllowlistEntries = KNOWN_ZERO_PRODUCER.filter(
|
|
129
|
+
(v) => !rows.some((r) => r.variant === v && r.zombie),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const registeredSources = extractLifecycleSources(squadrantdText);
|
|
133
|
+
const claudeMdClaim = extractClaudeMdClaim(claudeMdText);
|
|
134
|
+
|
|
135
|
+
const lines = [];
|
|
136
|
+
lines.push("<!-- GENERATED by scripts/control-event-table.mjs — do not hand-edit. -->");
|
|
137
|
+
lines.push("<!-- Regenerate: node scripts/control-event-table.mjs -->");
|
|
138
|
+
lines.push("");
|
|
139
|
+
lines.push("# ControlEvent producer/consumer table");
|
|
140
|
+
lines.push("");
|
|
141
|
+
lines.push(
|
|
142
|
+
"Every `ControlEvent` variant (`packages/shared/src/types/control.ts`) against its shipped " +
|
|
143
|
+
"producers and consumers. ⚠ marks a variant with zero shipped producers — see " +
|
|
144
|
+
"`docs/specs/2026-08-29-event-architecture-design.md` §1.5 / §11.",
|
|
145
|
+
);
|
|
146
|
+
lines.push("");
|
|
147
|
+
lines.push("| Variant | Producers | state-machine.ts | reduce.ts allowlist | telegram formatter |");
|
|
148
|
+
lines.push("|---|---|---|---|---|");
|
|
149
|
+
for (const r of rows) {
|
|
150
|
+
const variantCell = r.zombie ? `⚠ \`${r.variant}\`` : `\`${r.variant}\``;
|
|
151
|
+
const producerCell = r.zombie ? "0 (known zombie)" : String(r.producers.length);
|
|
152
|
+
lines.push(
|
|
153
|
+
`| ${variantCell} | ${producerCell} | ${r.stateMachine ? "✓" : "—"} | ${
|
|
154
|
+
r.reduceAllowlist ? "✓" : "—"
|
|
155
|
+
} | ${r.telegramFormatter ? "✓" : "—"} |`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
lines.push("");
|
|
159
|
+
lines.push("## Producer sites");
|
|
160
|
+
lines.push("");
|
|
161
|
+
for (const r of rows) {
|
|
162
|
+
if (r.producers.length === 0) continue;
|
|
163
|
+
lines.push(`- \`${r.variant}\``);
|
|
164
|
+
for (const hit of r.producers) lines.push(` - ${hit}`);
|
|
165
|
+
}
|
|
166
|
+
lines.push("");
|
|
167
|
+
lines.push("## Known zero-producer variants (allowlisted)");
|
|
168
|
+
lines.push("");
|
|
169
|
+
if (KNOWN_ZERO_PRODUCER.length === 0) {
|
|
170
|
+
lines.push("(none)");
|
|
171
|
+
} else {
|
|
172
|
+
for (const v of KNOWN_ZERO_PRODUCER) lines.push(`- \`${v}\``);
|
|
173
|
+
}
|
|
174
|
+
lines.push("");
|
|
175
|
+
lines.push("## LifecycleSource implementations");
|
|
176
|
+
lines.push("");
|
|
177
|
+
lines.push(`Registered in \`packages/cli/src/squadrantd.ts\` (\`ctx.lifecycleSources\`): **${registeredSources.length}**`);
|
|
178
|
+
lines.push("");
|
|
179
|
+
for (const cls of registeredSources) lines.push(`- \`${cls}\``);
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push(
|
|
182
|
+
`CLAUDE.md claims: **${claudeMdClaim === null ? "not found" : claudeMdClaim}** ${
|
|
183
|
+
claudeMdClaim === registeredSources.length ? "(matches ✓)" : "(⚠ DRIFT — does not match)"
|
|
184
|
+
}`,
|
|
185
|
+
);
|
|
186
|
+
lines.push("");
|
|
187
|
+
|
|
188
|
+
const content = lines.join("\n");
|
|
189
|
+
return { content, unauthorizedZombies, staleAllowlistEntries, registeredSources, claudeMdClaim };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function main() {
|
|
193
|
+
const check = process.argv.includes("--check");
|
|
194
|
+
const { content, unauthorizedZombies, staleAllowlistEntries } = buildTable();
|
|
195
|
+
|
|
196
|
+
let failed = false;
|
|
197
|
+
|
|
198
|
+
if (unauthorizedZombies.length > 0) {
|
|
199
|
+
console.error(
|
|
200
|
+
`control-event-table: new zero-producer ControlEvent variant(s) not in KNOWN_ZERO_PRODUCER: ${unauthorizedZombies.join(", ")}`,
|
|
201
|
+
);
|
|
202
|
+
console.error("Either add a producer, or deliberately add the variant to KNOWN_ZERO_PRODUCER in scripts/control-event-table.mjs.");
|
|
203
|
+
failed = true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (staleAllowlistEntries.length > 0) {
|
|
207
|
+
console.error(
|
|
208
|
+
`control-event-table: KNOWN_ZERO_PRODUCER lists variant(s) that now have a producer — remove from the allowlist: ${staleAllowlistEntries.join(", ")}`,
|
|
209
|
+
);
|
|
210
|
+
failed = true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (check) {
|
|
214
|
+
const onDisk = existsSync(OUT_FILE) ? readFileSync(OUT_FILE, "utf8") : null;
|
|
215
|
+
if (onDisk !== content) {
|
|
216
|
+
console.error(`control-event-table: ${OUT_FILE} is stale. Run: node scripts/control-event-table.mjs`);
|
|
217
|
+
failed = true;
|
|
218
|
+
}
|
|
219
|
+
process.exit(failed ? 1 : 0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
writeFileSync(OUT_FILE, content);
|
|
223
|
+
console.log(`control-event-table: wrote ${relative(ROOT, OUT_FILE)}`);
|
|
224
|
+
process.exit(failed ? 1 : 0);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
main();
|