@enwithai/product-check 0.1.0 → 0.2.0

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.
Files changed (46) hide show
  1. package/ATTRIBUTION.md +2 -2
  2. package/CHANGELOG.md +38 -0
  3. package/NOTICE.md +5 -3
  4. package/README.md +7 -5
  5. package/dashboard/dashboard.css +6 -0
  6. package/dist/blueprint/build.d.ts +3 -1
  7. package/dist/blueprint/build.js +41 -5
  8. package/dist/cli/run.js +96 -5
  9. package/dist/contract/contract.d.ts +4 -0
  10. package/dist/contract/contract.js +16 -0
  11. package/dist/dashboard/render.d.ts +5 -0
  12. package/dist/dashboard/render.js +107 -3
  13. package/dist/discovery/pops.d.ts +2 -0
  14. package/dist/discovery/pops.js +7 -5
  15. package/dist/importers/operational.d.ts +56 -0
  16. package/dist/importers/operational.js +217 -0
  17. package/dist/importers/reports.d.ts +24 -0
  18. package/dist/importers/reports.js +37 -1
  19. package/dist/inspectors/source/surface.d.ts +30 -0
  20. package/dist/inspectors/source/surface.js +529 -0
  21. package/dist/parse/yaml.d.ts +4 -0
  22. package/dist/parse/yaml.js +39 -0
  23. package/dist/reporting/markdown.js +17 -0
  24. package/dist/reporting/spec-markdown.d.ts +9 -0
  25. package/dist/reporting/spec-markdown.js +144 -0
  26. package/dist/run.d.ts +2 -0
  27. package/dist/run.js +52 -2
  28. package/dist/schema/registry.js +1 -0
  29. package/dist/scoring/score.js +1 -1
  30. package/dist/spec/build.d.ts +28 -0
  31. package/dist/spec/build.js +278 -0
  32. package/dist/types.d.ts +78 -2
  33. package/dist/types.js +6 -1
  34. package/dist/validation/catalog.d.ts +1 -1
  35. package/dist/validation/catalog.js +125 -2
  36. package/dist/validation/context.d.ts +6 -1
  37. package/dist/validation/import-evaluators.js +37 -3
  38. package/dist/validation/integrity.js +4 -0
  39. package/dist/validation/operational-evaluators.d.ts +3 -0
  40. package/dist/validation/operational-evaluators.js +153 -0
  41. package/dist/validation/source-evaluators.js +1 -1
  42. package/package.json +4 -1
  43. package/schemas/as-built-spec.schema.json +356 -0
  44. package/schemas/blueprint.schema.json +3 -0
  45. package/schemas/scan-contract.schema.json +20 -0
  46. package/schemas/scorecard.schema.json +1 -1
