@enwithai/product-check 0.1.0-alpha.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 +45 -3
  3. package/NOTICE.md +5 -3
  4. package/README.md +8 -6
  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 +5 -3
  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
@@ -3,8 +3,8 @@
3
3
  /**
4
4
  * Renders the local dashboard: one self-contained HTML file generated entirely from a results document. Every
5
5
  * number is read from (or recomputed from) the JSON; the integrity check runs first and a failure is shown as a
6
- * banner. Views: Executive Readiness, Scan Explorer, Validation Workbench, Blueprint & Remediation, Assurance
7
- * History. A small inline script (allowed by a hash-based CSP) adds tabs, filters and a theme toggle; without it
6
+ * banner. Views: Executive Readiness, Scan Explorer, Validation Workbench, Blueprint & Remediation, As-built Spec,
7
+ * Assurance History. A small inline script (allowed by a hash-based CSP) adds tabs, filters and a theme toggle; without it
8
8
  * every view is still readable. No external requests.
9
9
  */
10
10
  import { createHash } from "node:crypto";
@@ -176,6 +176,109 @@ function evidenceList(r, ids) {
176
176
  })
177
177
  .join("")}</ul></details>`;
178
178
  }
179
+ /**
180
+ * Converts an as-built statement (Markdown built in src/spec/build.ts: code spans for repository-derived names,
181
+ * backslash escapes elsewhere) to HTML. Every segment is HTML-escaped; only `<code>` is produced.
182
+ */
183
+ export function statementHtml(statement) {
184
+ const out = [];
185
+ let rest = statement;
186
+ for (;;) {
187
+ const m = /(`+)( ?)([\s\S]*?)\2\1(?!`)/.exec(rest);
188
+ if (!m)
189
+ break;
190
+ out.push(html(unescapeMd(rest.slice(0, m.index))));
191
+ out.push(`<code>${html((m[3] ?? "").replace(/\\\|/g, "|"))}</code>`);
192
+ rest = rest.slice(m.index + m[0].length);
193
+ }
194
+ out.push(html(unescapeMd(rest)));
195
+ return out.join("");
196
+ }
197
+ /** Undoes the Markdown escaping of `md()` for plain-text display (the result is HTML-escaped afterwards). */
198
+ function unescapeMd(text) {
199
+ return text
200
+ .replace(/\\([\\`*_[\]<>|~#+=-])/g, "$1")
201
+ .replace(/&amp;/g, "&")
202
+ .replace(/@\u200b/g, "@");
203
+ }
204
+ const CERTAINTY_TONE = { observed: "good", inferred: "warning", hypothesis: "unknown" };
205
+ const CERTAINTY_LABEL = {
206
+ observed: "observed",
207
+ inferred: "inferred",
208
+ hypothesis: "needs confirmation",
209
+ };
210
+ /** The As-built Spec view: the reverse-engineered specification carried in the blueprint. */
211
+ function asBuiltView(r) {
212
+ const head = `<section class="view" id="as-built" aria-labelledby="h-as-built"><h2 id="h-as-built">As-built spec</h2>`;
213
+ const built = r.blueprint.as_is.as_built;
214
+ if (!built)
215
+ return `${head}<p class="muted">No as-built spec: the repository was not inspected in this run.</p></section>`;
216
+ const byId = new Map(built.surface.map((s) => [s.id, s]));
217
+ const count = (k) => built.surface.filter((s) => s.kind === k).length;
218
+ const where = (s) => `<code>${html(`${s.location.path}${s.location.line ? `:${s.location.line}` : ""}`)}</code>`;
219
+ // Findings whose traceability names an as-built requirement ("As-built FR-001, FR-007; requirement …").
220
+ const findingsFor = new Map();
221
+ for (const t of r.blueprint.traceability) {
222
+ const ids = /^As-built ([^;]+);/.exec(t.journey_or_requirement)?.[1]?.split(", ") ?? [];
223
+ for (const id of ids)
224
+ findingsFor.set(id, [...(findingsFor.get(id) ?? []), t.finding_id]);
225
+ }
226
+ const openIds = new Set(r.findings.filter((f) => f.status === "OPEN" || f.status === "UNVERIFIED").map((f) => f.id));
227
+ const reqRow = (q, kind) => {
228
+ const sources = q.surface_ids.map((id) => byId.get(id)).filter((s) => s !== undefined);
229
+ const linked = findingsFor.get(q.id) ?? [];
230
+ const open = linked.filter((id) => openIds.has(id));
231
+ return `<tr data-kind="${kind}" data-certainty="${q.certainty}"><td class="id"><code>${html(q.id)}</code></td><td>${statementHtml(q.statement)}</td><td>${chip(CERTAINTY_LABEL[q.certainty] ?? q.certainty, CERTAINTY_TONE[q.certainty] ?? "neutral")}</td><td class="small">${sources.slice(0, 4).map(where).join("<br>") || "—"}${sources.length > 4 ? `<br><span class="muted">and ${sources.length - 4} more</span>` : ""}</td><td class="small">${linked.length ? `${open.length ? chip(`${open.length} open`, "critical") : chip("none open", "good")}<br>${linked.length > 3 ? `<details><summary>${linked.length} finding(s)</summary><code>${html(linked.join(", "))}</code></details>` : `<code>${html(linked.join(", "))}</code>`}` : "—"}</td><td class="small">${evidenceList(r, q.evidence_ids)}</td></tr>`;
232
+ };
233
+ const surfaceTable = (title, kind, cols, cells) => {
234
+ const rows = built.surface.filter((s) => s.kind === kind);
235
+ if (rows.length === 0)
236
+ return "";
237
+ return `<details class="card" style="margin-bottom:8px"><summary>${html(title)} (${rows.length})</summary><div class="scroll"><table><thead><tr>${cols.map((c) => `<th>${html(c)}</th>`).join("")}</tr></thead><tbody>${rows
238
+ .map((s) => `<tr>${cells(s)
239
+ .map((c) => `<td class="small">${c}</td>`)
240
+ .join("")}</tr>`)
241
+ .join("")}</tbody></table></div></details>`;
242
+ };
243
+ const auth = (s) => (s.auth_hint ? chip("auth helper (inferred)", "warning") : "—");
244
+ return `${head}
245
+ <p class="muted small">Reverse-engineered from the repository's structure; nothing was executed. <em>Observed</em> statements point at a file and line, <em>inferred</em> ones interpret what was found, and anything the code cannot tell us needs a person to confirm. The same spec is written as <code>specs/000-as-built/spec.md</code> (Spec Kit layout, lint it with Spec Check) and <code>as-built-spec.json</code>.</p>
246
+ <div class="grid kpis">${[
247
+ ["Pages", count("page")],
248
+ ["Endpoints", count("endpoint")],
249
+ ["Data entities", count("entity")],
250
+ ["Integrations", count("integration")],
251
+ ["Config names", count("config")],
252
+ ["Open questions", built.clarifications.length],
253
+ ]
254
+ .map(([k, v]) => `<div class="card kpi"><div class="label">${html(k)}</div><div class="value">${v}</div></div>`)
255
+ .join("")}</div>
256
+ ${built.clarifications.length ? `<h3>Questions only a person can answer</h3><ul>${built.clarifications.map((c) => `<li>${html(c)}</li>`).join("")}</ul>` : ""}
257
+ <h3>User stories</h3>${built.stories
258
+ .map((s) => `<details class="card" style="margin-bottom:8px"><summary>${html(s.id)} ${html(s.title)} ${s.critical_journey ? chip(`critical journey: ${s.critical_journey}`, "neutral") : ""}</summary><p class="small"><strong>Requirements:</strong> <code>${html(s.requirement_ids.join(", ") || "—")}</code></p>${s.test_behaviours.length ? `<p class="small"><strong>Behaviour described by tests</strong> (inferred)</p><ul class="small">${s.test_behaviours.map((t) => `<li>${html(t)}</li>`).join("")}</ul>` : `<p class="small muted">No test titles mention this area.</p>`}</details>`)
259
+ .join("") ||
260
+ `<p class="muted">No pages or endpoints were recognised, so no user stories could be derived.</p>`}
261
+ <h3>Requirements</h3>
262
+ <div class="filters" data-filter-table="as-built-table"><label>Type<select data-key="kind"><option value="">All</option><option value="functional">Functional</option><option value="non-functional">Non-functional</option><option value="success">Success criteria</option><option value="edge">Edge cases</option></select></label>
263
+ <label>Certainty<select data-key="certainty"><option value="">All</option><option value="observed">Observed</option><option value="inferred">Inferred</option><option value="hypothesis">Needs confirmation</option></select></label>
264
+ <label>Search<input type="search" data-key="text" placeholder="ID, route, entity, path"></label><span class="small muted" data-count style="align-self:end"></span></div>
265
+ <div class="scroll"><table id="as-built-table"><thead><tr><th>ID</th><th>Statement</th><th>Certainty</th><th>Source</th><th>Linked findings</th><th>Evidence</th></tr></thead><tbody>${[
266
+ ...built.requirements.map((q) => reqRow(q, "functional")),
267
+ ...built.non_functional.map((q) => reqRow(q, "non-functional")),
268
+ ...built.success_criteria.map((q) => reqRow(q, "success")),
269
+ ...built.edge_cases.map((q) => reqRow(q, "edge")),
270
+ ].join("")}</tbody></table></div>
271
+ <h3>Discovered surface</h3>
272
+ ${surfaceTable("Pages", "page", ["Route", "Kind", "Source", "Auth"], (s) => [`<code>${html(s.name)}</code>`, html(s.detail), where(s), auth(s)])}
273
+ ${surfaceTable("Endpoints", "endpoint", ["Method", "Path", "Kind", "Source", "Auth"], (s) => [`<code>${html(s.method ?? "ANY")}</code>`, `<code>${html(s.name)}</code>`, html(s.detail), where(s), auth(s)])}
274
+ ${surfaceTable("Data entities", "entity", ["Entity", "Detail", "Source"], (s) => [`<code>${html(s.name)}</code>`, html(s.detail), where(s)])}
275
+ ${surfaceTable("Integrations", "integration", ["Package", "Category", "Declared in"], (s) => [`<code>${html(s.name)}</code>`, html(s.detail), where(s)])}
276
+ ${surfaceTable("Configuration (names only; values never read)", "config", ["Variable", "Use", "First reference"], (s) => [`<code>${html(s.name)}</code>`, html(s.detail), where(s)])}
277
+ ${surfaceTable("Authentication boundaries", "auth-boundary", ["Boundary", "Detail", "Source"], (s) => [`<code>${html(s.name)}</code>`, html(s.detail), where(s)])}
278
+ ${surfaceTable("Test titles", "test", ["Title", "Area", "Source"], (s) => [html(s.name), html(s.area), where(s)])}
279
+ <h3>Uncertainties</h3><ul class="small">${built.uncertainties.map((u) => `<li>${html(u)}</li>`).join("")}</ul>
280
+ </section>`;
281
+ }
179
282
  /** Validation table row. */
