@hasna/hooks 0.9.5 → 0.9.6

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.
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Verify that the committed generated registry client is byte-identical to what
4
+ * the current source produces — and that it is a real client, not an empty file
5
+ * that trivially matches itself.
6
+ *
7
+ * `src/sdk/generated.ts` is committed because `package.json`'s build bundles it
8
+ * into the published `./sdk` export, so a drifted file ships. The repo rule is
9
+ * explicit (".github/workflows/ci.yml", `verify-generated` job): a member that
10
+ * commits generated bundles adds its own step to that job, sharing the job's
11
+ * bun-version pin — "a committed bundle no job regenerates is drift by
12
+ * construction". This is that step for hooks.
13
+ *
14
+ * THE RULES THIS HOLDS TO (mirrors apps/telephony and apps/knowledge):
15
+ *
16
+ * 1. ONE ENTRY POINT. The regeneration happens inside this script via
17
+ * `package.json`'s `generate:sdk`, so it cannot be run without the
18
+ * regeneration and read as a sync check, and the two can never disagree.
19
+ *
20
+ * 2. THE REGENERATION MUST BE BYTE-STABLE, NOT ONLY MATCH THE INDEX. The
21
+ * generator runs TWICE; the two outputs must be byte-identical before the
22
+ * index comparison means anything. A nondeterministic generator would make
23
+ * the byte gate pass on one run and fail on the next with no commit in
24
+ * between — the vacuous-check class wearing a green gate.
25
+ *
26
+ * 3. THE CHECK PROVES THE ARTIFACT IS REAL BEFORE TRUSTING A CLEAN RESULT. A
27
+ * generator that emitted nothing would be byte-stable and match its own
28
+ * empty index forever. The artifact must carry the client class and one
29
+ * method per operation in the SERVED document — the exact binding
30
+ * `hasna.contract.json`'s `serviceSurfaces[3].generatedFrom` claims.
31
+ *
32
+ * 4. THE PRECONDITION IS CHECKED. `git diff` after a regeneration only means
33
+ * something if the file matched the index BEFORE it; a file that was
34
+ * already modified proves nothing afterwards.
35
+ */
36
+ import { createHash } from "node:crypto";
37
+ import { readFileSync } from "node:fs";
38
+ import { spawnSync } from "node:child_process";
39
+ import { fileURLToPath } from "node:url";
40
+ import { dirname, join, resolve } from "node:path";
41
+ import { generateSdkFromOpenApi } from "@hasna/contracts/sdk";
42
+ import { buildOpenApiDocument } from "../src/openapi.ts";
43
+
44
+ const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
45
+
46
+ /** The one committed-and-shipped file `generate:sdk` rewrites. */
47
+ export const GENERATED_FILE = "src/sdk/generated.ts";
48
+
49
+ /** The class the manifest's `./sdk` surface advertises as generated. */
50
+ export const GENERATED_CLASS = "HooksRegistryApiClient";
51
+
52
+ function fail(message: string): never {
53
+ console.error(`verify-generated-artifacts: ${message}`);
54
+ process.exit(1);
55
+ }
56
+
57
+ function git(args: string[]): { status: number; stdout: string; stderr: string } {
58
+ const run = spawnSync("git", args, { cwd: appRoot, encoding: "utf8" });
59
+ // A spawn that never started reports status null. Treat that as failure: this
60
+ // whole script is a control, and a control that cannot run must not pass.
61
+ return { status: run.status ?? 1, stdout: run.stdout ?? "", stderr: run.stderr ?? "" };
62
+ }
63
+
64
+ function sha256Of(relativePath: string): string {
65
+ return createHash("sha256").update(readFileSync(join(appRoot, relativePath))).digest("hex");
66
+ }
67
+
68
+ /* ------------------------------------------------------------------ *
69
+ * Rule 3: prove the committed artifact is a real generated client for *
70
+ * the served document — before any clean result is believed. *
71
+ * ------------------------------------------------------------------ */
72
+
73
+ /**
74
+ * The operations the SERVED document declares. Derived from
75
+ * `buildOpenApiDocument`, the same builder src/serve.ts answers
76
+ * `GET /openapi.json` with, so this is the binding the contract claims.
77
+ */
78
+ export function servedOperations(): string[] {
79
+ const spec = buildOpenApiDocument("0.0.0") as {
80
+ paths?: Record<string, Record<string, unknown>>;
81
+ };
82
+ const methods = ["get", "put", "post", "delete", "patch", "options", "head"];
83
+ const found: string[] = [];
84
+ for (const [path, item] of Object.entries(spec.paths ?? {})) {
85
+ for (const method of methods) {
86
+ if (item[method]) found.push(`${method.toUpperCase()} ${path}`);
87
+ }
88
+ }
89
+ return found.sort();
90
+ }
91
+
92
+ /** Problems with the committed artifact, or [] when it looks generated. */
93
+ export function artifactProblems(text: string): string[] {
94
+ const problems: string[] = [];
95
+ if (!text.includes("// @generated")) {
96
+ problems.push(`${GENERATED_FILE} carries no "@generated" header — it is not generator output`);
97
+ }
98
+ if (!text.includes(`export class ${GENERATED_CLASS}`)) {
99
+ problems.push(`${GENERATED_FILE} does not declare "export class ${GENERATED_CLASS}"`);
100
+ }
101
+ return problems;
102
+ }
103
+
104
+ /**
105
+ * The operations the committed artifact exposes, as `METHOD path` pairs. Read
106
+ * from the generated `this.request("METHOD", \`path\`)` call sites so this is
107
+ * about what was actually emitted, not about what the spec says. Emitted path
108
+ * templates interpolate path params (`\${encodeURIComponent(String(name))}`);
109
+ * they are normalized back to the OpenAPI `{name}` form so the comparison is
110
+ * against the served document's spelling.
111
+ */
112
+ export function artifactOperations(text: string): string[] {
113
+ const found: string[] = [];
114
+ const re = /this\.request\(\s*"([A-Z]+)",\s*`([^`]+)`/g;
115
+ for (const match of text.matchAll(re)) {
116
+ found.push(`${match[1]} ${normalizePathTemplate(match[2]!)}`);
117
+ }
118
+ return found.sort();
119
+ }
120
+
121
+ /** `\${encodeURIComponent(String(name))}` -> `{name}`. */
122
+ export function normalizePathTemplate(path: string): string {
123
+ return path.replace(
124
+ /\$\{encodeURIComponent\(String\(([A-Za-z_$][A-Za-z0-9_$]*)\)\)\}/g,
125
+ "{$1}",
126
+ );
127
+ }
128
+
129
+ /**
130
+ * A served-operation extractor that can no longer find call sites reports "all
131
+ * covered" forever. These fixtures prove the extraction still fires, and still
132
+ * normalizes, before a clean result is believed. The fixture is the exact
133
+ * emitted shape for the parameterized artifact route.
134
+ */
135
+ export const EXTRACTION_FIXTURE =
136
+ 'return this.request("GET", `/api/v1/hooks/${encodeURIComponent(String(name))}/${encodeURIComponent(String(version))}`, {';
137
+ export const EXTRACTION_FIXTURE_EXPECTED = "GET /api/v1/hooks/{name}/{version}";
138
+ // Must NOT match: a bare path with no request call site is not an operation.
139
+ export const EXTRACTION_COUNTER_FIXTURE = 'const p = "/api/v1/hooks/{name}/{version}";';
140
+
141
+ /** Problems with the extraction pattern itself; [] means it is worth believing. */
142
+ export function patternSelfCheck(): string[] {
143
+ const problems: string[] = [];
144
+ const extracted = artifactOperations(EXTRACTION_FIXTURE);
145
+ if (extracted.length !== 1 || extracted[0] !== EXTRACTION_FIXTURE_EXPECTED) {
146
+ problems.push(
147
+ `the operation extractor no longer reads its own fixture (got ${JSON.stringify(extracted)} for ${EXTRACTION_FIXTURE_EXPECTED}) — the coverage check below cannot detect anything`,
148
+ );
149
+ }
150
+ if (artifactOperations(EXTRACTION_COUNTER_FIXTURE).length !== 0) {
151
+ problems.push("the operation extractor matches its counter-fixture — it is too loose to be meaningful");
152
+ }
153
+ return problems;
154
+ }
155
+
156
+ /**
157
+ * The served operations minus the ones the artifact emitted. Empty means the
158
+ * committed client covers the served document exactly.
159
+ */
160
+ export function missingOperations(text: string): string[] {
161
+ const emitted = new Set(artifactOperations(text));
162
+ return servedOperations().filter((op) => !emitted.has(op));
163
+ }
164
+
165
+ function main(): void {
166
+ const committed = readFileSync(join(appRoot, GENERATED_FILE), "utf8");
167
+
168
+ // Rule 3 first: a clean regeneration of a broken artifact must not be read
169
+ // as a pass, and a dead extractor must not be read as full coverage.
170
+ const patternProblems = patternSelfCheck();
171
+ const problems = [...patternProblems, ...artifactProblems(committed)];
172
+ const missing = missingOperations(committed);
173
+ if (missing.length > 0) {
174
+ problems.push(
175
+ `${GENERATED_FILE} is missing ${missing.length} operation(s) the served /openapi.json declares: ${missing.join(", ")}`,
176
+ );
177
+ }
178
+ if (problems.length > 0) {
179
+ for (const problem of problems) console.error(`verify-generated-artifacts: ${problem}`);
180
+ process.exit(1);
181
+ }
182
+
183
+ // Rule 4: the precondition for the diff gate below.
184
+ const dirtyBefore = git(["status", "--porcelain", "--", GENERATED_FILE]);
185
+ if (dirtyBefore.status !== 0) fail(`git status failed: ${dirtyBefore.stderr.trim()}`);
186
+ if (dirtyBefore.stdout.trim() !== "") {
187
+ fail(
188
+ `${GENERATED_FILE} is already modified before the regeneration, so this check cannot tell drift from your edits:\n` +
189
+ `${dirtyBefore.stdout.trimEnd()}\nCommit or restore it, then re-run.`,
190
+ );
191
+ }
192
+
193
+ // Rule 1: regenerate through the package script, never a repeated command.
194
+ const generate = (pass: number): void => {
195
+ const run = spawnSync("bun", ["run", "generate:sdk"], { cwd: appRoot, stdio: "inherit" });
196
+ if ((run.status ?? 1) !== 0) {
197
+ fail(`\`bun run generate:sdk\` (pass ${pass}) exited ${run.status ?? "without a status"}`);
198
+ }
199
+ };
200
+
201
+ // Rule 2: two regenerations of the same source must be byte-identical.
202
+ generate(1);
203
+ const first = sha256Of(GENERATED_FILE);
204
+ generate(2);
205
+ const second = sha256Of(GENERATED_FILE);
206
+ if (first !== second) {
207
+ fail(
208
+ `regeneration is NOT byte-stable: two consecutive \`bun run generate:sdk\` runs of the same source produced different bytes for ${GENERATED_FILE}.\n` +
209
+ `This bun is ${process.versions?.bun ?? "unknown"}. A nondeterministic generator makes the byte gate vacuous — fix the generator (or the bun version) rather than committing either output.`,
210
+ );
211
+ }
212
+
213
+ // The gate: the regeneration must equal what is committed.
214
+ const drift = git(["diff", "--exit-code", "--", GENERATED_FILE]);
215
+ if (drift.status !== 0) {
216
+ console.error(
217
+ `verify-generated-artifacts: the committed registry client is not what the current source generates.\n` +
218
+ `Run \`bun run generate:sdk\` and commit the result. ${GENERATED_FILE} is bundled into the published\n` +
219
+ `\`./sdk\` export, so an uncommitted regeneration ships a client that disagrees with src/openapi.ts.`,
220
+ );
221
+ process.exit(drift.status ?? 1);
222
+ }
223
+
224
+ // Post-condition: the artifact survived the regeneration with its binding intact.
225
+ const finalText = readFileSync(join(appRoot, GENERATED_FILE), "utf8");
226
+ const finalProblems = [...artifactProblems(finalText), ...missingOperations(finalText).map((op) => `missing ${op}`)];
227
+ if (finalProblems.length > 0) {
228
+ fail(`regeneration rewrote ${GENERATED_FILE} but it is not usable: ${finalProblems.join("; ")}`);
229
+ }
230
+
231
+ console.log(
232
+ `verify-generated-artifacts: two consecutive regenerations of ${GENERATED_FILE} are byte-identical to each other and to the committed output; all ${servedOperations().length} operations in the served /openapi.json are covered.`,
233
+ );
234
+ }
235
+
236
+ // Run only when invoked directly, so the exports above are importable from tests.
237
+ if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) main();