@@ -0,0 +1,529 @@
1
+ import { lineOf, lineText } from "./facts.js";
2
+ const MAX_ITEMS_PER_KIND = 500;
3
+ const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
4
+ const AUTH_HELPER = /\b(getServerSession|auth\(\)|currentUser\(|getUser\(|getSession\(|requireAuth|requireUser|isAuthenticated|withAuth|clerkClient|supabase\.auth\.|verifyToken|jwt\.verify|login_required|Depends\(\s*get_current_user)/;
5
+ /** Extra integrations not already classified by facts.ts, by category. */
6
+ const INTEGRATIONS = [
7
+ [/^(stripe|@stripe\/.+|@lemonsqueezy\/.+|@paddle\/.+|braintree|razorpay)$/, "payments"],
8
+ [
9
+ /^(resend|nodemailer|@sendgrid\/mail|postmark|mailgun\.js|@react-email\/.+|@aws-sdk\/client-ses)$/,
10
+ "email",
11
+ ],
12
+ [
13
+ /^(@aws-sdk\/client-s3|@vercel\/blob|uploadthing|cloudinary|@google-cloud\/storage|@azure\/storage-blob)$/,
14
+ "storage",
15
+ ],
16
+ [
17
+ /^(posthog-js|@vercel\/analytics|@segment\/analytics-next|mixpanel-browser|@amplitude\/.+|plausible-tracker)$/,
18
+ "analytics",
19
+ ],
20
+ [/^(twilio|@slack\/web-api|@octokit\/rest|octokit|pusher|pusher-js|ably|@liveblocks\/.+)$/, "messaging"],
21
+ [
22
+ /^(@upstash\/redis|@upstash\/ratelimit|ioredis|redis|bullmq|inngest|@trigger\.dev\/sdk|@vercel\/kv)$/,
23
+ "queue-cache",
24
+ ],
25
+ ];
26
+ const isJs = (p) => /\.[cm]?[jt]sx?$/.test(p);
27
+ const strip = (p) => p.replace(/^src\//, "");
28
+ /** Turns a file-system route (segments) into a URL path, dropping route groups and parallel-route slots. */
29
+ function routeFromSegments(segments) {
30
+ const kept = segments.filter((s) => s !== "" && !/^\(.*\)$/.test(s) && !s.startsWith("@") && s !== "index");
31
+ return `/${kept.join("/")}`;
32
+ }
33
+ /** The grouping area of a URL path: first static segment after an optional `api` prefix. */
34
+ export function areaOf(route) {
35
+ const segs = route.split("/").filter(Boolean);
36
+ const rest = segs[0] === "api" ? segs.slice(1) : segs;
37
+ const first = rest.find((s) => !/^[[:{(]/.test(s));
38
+ return first ? first.toLowerCase() : segs[0] === "api" ? "api" : "home";
39
+ }
40
+ function firstLineMatching(text, re) {
41
+ const m = re.exec(text);
42
+ if (!m)
43
+ return null;
44
+ const line = lineOf(text, m.index);
45
+ return { line, excerpt: lineText(text, line).trim() };
46
+ }
47
+ /** Derives the product surface. */
48
+ export function deriveSurface(facts) {
49
+ const items = [];
50
+ const notes = [];
51
+ const counts = new Map();
52
+ const seen = new Set();
53
+ const add = (item) => {
54
+ const key = `${item.kind}\u001f${item.method ?? ""}\u001f${item.name}\u001f${item.path}`;
55
+ if (seen.has(key))
56
+ return;
57
+ const n = counts.get(item.kind) ?? 0;
58
+ if (n >= MAX_ITEMS_PER_KIND)
59
+ return;
60
+ seen.add(key);
61
+ counts.set(item.kind, n + 1);
62
+ items.push({ ...item, auth_hint: item.auth_hint ?? false });
63
+ };
64
+ const files = facts.snapshot.files;
65
+ const code = facts.codeFiles;
66
+ const has = (dep) => facts.deps.has(dep);
67
+ const authHint = (f) => AUTH_HELPER.test(f.text ?? "");
68
+ // ------------------------------------------------------------- file-system routes
69
+ for (const f of files) {
70
+ const p = f.path;
71
+ // Next.js app router pages and route handlers.
72
+ let m = /^(?:src\/)?app\/(?:(.*)\/)?page\.[jt]sx?$/.exec(p);
73
+ if (m && has("next")) {
74
+ const route = routeFromSegments((m[1] ?? "").split("/"));
75
+ add({
76
+ kind: "page",
77
+ name: route,
78
+ method: null,
79
+ area: areaOf(route),
80
+ detail: "Next.js app router page",
81
+ path: p,
82
+ line: 1,
83
+ excerpt: null,
84
+ certainty: "observed",
85
+ auth_hint: authHint(f),
86
+ });
87
+ continue;
88
+ }
89
+ m = /^(?:src\/)?app\/(?:(.*)\/)?route\.[jt]s$/.exec(p);
90
+ if (m && has("next")) {
91
+ const route = routeFromSegments((m[1] ?? "").split("/"));
92
+ const text = f.text ?? "";
93
+ let found = false;
94
+ for (const method of METHODS) {
95
+ const hit = firstLineMatching(text, new RegExp(`export\\s+(?:async\\s+)?function\\s+${method}\\b|export\\s+const\\s+${method}\\b`));
96
+ if (hit) {
97
+ found = true;
98
+ add({
99
+ kind: "endpoint",
100
+ name: route,
101
+ method,
102
+ area: areaOf(route),
103
+ detail: "Next.js route handler",
104
+ path: p,
105
+ line: hit.line,
106
+ excerpt: hit.excerpt,
107
+ certainty: "observed",
108
+ auth_hint: authHint(f),
109
+ });
110
+ }
111
+ }
112
+ if (!found)
113
+ add({
114
+ kind: "endpoint",
115
+ name: route,
116
+ method: "ANY",
117
+ area: areaOf(route),
118
+ detail: "Next.js route handler (methods not recognised)",
119
+ path: p,
120
+ line: 1,
121
+ excerpt: null,
122
+ certainty: "inferred",
123
+ auth_hint: authHint(f),
124
+ });
125
+ continue;
126
+ }
127
+ // Next.js pages router.
128
+ m = /^(?:src\/)?pages\/(.+)\.[jt]sx?$/.exec(p);
129
+ if (m && has("next")) {
130
+ const rel = m[1];
131
+ if (/(^|\/)_(app|document|error|middleware)$/.test(rel))
132
+ continue;
133
+ const route = routeFromSegments(rel.split("/"));
134
+ if (rel.startsWith("api/")) {
135
+ const text = f.text ?? "";
136
+ const methods = [
137
+ ...new Set([...text.matchAll(/method\s*===?\s*["'`]([A-Z]+)["'`]/g)].map((x) => x[1])),
138
+ ].filter((x) => METHODS.includes(x));
139
+ for (const method of methods.length > 0 ? methods.sort() : ["ANY"])
140
+ add({
141
+ kind: "endpoint",
142
+ name: route,
143
+ method,
144
+ area: areaOf(route),
145
+ detail: "Next.js API route",
146
+ path: p,
147
+ line: 1,
148
+ excerpt: null,
149
+ certainty: methods.length > 0 ? "observed" : "inferred",
150
+ auth_hint: authHint(f),
151
+ });
152
+ }
153
+ else if (rel !== "404" && rel !== "500") {
154
+ add({
155
+ kind: "page",
156
+ name: route,
157
+ method: null,
158
+ area: areaOf(route),
159
+ detail: "Next.js pages router page",
160
+ path: p,
161
+ line: 1,
162
+ excerpt: null,
163
+ certainty: "observed",
164
+ auth_hint: authHint(f),
165
+ });
166
+ }
167
+ continue;
168
+ }
169
+ // SvelteKit.
170
+ m = /^src\/routes\/(?:(.*)\/)?\+(page\.svelte|server\.[jt]s)$/.exec(p);
171
+ if (m && has("@sveltejs/kit")) {
172
+ const route = routeFromSegments((m[1] ?? "").split("/"));
173
+ if (m[2].startsWith("page"))
174
+ add({
175
+ kind: "page",
176
+ name: route,
177
+ method: null,
178
+ area: areaOf(route),
179
+ detail: "SvelteKit page",
180
+ path: p,
181
+ line: 1,
182
+ excerpt: null,
183
+ certainty: "observed",
184
+ auth_hint: authHint(f),
185
+ });
186
+ else
187
+ for (const method of METHODS) {
188
+ const hit = firstLineMatching(f.text ?? "", new RegExp(`export\\s+(?:async\\s+)?(?:function|const)\\s+${method}\\b`));
189
+ if (hit)
190
+ add({
191
+ kind: "endpoint",
192
+ name: route,
193
+ method,
194
+ area: areaOf(route),
195
+ detail: "SvelteKit endpoint",
196
+ path: p,
197
+ line: hit.line,
198
+ excerpt: hit.excerpt,
199
+ certainty: "observed",
200
+ auth_hint: authHint(f),
201
+ });
202
+ }
203
+ continue;
204
+ }
205
+ // Astro.
206
+ m = /^src\/pages\/(.+)\.(astro|md|mdx)$/.exec(p);
207
+ if (m && has("astro") && m[1] !== "404") {
208
+ const route = routeFromSegments(m[1].split("/"));
209
+ add({
210
+ kind: "page",
211
+ name: route,
212
+ method: null,
213
+ area: areaOf(route),
214
+ detail: "Astro page",
215
+ path: p,
216
+ line: 1,
217
+ excerpt: null,
218
+ certainty: "observed",
219
+ });
220
+ }
221
+ }
222
+ // ------------------------------------------------------------- routes declared in code
223
+ const serverFw = ["express", "fastify", "hono", "koa", "@koa/router", "koa-router", "@nestjs/core"].some(has);
224
+ for (const f of code) {
225
+ const text = f.text;
226
+ if (isJs(f.path) && serverFw) {
227
+ for (const hit of text.matchAll(/\b(?:app|router|server|api|fastify|route[rs]?|r)\.(get|post|put|patch|delete|all)\(\s*(["'`])(\/[^"'`]*)\2/g)) {
228
+ const line = lineOf(text, hit.index);
229
+ const route = hit[3];
230
+ const method = hit[1].toUpperCase();
231
+ add({
232
+ kind: "endpoint",
233
+ name: route,
234
+ method: method === "ALL" ? "ANY" : method,
235
+ area: areaOf(route),
236
+ detail: "route registered in code",
237
+ path: f.path,
238
+ line,
239
+ excerpt: lineText(text, line).trim(),
240
+ certainty: "observed",
241
+ auth_hint: AUTH_HELPER.test(lineText(text, line)),
242
+ });
243
+ }
244
+ for (const hit of text.matchAll(/@(Get|Post|Put|Patch|Delete)\(\s*(?:["'`]([^"'`]*)["'`])?\s*\)/g)) {
245
+ const ctrl = /@Controller\(\s*["'`]([^"'`]*)["'`]/.exec(text)?.[1] ?? "";
246
+ const route = `/${[ctrl, hit[2] ?? ""].filter(Boolean).join("/")}`.replace(/\/+/g, "/");
247
+ const line = lineOf(text, hit.index);
248
+ add({
249
+ kind: "endpoint",
250
+ name: route,
251
+ method: hit[1].toUpperCase(),
252
+ area: areaOf(route),
253
+ detail: "NestJS controller route",
254
+ path: f.path,
255
+ line,
256
+ excerpt: lineText(text, line).trim(),
257
+ certainty: "observed",
258
+ });
259
+ }
260
+ }
261
+ if (isJs(f.path) && /react-router/.test(text)) {
262
+ for (const hit of text.matchAll(/(?:<Route\b[^>]*\bpath=|\bpath\s*:\s*)(["'`])(\/?[^"'`]*)\1/g)) {
263
+ const raw = hit[2];
264
+ const route = raw.startsWith("/") ? raw : `/${raw}`;
265
+ const line = lineOf(text, hit.index);
266
+ add({
267
+ kind: "page",
268
+ name: route,
269
+ method: null,
270
+ area: areaOf(route),
271
+ detail: "React Router route",
272
+ path: f.path,
273
+ line,
274
+ excerpt: lineText(text, line).trim(),
275
+ certainty: "observed",
276
+ });
277
+ }
278
+ }
279
+ if (f.path.endsWith(".py")) {
280
+ for (const hit of text.matchAll(/@(?:\w+)\.(get|post|put|patch|delete|route|api_route)\(\s*["'](\/[^"']*)["']([^)]*)\)/g)) {
281
+ const line = lineOf(text, hit.index);
282
+ const verb = hit[1].toUpperCase();
283
+ const methods = /methods\s*=\s*\[([^\]]*)\]/.exec(hit[3] ?? "")?.[1];
284
+ const list = verb === "ROUTE" || verb === "API_ROUTE"
285
+ ? methods
286
+ ? [...methods.matchAll(/["'](\w+)["']/g)].map((x) => x[1].toUpperCase())
287
+ : ["GET"]
288
+ : [verb];
289
+ for (const method of list)
290
+ add({
291
+ kind: "endpoint",
292
+ name: hit[2],
293
+ method,
294
+ area: areaOf(hit[2]),
295
+ detail: "Python route decorator",
296
+ path: f.path,
297
+ line,
298
+ excerpt: lineText(text, line).trim(),
299
+ certainty: "observed",
300
+ auth_hint: AUTH_HELPER.test(text.slice(hit.index, hit.index + 600)),
301
+ });
302
+ }
303
+ }
304
+ }
305
+ // ------------------------------------------------------------- data entities
306
+ for (const f of facts.snapshot.files) {
307
+ const text = f.text;
308
+ if (text === null)
309
+ continue;
310
+ if (f.path.endsWith(".prisma")) {
311
+ for (const hit of text.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) {
312
+ const fields = [...hit[2].matchAll(/^\s+(\w+)\s+\w+/gm)]
313
+ .map((x) => x[1])
314
+ .filter((x) => !x.startsWith("@@"));
315
+ const line = lineOf(text, hit.index);
316
+ add({
317
+ kind: "entity",
318
+ name: hit[1],
319
+ method: null,
320
+ area: "data",
321
+ detail: fields.length
322
+ ? `fields: ${fields.slice(0, 15).join(", ")}${fields.length > 15 ? ", …" : ""}`
323
+ : "no fields recognised",
324
+ path: f.path,
325
+ line,
326
+ excerpt: lineText(text, line).trim(),
327
+ certainty: "observed",
328
+ });
329
+ }
330
+ }
331
+ else if (f.path.endsWith(".sql")) {
332
+ for (const hit of text.matchAll(/create\s+table\s+(?:if\s+not\s+exists\s+)?["`[]?(?:\w+["`\]]?\.["`[]?)?(\w+)/gi)) {
333
+ const line = lineOf(text, hit.index);
334
+ add({
335
+ kind: "entity",
336
+ name: hit[1],
337
+ method: null,
338
+ area: "data",
339
+ detail: "SQL table",
340
+ path: f.path,
341
+ line,
342
+ excerpt: lineText(text, line).trim(),
343
+ certainty: "observed",
344
+ });
345
+ }
346
+ }
347
+ else if (isJs(f.path)) {
348
+ for (const hit of text.matchAll(/\b(?:pgTable|mysqlTable|sqliteTable)\(\s*["'`](\w+)["'`]/g)) {
349
+ const line = lineOf(text, hit.index);
350
+ add({
351
+ kind: "entity",
352
+ name: hit[1],
353
+ method: null,
354
+ area: "data",
355
+ detail: "Drizzle table",
356
+ path: f.path,
357
+ line,
358
+ excerpt: lineText(text, line).trim(),
359
+ certainty: "observed",
360
+ });
361
+ }
362
+ if (has("mongoose"))
363
+ for (const hit of text.matchAll(/\bmodel(?:<[^>]*>)?\(\s*["'`](\w+)["'`]/g)) {
364
+ const line = lineOf(text, hit.index);
365
+ add({
366
+ kind: "entity",
367
+ name: hit[1],
368
+ method: null,
369
+ area: "data",
370
+ detail: "Mongoose model",
371
+ path: f.path,
372
+ line,
373
+ excerpt: lineText(text, line).trim(),
374
+ certainty: "observed",
375
+ });
376
+ }
377
+ }
378
+ else if (f.path.endsWith(".py")) {
379
+ for (const hit of text.matchAll(/^class\s+(\w+)\((?:[\w.]*\b(?:models\.Model|Base|db\.Model|SQLModel|DeclarativeBase)\b[^)]*)\)\s*:/gm)) {
380
+ const line = lineOf(text, hit.index);
381
+ add({
382
+ kind: "entity",
383
+ name: hit[1],
384
+ method: null,
385
+ area: "data",
386
+ detail: "Python model class",
387
+ path: f.path,
388
+ line,
389
+ excerpt: lineText(text, line).trim(),
390
+ certainty: "observed",
391
+ });
392
+ }
393
+ }
394
+ }
395
+ // ------------------------------------------------------------- configuration names (never values)
396
+ const env = new Map();
397
+ const testSet = new Set(facts.testFiles);
398
+ for (const f of code) {
399
+ if (testSet.has(f.path))
400
+ continue;
401
+ const text = f.text;
402
+ for (const hit of text.matchAll(/\b(?:process\.env\.|import\.meta\.env\.|process\.env\[["'`]|os\.environ\[["']|os\.environ\.get\(\s*["']|os\.getenv\(\s*["'])([A-Z][A-Z0-9_]{1,80})\b/g)) {
403
+ const name = hit[1];
404
+ if (name === "NODE_ENV")
405
+ continue;
406
+ const prev = env.get(name);
407
+ if (prev)
408
+ prev.refs++;
409
+ else
410
+ env.set(name, { path: f.path, line: lineOf(text, hit.index), refs: 1 });
411
+ }
412
+ }
413
+ for (const [name, loc] of [...env.entries()].sort(([a], [b]) => (a < b ? -1 : 1))) {
414
+ const publicVar = /^(NEXT_PUBLIC_|VITE_|PUBLIC_|EXPO_PUBLIC_|REACT_APP_)/.test(name);
415
+ add({
416
+ kind: "config",
417
+ name,
418
+ method: null,
419
+ area: "configuration",
420
+ detail: `${publicVar ? "exposed to the browser bundle" : "server-side"}; ${loc.refs} reference(s)`,
421
+ path: loc.path,
422
+ line: loc.line,
423
+ excerpt: null,
424
+ certainty: "observed",
425
+ });
426
+ }
427
+ // ------------------------------------------------------------- integrations
428
+ const manifestLine = (dep) => {
429
+ const d = facts.deps.get(dep);
430
+ const m = facts.manifests.find((x) => x.path === d?.manifest);
431
+ if (!d || !m)
432
+ return { path: d?.manifest ?? "package.json", line: null };
433
+ const idx = m.text.indexOf(`"${dep}"`);
434
+ return { path: m.path, line: idx >= 0 ? lineOf(m.text, idx) : null };
435
+ };
436
+ const integration = (dep, category) => {
437
+ const loc = manifestLine(dep);
438
+ if (facts.deps.get(dep)?.dev)
439
+ return;
440
+ add({
441
+ kind: "integration",
442
+ name: dep,
443
+ method: null,
444
+ area: category,
445
+ detail: category,
446
+ path: loc.path,
447
+ line: loc.line,
448
+ excerpt: null,
449
+ certainty: "observed",
450
+ });
451
+ };
452
+ for (const d of facts.dbDeps)
453
+ integration(d, "data");
454
+ for (const d of facts.authDeps)
455
+ integration(d, "authentication");
456
+ for (const d of facts.aiDeps)
457
+ integration(d, "ai");
458
+ for (const d of facts.observability)
459
+ integration(d, "observability");
460
+ for (const d of [...facts.deps.keys()].sort())
461
+ for (const [re, cat] of INTEGRATIONS)
462
+ if (re.test(d))
463
+ integration(d, cat);
464
+ // ------------------------------------------------------------- authentication boundaries
465
+ for (const f of files) {
466
+ if (!/^(?:src\/)?middleware\.[jt]s$/.test(f.path) || f.text === null)
467
+ continue;
468
+ const hit = firstLineMatching(f.text, /matcher\s*:/);
469
+ add({
470
+ kind: "auth-boundary",
471
+ name: "middleware",
472
+ method: null,
473
+ area: "authentication",
474
+ detail: hit
475
+ ? `request middleware; ${hit.excerpt}`
476
+ : "request middleware (no matcher: applies to every route)",
477
+ path: f.path,
478
+ line: hit?.line ?? 1,
479
+ excerpt: hit?.excerpt ?? null,
480
+ certainty: "inferred",
481
+ });
482
+ }
483
+ // ------------------------------------------------------------- behaviours named by tests
484
+ for (const p of facts.testFiles) {
485
+ const text = facts.snapshot.byPath.get(p)?.text;
486
+ if (!text)
487
+ continue;
488
+ for (const hit of text.matchAll(/\b(?:it|test|describe)(?:\.\w+)?\(\s*(["'`])([^"'`\n]{3,200})\1/g)) {
489
+ const line = lineOf(text, hit.index);
490
+ add({
491
+ kind: "test",
492
+ name: hit[2].trim(),
493
+ method: null,
494
+ area: strip(p).split("/").slice(0, -1).join("/") || ".",
495
+ detail: "test title",
496
+ path: p,
497
+ line,
498
+ excerpt: null,
499
+ certainty: "inferred",
500
+ });
501
+ }
502
+ }
503
+ // ------------------------------------------------------------- notes
504
+ for (const [kind, n] of counts)
505
+ if (n >= MAX_ITEMS_PER_KIND)
506
+ notes.push(`More than ${MAX_ITEMS_PER_KIND} ${kind} items were found; only the first ${MAX_ITEMS_PER_KIND} are listed.`);
507
+ if (!items.some((i) => i.kind === "page" || i.kind === "endpoint"))
508
+ notes.push("No pages or API endpoints were recognised; the product may be a library, CLI or use a framework this release does not read.");
509
+ if (facts.dbDeps.length > 0 && !items.some((i) => i.kind === "entity"))
510
+ notes.push("A data layer is used but no entity definitions were recognised in the repository.");
511
+ if (env.size > 0)
512
+ notes.push("Configuration is listed by variable name only; values were never read.");
513
+ if (facts.snapshot.files.some((f) => /\.(go|rs|java|kt|php|rb|cs)$/.test(f.path)))
514
+ notes.push("Go, Rust, Java, Kotlin, PHP, Ruby and C# sources are present but their routes and models are not read in this release.");
515
+ const order = [
516
+ "page",
517
+ "endpoint",
518
+ "entity",
519
+ "config",
520
+ "integration",
521
+ "auth-boundary",
522
+ "test",
523
+ ];
524
+ items.sort((a, b) => order.indexOf(a.kind) - order.indexOf(b.kind) ||
525
+ (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) ||
526
+ (a.method ?? "").localeCompare(b.method ?? "") ||
527
+ (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
528
+ return { items, notes };
529
+ }
@@ -0,0 +1,4 @@
1
+ /** Largest YAML input accepted, in bytes. */
2
+ export declare const MAX_YAML_BYTES: number;
3
+ /** Parses every document in a YAML or JSON text into plain values. */
4
+ export declare function readYaml(text: string, source: string): unknown[];
@@ -0,0 +1,39 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 EnWithAI Technologies
3
+ /**
4
+ * Safe YAML (and JSON) reading for the operational importers (docs/operational-imports.md): YAML 1.2 core schema,
5
+ * unique keys, at most 100 alias expansions, no custom tags, 10 MiB per file. Any document error is an input error.
6
+ */
7
+ import { parseAllDocuments } from "yaml";
8
+ import { inputError } from "../errors.js";
9
+ /** Largest YAML input accepted, in bytes. */
10
+ export const MAX_YAML_BYTES = 10 * 1024 * 1024;
11
+ /** Parses every document in a YAML or JSON text into plain values. */
12
+ export function readYaml(text, source) {
13
+ if (Buffer.byteLength(text, "utf8") > MAX_YAML_BYTES)
14
+ throw inputError(`${source} is larger than 10 MiB; YAML inputs are limited to 10 MiB.`);
15
+ const fail = () => {
16
+ throw inputError(`${source} is not valid YAML (or uses more than 100 aliases or duplicate keys).`);
17
+ };
18
+ try {
19
+ const docs = parseAllDocuments(text, {
20
+ schema: "core",
21
+ version: "1.2",
22
+ uniqueKeys: true,
23
+ prettyErrors: false,
24
+ customTags: [],
25
+ });
26
+ if (!Array.isArray(docs))
27
+ return fail();
28
+ const out = [];
29
+ for (const doc of docs) {
30
+ if (doc.errors.length > 0)
31
+ return fail();
32
+ out.push(doc.toJS({ maxAliasCount: 100 }));
33
+ }
34
+ return out;
35
+ }
36
+ catch {
37
+ return fail();
38
+ }
39
+ }
@@ -110,6 +110,23 @@ export function renderBlueprint(r, options) {
110
110
  lines.push("", "### Stated uncertainties", "");
111
111
  for (const u of b.as_is.uncertainties)
112
112
  lines.push(`- ${md(u)}`);
113
+ const built = b.as_is.as_built;
114
+ if (built) {
115
+ const count = (k) => built.surface.filter((s) => s.kind === k).length;
116
+ lines.push("", "## As-built specification (reverse-engineered)", "", `Derived from the repository's structure without running it: ${count("page")} page(s), ${count("endpoint")} endpoint(s), ${count("entity")} data entit(y/ies), ${count("integration")} integration(s), ${count("config")} configuration name(s). The full spec is in \`specs/000-as-built/spec.md\` (Spec Kit layout) and \`as-built-spec.json\`; traceability links findings to these requirement IDs.`, "");
117
+ if (built.requirements.length === 0)
118
+ lines.push("No functional requirements could be derived.");
119
+ // Statements are escaped where they are built (src/spec/build.ts).
120
+ for (const req of built.requirements.slice(0, 60))
121
+ lines.push(`- **${req.id}** ${req.statement} _(${req.certainty})_`);
122
+ if (built.requirements.length > 60)
123
+ lines.push(`- … and ${built.requirements.length - 60} more`);
124
+ if (built.clarifications.length > 0) {
125
+ lines.push("", `**${built.clarifications.length} question(s) only a person can answer:**`, "");
126
+ for (const c of built.clarifications)
127
+ lines.push(`- ${md(c)}`);
128
+ }
129
+ }
113
130
  lines.push("", "## To-be architecture (target changes)", "");
114
131
  if (b.to_be.changes.length === 0)
115
132
  lines.push("No target changes: no open findings.");
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Renders an as-built specification as a GitHub Spec Kit style `spec.md`, so Spec Check can lint it like any other
3
+ * specification. Statements carry their certainty and source location; open questions are left as
4
+ * `[NEEDS CLARIFICATION: …]` markers. Untrusted strings (paths, route names, test titles) go through the escaping
5
+ * helpers.
6
+ */
7
+ import type { AsBuiltSpec } from "../types.js";
8
+ /** Renders the as-built spec. */
9
+ export declare function renderAsBuiltSpec(spec: AsBuiltSpec): string;