@enwithai/product-check 0.1.0 → 0.3.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.
- package/ATTRIBUTION.md +6 -6
- package/CHANGELOG.md +66 -0
- package/NOTICE.md +5 -3
- package/README.md +34 -19
- package/SECURITY.md +5 -1
- package/dashboard/assurance.css +71 -0
- package/dashboard/assurance.js +21 -0
- package/dashboard/dashboard.css +6 -0
- package/dist/assurance/adapters.d.ts +32 -0
- package/dist/assurance/adapters.js +76 -0
- package/dist/assurance/approvals.d.ts +34 -0
- package/dist/assurance/approvals.js +133 -0
- package/dist/assurance/cli.d.ts +13 -0
- package/dist/assurance/cli.js +863 -0
- package/dist/assurance/dashboard.d.ts +18 -0
- package/dist/assurance/dashboard.js +267 -0
- package/dist/assurance/diff.d.ts +20 -0
- package/dist/assurance/diff.js +99 -0
- package/dist/assurance/evidence-log.d.ts +20 -0
- package/dist/assurance/evidence-log.js +100 -0
- package/dist/assurance/fix-packets.d.ts +12 -0
- package/dist/assurance/fix-packets.js +178 -0
- package/dist/assurance/gate.d.ts +37 -0
- package/dist/assurance/gate.js +324 -0
- package/dist/assurance/lanes.d.ts +61 -0
- package/dist/assurance/lanes.js +380 -0
- package/dist/assurance/policy.d.ts +10 -0
- package/dist/assurance/policy.js +85 -0
- package/dist/assurance/proof.d.ts +35 -0
- package/dist/assurance/proof.js +146 -0
- package/dist/assurance/register.d.ts +41 -0
- package/dist/assurance/register.js +480 -0
- package/dist/assurance/render-md.d.ts +17 -0
- package/dist/assurance/render-md.js +212 -0
- package/dist/assurance/repo-state.d.ts +15 -0
- package/dist/assurance/repo-state.js +79 -0
- package/dist/assurance/review.d.ts +52 -0
- package/dist/assurance/review.js +408 -0
- package/dist/assurance/risk.d.ts +21 -0
- package/dist/assurance/risk.js +184 -0
- package/dist/assurance/types.d.ts +573 -0
- package/dist/assurance/types.js +6 -0
- package/dist/assurance/util.d.ts +24 -0
- package/dist/assurance/util.js +89 -0
- package/dist/assurance/verification.d.ts +30 -0
- package/dist/assurance/verification.js +212 -0
- package/dist/assurance/verify.d.ts +26 -0
- package/dist/assurance/verify.js +304 -0
- package/dist/blueprint/build.d.ts +3 -1
- package/dist/blueprint/build.js +41 -5
- package/dist/cli/run.js +103 -5
- package/dist/contract/contract.d.ts +4 -0
- package/dist/contract/contract.js +16 -0
- package/dist/dashboard/render.d.ts +5 -0
- package/dist/dashboard/render.js +107 -3
- package/dist/discovery/pops.d.ts +2 -0
- package/dist/discovery/pops.js +7 -5
- package/dist/exec/process.d.ts +27 -0
- package/dist/exec/process.js +175 -0
- package/dist/importers/operational.d.ts +56 -0
- package/dist/importers/operational.js +217 -0
- package/dist/importers/reports.d.ts +24 -0
- package/dist/importers/reports.js +37 -1
- package/dist/inspectors/source/surface.d.ts +30 -0
- package/dist/inspectors/source/surface.js +529 -0
- package/dist/parse/yaml.d.ts +4 -0
- package/dist/parse/yaml.js +39 -0
- package/dist/reporting/markdown.d.ts +1 -0
- package/dist/reporting/markdown.js +18 -1
- package/dist/reporting/spec-markdown.d.ts +9 -0
- package/dist/reporting/spec-markdown.js +144 -0
- package/dist/run.d.ts +2 -0
- package/dist/run.js +52 -2
- package/dist/schema/registry.js +10 -0
- package/dist/scoring/score.js +1 -1
- package/dist/spec/build.d.ts +28 -0
- package/dist/spec/build.js +278 -0
- package/dist/types.d.ts +78 -2
- package/dist/types.js +6 -1
- package/dist/validation/catalog.d.ts +1 -1
- package/dist/validation/catalog.js +125 -2
- package/dist/validation/context.d.ts +6 -1
- package/dist/validation/import-evaluators.js +37 -3
- package/dist/validation/integrity.js +4 -0
- package/dist/validation/operational-evaluators.d.ts +3 -0
- package/dist/validation/operational-evaluators.js +153 -0
- package/dist/validation/source-evaluators.js +1 -1
- package/package.json +4 -1
- package/schemas/approval.schema.json +98 -0
- package/schemas/as-built-spec.schema.json +356 -0
- package/schemas/assurance-common.schema.json +291 -0
- package/schemas/assurance-policy.schema.json +185 -0
- package/schemas/blueprint.schema.json +3 -0
- package/schemas/change-review.schema.json +711 -0
- package/schemas/closure-register.schema.json +499 -0
- package/schemas/evidence-record.schema.json +94 -0
- package/schemas/fix-packet.schema.json +272 -0
- package/schemas/release-gate.schema.json +308 -0
- package/schemas/reviewer-findings.schema.json +49 -0
- package/schemas/scan-contract.schema.json +20 -0
- package/schemas/scorecard.schema.json +1 -1
- package/schemas/verification-run.schema.json +199 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 EnWithAI Technologies
|
|
3
|
+
/**
|
|
4
|
+
* The ONLY module in src/ that starts processes (tests enforce this). Two narrow entry points:
|
|
5
|
+
*
|
|
6
|
+
* - `gitRead`: read-only git plumbing (rev-parse, diff, show, status, log, merge-base) with a fixed, hardened
|
|
7
|
+
* argument prefix: no hooks, no fsmonitor, no external diff or textconv drivers, no pager, no system config, no
|
|
8
|
+
* prompts. Revisions are validated so a value can never be read as an option.
|
|
9
|
+
* - `execCommand`: runs one operator-declared command (argv, never a shell) for author-side proof (VC-P1) and
|
|
10
|
+
* closure verification (VC-P5). Callers must have checked that execution was authorized for this invocation
|
|
11
|
+
* (`--allow-exec`); this module refuses otherwise. The environment is reduced to an allowlist, output is capped,
|
|
12
|
+
* digested and redacted, and nothing is retried.
|
|
13
|
+
*
|
|
14
|
+
* The scan pipeline (`run`, `retest`, `scan`, `spec`) never calls this module.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { devNull } from "node:os";
|
|
18
|
+
import { resolve, sep } from "node:path";
|
|
19
|
+
import { safetyError } from "../errors.js";
|
|
20
|
+
import { sha256 } from "../util/hash.js";
|
|
21
|
+
import { redact } from "../util/redact.js";
|
|
22
|
+
import { stableId } from "../util/hash.js";
|
|
23
|
+
const GIT_SUBCOMMANDS = new Set(["rev-parse", "diff", "show", "status", "log", "merge-base"]);
|
|
24
|
+
const GIT_PREFIX = [
|
|
25
|
+
"-c",
|
|
26
|
+
"core.fsmonitor=false",
|
|
27
|
+
"-c",
|
|
28
|
+
`core.hooksPath=${devNull}`,
|
|
29
|
+
"-c",
|
|
30
|
+
"diff.external=",
|
|
31
|
+
"-c",
|
|
32
|
+
"core.pager=cat",
|
|
33
|
+
"-c",
|
|
34
|
+
"protocol.allow=never",
|
|
35
|
+
"--no-pager",
|
|
36
|
+
];
|
|
37
|
+
/** Runs a read-only git subcommand in `repo`. */
|
|
38
|
+
export function gitRead(repo, args, maxBuffer = 64 * 1024 * 1024) {
|
|
39
|
+
const sub = args[0];
|
|
40
|
+
if (sub === undefined || !GIT_SUBCOMMANDS.has(sub))
|
|
41
|
+
throw safetyError(`git ${sub ?? ""} is not a read-only subcommand.`);
|
|
42
|
+
const extra = sub === "diff" || sub === "show" ? ["--no-ext-diff", "--no-textconv", "--no-color"] : [];
|
|
43
|
+
const r = spawnSync("git", [...GIT_PREFIX, sub, ...extra, ...args.slice(1)], {
|
|
44
|
+
cwd: repo,
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
maxBuffer,
|
|
47
|
+
shell: false,
|
|
48
|
+
windowsHide: true,
|
|
49
|
+
env: {
|
|
50
|
+
PATH: process.env["PATH"] ?? "",
|
|
51
|
+
HOME: process.env["HOME"] ?? "",
|
|
52
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
53
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
54
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
55
|
+
LC_ALL: "C",
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
ok: r.status === 0 && !r.error,
|
|
60
|
+
stdout: r.stdout ?? "",
|
|
61
|
+
stderr: r.stderr ?? r.error?.message ?? "",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** A NOT_TESTED/BLOCKED execution record that ran nothing. */
|
|
65
|
+
function notRun(input, status, reason) {
|
|
66
|
+
return {
|
|
67
|
+
execution_id: stableId("EXE", [
|
|
68
|
+
input.spec.id,
|
|
69
|
+
input.spec.argv.join(" "),
|
|
70
|
+
input.commitSha,
|
|
71
|
+
status,
|
|
72
|
+
reason,
|
|
73
|
+
]),
|
|
74
|
+
command_id: input.spec.id,
|
|
75
|
+
purpose: input.spec.purpose,
|
|
76
|
+
argv: input.spec.argv,
|
|
77
|
+
cwd: input.spec.cwd,
|
|
78
|
+
status,
|
|
79
|
+
exit_code: null,
|
|
80
|
+
signal: null,
|
|
81
|
+
duration_ms: 0,
|
|
82
|
+
started_at: input.now().toISOString(),
|
|
83
|
+
environment_id: input.environmentId,
|
|
84
|
+
commit_sha: input.commitSha,
|
|
85
|
+
stdout_digest: null,
|
|
86
|
+
stderr_digest: null,
|
|
87
|
+
output_bytes: 0,
|
|
88
|
+
output_truncated: false,
|
|
89
|
+
excerpt: null,
|
|
90
|
+
reason,
|
|
91
|
+
evidence_id: null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Executes one declared command (argv, no shell). Never throws for command failures. */
|
|
95
|
+
export function execCommand(input) {
|
|
96
|
+
const { spec } = input;
|
|
97
|
+
if (!input.permission.allowed)
|
|
98
|
+
return notRun(input, "NOT_TESTED", input.permission.reason);
|
|
99
|
+
if (spec.argv.length === 0 || spec.argv[0] === "")
|
|
100
|
+
return notRun(input, "BLOCKED", "Empty command.");
|
|
101
|
+
const root = resolve(input.repo);
|
|
102
|
+
const cwd = resolve(root, spec.cwd);
|
|
103
|
+
if (cwd !== root && !cwd.startsWith(root + sep))
|
|
104
|
+
return notRun(input, "BLOCKED", `Working directory ${spec.cwd} is outside the repository.`);
|
|
105
|
+
const env = { CI: "1", PRODUCT_CHECK_VERIFY: "1" };
|
|
106
|
+
for (const name of input.envAllowlist) {
|
|
107
|
+
const v = process.env[name];
|
|
108
|
+
if (v !== undefined)
|
|
109
|
+
env[name] = v;
|
|
110
|
+
}
|
|
111
|
+
const started = input.now();
|
|
112
|
+
const t0 = performance.now();
|
|
113
|
+
const r = spawnSync(spec.argv[0], spec.argv.slice(1), {
|
|
114
|
+
cwd,
|
|
115
|
+
env,
|
|
116
|
+
shell: false,
|
|
117
|
+
windowsHide: true,
|
|
118
|
+
timeout: spec.timeout_ms,
|
|
119
|
+
maxBuffer: input.maxOutputBytes,
|
|
120
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
121
|
+
});
|
|
122
|
+
const duration = Math.round(performance.now() - t0);
|
|
123
|
+
const stdout = r.stdout ?? Buffer.alloc(0);
|
|
124
|
+
const stderr = r.stderr ?? Buffer.alloc(0);
|
|
125
|
+
const err = r.error;
|
|
126
|
+
const truncated = err?.code === "ENOBUFS";
|
|
127
|
+
const timedOut = err?.code === "ETIMEDOUT" || (r.signal === "SIGTERM" && duration >= spec.timeout_ms);
|
|
128
|
+
const spawnFailed = err !== undefined && !truncated && !timedOut;
|
|
129
|
+
const combined = `${stdout.toString("utf8")}\n${stderr.toString("utf8")}`.trim();
|
|
130
|
+
const excerpt = combined ? redact(combined.slice(-400)) : null;
|
|
131
|
+
let status;
|
|
132
|
+
let reason = null;
|
|
133
|
+
if (spawnFailed) {
|
|
134
|
+
status = "BLOCKED";
|
|
135
|
+
reason = `Could not start ${spec.argv[0]}: ${err.code ?? err.message}`;
|
|
136
|
+
}
|
|
137
|
+
else if (timedOut) {
|
|
138
|
+
status = "FAIL";
|
|
139
|
+
reason = `Timed out after ${spec.timeout_ms} ms.`;
|
|
140
|
+
}
|
|
141
|
+
else if (truncated) {
|
|
142
|
+
status = "FAIL";
|
|
143
|
+
reason = `Output exceeded ${input.maxOutputBytes} bytes; the run was stopped.`;
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
status = r.status === 0 ? "PASS" : "FAIL";
|
|
147
|
+
return {
|
|
148
|
+
execution_id: stableId("EXE", [
|
|
149
|
+
spec.id,
|
|
150
|
+
spec.argv.join(" "),
|
|
151
|
+
input.commitSha,
|
|
152
|
+
started.toISOString(),
|
|
153
|
+
sha256(stdout),
|
|
154
|
+
sha256(stderr),
|
|
155
|
+
]),
|
|
156
|
+
command_id: spec.id,
|
|
157
|
+
purpose: spec.purpose,
|
|
158
|
+
argv: spec.argv,
|
|
159
|
+
cwd: spec.cwd,
|
|
160
|
+
status,
|
|
161
|
+
exit_code: r.status,
|
|
162
|
+
signal: r.signal ?? null,
|
|
163
|
+
duration_ms: duration,
|
|
164
|
+
started_at: started.toISOString(),
|
|
165
|
+
environment_id: input.environmentId,
|
|
166
|
+
commit_sha: input.commitSha,
|
|
167
|
+
stdout_digest: sha256(stdout),
|
|
168
|
+
stderr_digest: sha256(stderr),
|
|
169
|
+
output_bytes: stdout.length + stderr.length,
|
|
170
|
+
output_truncated: truncated,
|
|
171
|
+
excerpt,
|
|
172
|
+
reason,
|
|
173
|
+
evidence_id: null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -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
|
|
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;
|