@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.
- package/README.md +27 -0
- package/bin/hooks-mcp.js +282 -123
- package/bin/index.js +625 -252
- package/bin/native-safety-entry.js +1136 -0
- package/bin/serve.js +329 -127
- package/dist/index.js +610 -249
- package/dist/lib/native-safety.d.ts +15 -0
- package/dist/openapi.d.ts +314 -45
- package/dist/sdk/authority.d.ts +31 -0
- package/dist/sdk/generated.d.ts +97 -0
- package/dist/sdk/index.d.ts +14 -15
- package/dist/sdk/index.js +172 -1
- package/dist/sdk/registry-client.d.ts +75 -0
- package/hooks/native-safety-entry.ts +28 -0
- package/package.json +7 -4
- package/scripts/generate-sdk.ts +60 -0
- package/scripts/validate-package.ts +34 -0
- package/scripts/verify-generated-artifacts.ts +237 -0
|
@@ -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();
|