180
283
  function validationRow(r, v) {
181
284
  return `<tr data-pillar="${v.pillar}" data-status="${v.status}">
@@ -328,12 +431,13 @@ ${c
328
431
  <div class="wrap">
329
432
  <header class="top"><div><h1>Product Check: ${html(r.contract.target.name)}</h1><div class="meta">Run ${html(r.run.run_id)} · ${html(r.run.finished_at)} · ${html(r.run.status)}</div></div><button class="theme-toggle" type="button" hidden>Theme: auto</button></header>
330
433
  ${banners}
331
- <nav class="tabs" aria-label="Dashboard views"><a href="#readiness" aria-current="page">Executive readiness</a><a href="#scan">Scan explorer</a><a href="#workbench">Validation workbench</a><a href="#blueprint">Blueprint &amp; remediation</a><a href="#history">Assurance history</a></nav>
434
+ <nav class="tabs" aria-label="Dashboard views"><a href="#readiness" aria-current="page">Executive readiness</a><a href="#scan">Scan explorer</a><a href="#workbench">Validation workbench</a><a href="#blueprint">Blueprint &amp; remediation</a><a href="#as-built">As-built spec</a><a href="#history">Assurance history</a></nav>
332
435
  <main>
333
436
  ${exec}
334
437
  ${scan}
335
438
  ${work}
336
439
  ${bp}
440
+ ${asBuiltView(r)}
337
441
  ${hist}
338
442
  </main>
339
443
  <footer>Generated from <code>product-check-results.json</code> (schema ${html(r.schema_version)}); every figure is derived from that file${integrity.errors.length === 0 ? " and its scorecard and verdict recompute from the validations" : ""}. Evidence-bounded readiness, not a penetration test, certification or guarantee.${options.branding ? " Product Check is an EnWithAI Engineering Commons tool." : ""}</footer>
@@ -16,6 +16,8 @@ export interface PopInput {
16
16
  auditImported: boolean;
17
17
  specImported: boolean;
18
18
  outcomeImported: boolean;
19
+ /** OpenSLO, alert rules or a service graph was imported (CT-T4). */
20
+ operationalImported: boolean;
19
21
  aiInScope: boolean;
20
22
  tier: Tier;
21
23
  now: string;
@@ -217,14 +217,16 @@ export function buildPops(input) {
217
217
  id: "observability",
218
218
  category: "operations",
219
219
  name: "Production telemetry (logs, metrics, traces, alerts)",
220
- status: postLaunch ? "BLOCKED" : "NOT_APPLICABLE",
220
+ status: postLaunch ? (input.operationalImported ? "AVAILABLE" : "BLOCKED") : "NOT_APPLICABLE",
221
221
  relevant: postLaunch,
222
222
  scope: null,
223
- provenance: "none",
223
+ provenance: postLaunch && input.operationalImported ? "imported" : "none",
224
224
  limitations: [
225
- postLaunch
226
- ? "No telemetry adapter in this release; production signals were not read."
227
- : "Not required before launch tiers (CT-T4+).",
225
+ !postLaunch
226
+ ? "Not required before launch tiers (CT-T4+)."
227
+ : input.operationalImported
228
+ ? "Read from exported files (OpenSLO, alert rules, service graph); nothing was queried live, and a service graph covers only its time window."
229
+ : "No operational files were imported (--openslo, --alert-rules, --service-graph); production signals were not read.",
228
230
  ],
229
231
  }));
230
232
  pops.push(input.aiInScope
@@ -0,0 +1,56 @@
1
+ /** The parsed documents of one input file. */
2
+ export interface InputDocs {
3
+ source: string;
4
+ docs: unknown[];
5
+ }
6
+ /** One OpenSLO SLO. */
7
+ export interface SloInfo {
8
+ name: string;
9
+ service: string | null;
10
+ /** Missing parts: "service", "time window", "objective target", "indicator". Empty when complete. */
11
+ missing: string[];
12
+ has_alert_policy: boolean;
13
+ dangling_policy_refs: string[];
14
+ }
15
+ /** Summary of the imported OpenSLO files. */
16
+ export interface OpenSloSummary {
17
+ slos: SloInfo[];
18
+ slis: number;
19
+ alert_policies: number;
20
+ /** Documents with another OpenSLO apiVersion (v1alpha, v2alpha). */
21
+ unsupported: number;
22
+ /** openslo/v1 documents of kinds that are not read (Service, DataSource, AlertCondition, …). */
23
+ other: number;
24
+ }
25
+ /** Parses OpenSLO v1 files; references resolve across all of them by metadata.name. */
26
+ export declare function parseOpenSlo(files: InputDocs[]): OpenSloSummary;
27
+ /** An alerting rule. `expr` is used for matching only and never written to results. */
28
+ export interface AlertRule {
29
+ name: string;
30
+ expr: string;
31
+ labels: Record<string, string>;
32
+ }
33
+ /** Summary of the imported alert rules. */
34
+ export interface AlertRulesSummary {
35
+ alerting: AlertRule[];
36
+ recording: number;
37
+ }
38
+ /** Parses Prometheus/Mimir rule files, PrometheusRule resources and /api/v1/rules exports. */
39
+ export declare function parseAlertRules(files: InputDocs[]): AlertRulesSummary;
40
+ /** A rule matches an SLO by a label value or a label matcher in its expression (name-based: an inference). */
41
+ export declare function sloMatchesRule(slo: string, rule: AlertRule): boolean;
42
+ /** One runtime edge. */
43
+ export interface ServiceEdge {
44
+ client: string;
45
+ server: string;
46
+ count: number;
47
+ }
48
+ /** Summary of a service-graph export. */
49
+ export interface ServiceGraphSummary {
50
+ format: "jaeger" | "prometheus";
51
+ edges: ServiceEdge[];
52
+ /** Distinct peers that look like hosts (not named in results, never matched). */
53
+ external_peers: number;
54
+ }
55
+ /** Parses a Jaeger /api/dependencies export or a service-graph metrics query result. */
56
+ export declare function parseServiceGraph(data: unknown, source: string): ServiceGraphSummary;
@@ -0,0 +1,217 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 EnWithAI Technologies
3
+ /**
4
+ * Operational importers for CT-T4 (docs/operational-imports.md): OpenSLO v1 documents, Prometheus/Mimir alert rules
5
+ * and service-graph exports (Jaeger dependencies, `traces_service_graph_request_total` query results). Each reads only
6
+ * the documented fields it needs and rejects inputs it does not recognize instead of guessing. Rule expressions are
7
+ * kept in memory for matching and never written to results.
8
+ */
9
+ import { inputError } from "../errors.js";
10
+ const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
11
+ const str = (v) => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
12
+ const byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
13
+ const OPENSLO_V1 = "openslo/v1";
14
+ /** Parses OpenSLO v1 files; references resolve across all of them by metadata.name. */
15
+ export function parseOpenSlo(files) {
16
+ const slos = [];
17
+ const slis = new Set();
18
+ const policies = new Set();
19
+ let unsupported = 0;
20
+ let other = 0;
21
+ for (const file of files) {
22
+ let v1 = 0;
23
+ for (const doc of file.docs) {
24
+ if (!isObj(doc))
25
+ continue;
26
+ const api = doc["apiVersion"];
27
+ if (typeof api === "string" && api.startsWith("openslo/") && api !== OPENSLO_V1) {
28
+ unsupported++;
29
+ continue;
30
+ }
31
+ if (api !== OPENSLO_V1)
32
+ continue;
33
+ v1++;
34
+ const name = isObj(doc["metadata"]) ? str(doc["metadata"]["name"]) : null;
35
+ const kind = doc["kind"];
36
+ if (kind === "SLO")
37
+ slos.push({
38
+ name: name ?? `(unnamed SLO ${slos.length + 1})`,
39
+ spec: isObj(doc["spec"]) ? doc["spec"] : {},
40
+ });
41
+ else if (kind === "SLI" && name !== null)
42
+ slis.add(name);
43
+ else if (kind === "AlertPolicy" && name !== null)
44
+ policies.add(name);
45
+ else
46
+ other++;
47
+ }
48
+ if (v1 === 0)
49
+ throw inputError(`${file.source} contains no OpenSLO v1 document (apiVersion: ${OPENSLO_V1}).`, "Pass OpenSLO v1 files (SLO, SLI, AlertPolicy) to --openslo.");
50
+ }
51
+ const infos = slos.map(({ name, spec }) => {
52
+ const missing = [];
53
+ const service = str(spec["service"]);
54
+ if (service === null)
55
+ missing.push("service");
56
+ const window = spec["timeWindow"];
57
+ if (!Array.isArray(window) ||
58
+ window.length !== 1 ||
59
+ !isObj(window[0]) ||
60
+ str(window[0]["duration"]) === null)
61
+ missing.push("time window");
62
+ const objectives = Array.isArray(spec["objectives"]) ? spec["objectives"].filter(isObj) : [];
63
+ if (!objectives.some((o) => typeof o["target"] === "number" || typeof o["targetPercent"] === "number"))
64
+ missing.push("objective target");
65
+ const ref = str(spec["indicatorRef"]);
66
+ if (!isObj(spec["indicator"]) && (ref === null || !slis.has(ref)))
67
+ missing.push("indicator");
68
+ const entries = Array.isArray(spec["alertPolicies"]) ? spec["alertPolicies"] : [];
69
+ const dangling = [];
70
+ let resolved = 0;
71
+ for (const entry of entries) {
72
+ if (!isObj(entry))
73
+ continue;
74
+ const policyRef = str(entry["alertPolicyRef"]);
75
+ if (policyRef !== null) {
76
+ if (policies.has(policyRef))
77
+ resolved++;
78
+ else
79
+ dangling.push(policyRef);
80
+ }
81
+ else if (entry["kind"] === "AlertPolicy")
82
+ resolved++;
83
+ }
84
+ return {
85
+ name,
86
+ service,
87
+ missing,
88
+ has_alert_policy: entries.length > 0 && resolved === entries.length,
89
+ dangling_policy_refs: [...new Set(dangling)].sort(),
90
+ };
91
+ });
92
+ return {
93
+ slos: infos.sort(byName),
94
+ slis: slis.size,
95
+ alert_policies: policies.size,
96
+ unsupported,
97
+ other,
98
+ };
99
+ }
100
+ /** The rule groups of one document, in any of the three accepted shapes; null when it has none. */
101
+ function groupsOf(doc) {
102
+ if (!isObj(doc))
103
+ return null;
104
+ if (Array.isArray(doc["groups"]))
105
+ return doc["groups"];
106
+ if (isObj(doc["spec"]) && Array.isArray(doc["spec"]["groups"]))
107
+ return doc["spec"]["groups"];
108
+ if (isObj(doc["data"]) && Array.isArray(doc["data"]["groups"]))
109
+ return doc["data"]["groups"];
110
+ return null;
111
+ }
112
+ /** Parses Prometheus/Mimir rule files, PrometheusRule resources and /api/v1/rules exports. */
113
+ export function parseAlertRules(files) {
114
+ const alerting = [];
115
+ let recording = 0;
116
+ for (const file of files) {
117
+ const groups = file.docs.map(groupsOf).filter((g) => g !== null);
118
+ if (groups.length === 0)
119
+ throw inputError(`${file.source} has no rule groups (groups, spec.groups or data.groups).`, "Pass a Prometheus/Mimir rule file, a PrometheusRule resource or the /api/v1/rules JSON export to --alert-rules.");
120
+ for (const group of groups.flat()) {
121
+ if (!isObj(group) || !Array.isArray(group["rules"]))
122
+ continue;
123
+ for (const rule of group["rules"]) {
124
+ if (!isObj(rule))
125
+ continue;
126
+ const name = str(rule["alert"]) ?? (rule["type"] === "alerting" ? str(rule["name"]) : null);
127
+ if (name === null) {
128
+ if (str(rule["record"]) !== null || rule["type"] === "recording")
129
+ recording++;
130
+ continue;
131
+ }
132
+ const labels = {};
133
+ if (isObj(rule["labels"]))
134
+ for (const [k, v] of Object.entries(rule["labels"]))
135
+ if (typeof v === "string")
136
+ labels[k] = v;
137
+ alerting.push({ name, expr: String(rule["expr"] ?? rule["query"] ?? ""), labels });
138
+ }
139
+ }
140
+ }
141
+ return { alerting: alerting.sort(byName), recording };
142
+ }
143
+ /** A rule matches an SLO by a label value or a label matcher in its expression (name-based: an inference). */
144
+ export function sloMatchesRule(slo, rule) {
145
+ if (Object.values(rule.labels).includes(slo))
146
+ return true;
147
+ const quoted = slo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
148
+ return new RegExp(`[A-Za-z_][A-Za-z0-9_]*\\s*=~?\\s*"${quoted}"`).test(rule.expr);
149
+ }
150
+ /** A peer that looks like a host: contains ':' or a dot, or is an IPv4 address. */
151
+ const hostLike = (name) => name.includes(":") || name.includes(".");
152
+ /** Parses a Jaeger /api/dependencies export or a service-graph metrics query result. */
153
+ export function parseServiceGraph(data, source) {
154
+ const bad = () => {
155
+ throw inputError(`${source} is not a service graph export (Jaeger /api/dependencies, or a traces_service_graph_request_total query result).`, "Export it from Jaeger, Tempo or Prometheus as JSON and pass it to --service-graph.");
156
+ };
157
+ const raw = [];
158
+ let format;
159
+ if (isObj(data) && Array.isArray(data["data"])) {
160
+ format = "jaeger";
161
+ for (const link of data["data"]) {
162
+ if (!isObj(link))
163
+ continue;
164
+ const client = str(link["parent"]);
165
+ const server = str(link["child"]);
166
+ const count = link["callCount"];
167
+ if (client === null || server === null || typeof count !== "number")
168
+ return bad();
169
+ raw.push({ client, server, count });
170
+ }
171
+ }
172
+ else if (isObj(data) &&
173
+ data["status"] === "success" &&
174
+ isObj(data["data"]) &&
175
+ Array.isArray(data["data"]["result"])) {
176
+ format = "prometheus";
177
+ for (const series of data["data"]["result"]) {
178
+ if (!isObj(series) || !isObj(series["metric"]))
179
+ return bad();
180
+ const metric = series["metric"];
181
+ const name = metric["__name__"];
182
+ if (typeof name === "string" && !name.endsWith("service_graph_request_total"))
183
+ return bad();
184
+ const client = str(metric["client"]);
185
+ const server = str(metric["server"]);
186
+ const sample = Array.isArray(series["value"])
187
+ ? series["value"]
188
+ : Array.isArray(series["values"])
189
+ ? series["values"].at(-1)
190
+ : null;
191
+ const count = Array.isArray(sample) ? Number(sample[1]) : NaN;
192
+ if (client === null || server === null || !Number.isFinite(count))
193
+ return bad();
194
+ raw.push({ client, server, count });
195
+ }
196
+ }
197
+ else
198
+ return bad();
199
+ const external = new Set();
200
+ const sums = new Map();
201
+ for (const e of raw) {
202
+ if (e.count <= 0)
203
+ continue;
204
+ if (hostLike(e.client) || hostLike(e.server)) {
205
+ for (const peer of [e.client, e.server])
206
+ if (hostLike(peer))
207
+ external.add(peer);
208
+ continue;
209
+ }
210
+ const key = `${e.client}\u0000${e.server}`;
211
+ const edge = sums.get(key) ?? { client: e.client, server: e.server, count: 0 };
212
+ edge.count += e.count;
213
+ sums.set(key, edge);
214
+ }
215
+ const edges = [...sums.values()].sort((a, b) => a.client === b.client ? (a.server < b.server ? -1 : 1) : a.client < b.client ? -1 : 1);
216
+ return { format, edges, external_peers: external.size };
217
+ }
@@ -36,3 +36,27 @@ export interface OutcomeCheckSummary {
36
36
  }
37
37
  /** Parses an Outcome Check report (schema 1.0); reads the all-PR segment of two metrics. */
38
38
  export declare function parseOutcomeCheck(data: unknown, source: string): OutcomeCheckSummary;
39
+ /** Summary of an Architecture Check report. */
40
+ export interface ArchitectureCheckSummary {
41
+ schema_version: string;
42
+ model_path: string | null;
43
+ model_format: string | null;
44
+ model_usable: boolean;
45
+ complete_mapping: boolean;
46
+ findings: number;
47
+ /** Verified static facts of high or medium severity. */
48
+ verified_drift: number;
49
+ verified_rules: string[];
50
+ hypotheses: number;
51
+ unknown: number;
52
+ /** Declared relationships with no static evidence (`relationship-unobserved`), as declared IDs. */
53
+ unobserved: {
54
+ from: string;
55
+ to: string;
56
+ }[];
57
+ }
58
+ /**
59
+ * Parses an Architecture Check `analyze`/`check` report (schema 1.0 or 1.1; 1.1 only adds enum values). A `derive`
60
+ * report has no declared model and describes no drift, so it is rejected.
61
+ */
62
+ export declare function parseArchitectureCheck(data: unknown, source: string): ArchitectureCheckSummary;
@@ -2,7 +2,8 @@
2
2
  // SPDX-FileCopyrightText: 2026 EnWithAI Technologies
3
3
  /**
4
4
  * Optional importers for artifacts produced by other tools: `npm audit --json`, and the JSON reports of the
5
- * sibling Engineering Commons tools (Spec Check 1.0, Impact Check 2.0, Outcome Check 1.0). Each importer reads
5
+ * sibling Engineering Commons tools (Spec Check 1.0, Impact Check 2.0, Outcome Check 1.0, Architecture Check 1.0/1.1).
6
+ * Each importer reads
6
7
  * only the documented fields it needs and rejects unknown schema versions instead of guessing.
7
8
  */
8
9
  import { inputError } from "../errors.js";
@@ -82,3 +83,38 @@ export function parseOutcomeCheck(data, source) {
82
83
  reverted_prs: reverts && typeof reverts["numerator"] === "number" ? reverts["numerator"] : null,
83
84
  };
84
85
  }
86
+ /**
87
+ * Parses an Architecture Check `analyze`/`check` report (schema 1.0 or 1.1; 1.1 only adds enum values). A `derive`
88
+ * report has no declared model and describes no drift, so it is rejected.
89
+ */
90
+ export function parseArchitectureCheck(data, source) {
91
+ if (!isObj(data) ||
92
+ data["kind"] !== "architecture-check-report" ||
93
+ (data["schema_version"] !== "1.0" && data["schema_version"] !== "1.1") ||
94
+ !Array.isArray(data["findings"]) ||
95
+ !isObj(data["declared_model"])) {
96
+ throw inputError(`${source} is not an Architecture Check report with a declared model (schema 1.0 or 1.1).`, "Produce it with `architecture-check check --format json` (or `analyze`); `derive` output has no drift to import.");
97
+ }
98
+ const model = data["declared_model"];
99
+ const findings = data["findings"].filter(isObj);
100
+ const drift = findings.filter((f) => f["status"] === "verified_static_fact" && (f["severity"] === "high" || f["severity"] === "medium"));
101
+ const str = (v) => (typeof v === "string" ? v : null);
102
+ return {
103
+ schema_version: data["schema_version"],
104
+ model_path: str(model["path"]),
105
+ model_format: str(model["format"]),
106
+ model_usable: model["usable"] === true,
107
+ complete_mapping: data["complete_mapping"] === true,
108
+ findings: findings.length,
109
+ verified_drift: drift.length,
110
+ verified_rules: [...new Set(drift.map((f) => String(f["rule_id"])))].sort(),
111
+ hypotheses: findings.filter((f) => f["status"] === "hypothesis").length,
112
+ unknown: findings.filter((f) => f["status"] === "unknown").length,
113
+ unobserved: findings
114
+ .filter((f) => f["rule_id"] === "relationship-unobserved" && typeof f["subject"] === "string")
115
+ .map((f) => /^(\S+) -> (\S+)$/.exec(f["subject"]))
116
+ .filter((m) => m !== null)
117
+ .map((m) => ({ from: m[1], to: m[2] }))
118
+ .sort((a, b) => (`${a.from} ${a.to}` < `${b.from} ${b.to}` ? -1 : 1)),
119
+ };
120
+ }
@@ -0,0 +1,30 @@
1
+ import { type RepoFacts } from "./facts.js";
2
+ export type SurfaceKind = "page" | "endpoint" | "entity" | "config" | "integration" | "auth-boundary" | "test";
3
+ /** One element of the product surface. */
4
+ export interface SurfaceItem {
5
+ kind: SurfaceKind;
6
+ /** Route path, entity name, variable name, package name or test title. */
7
+ name: string;
8
+ /** HTTP method for endpoints (`ANY` when the handler dispatches on method itself); null otherwise. */
9
+ method: string | null;
10
+ /** Grouping area: first meaningful route segment, or the entity/integration category. */
11
+ area: string;
12
+ /** Short human-readable detail (fields, category, matcher, how it was recognised). */
13
+ detail: string;
14
+ path: string;
15
+ line: number | null;
16
+ excerpt: string | null;
17
+ certainty: "observed" | "inferred";
18
+ /** Endpoints and pages whose source references a session or auth helper (inference). */
19
+ auth_hint: boolean;
20
+ }
21
+ /** Everything derived about the surface. */
22
+ export interface ProductSurface {
23
+ items: SurfaceItem[];
24
+ /** Notes about what could not be determined (for the spec's uncertainties). */
25
+ notes: string[];
26
+ }
27
+ /** The grouping area of a URL path: first static segment after an optional `api` prefix. */
28
+ export declare function areaOf(route: string): string;
29
+ /** Derives the product surface. */
30
+ export declare function deriveSurface(facts: RepoFacts): ProductSurface;