@awak-app/simy-cli 0.3.3 → 0.4.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.
@@ -0,0 +1,249 @@
1
+ import { createHash, createPublicKey } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { verifyBundleIntegrity } from "./canonical.js";
7
+ import { validateKnowledgeBundle } from "./validation.js";
8
+
9
+ const DEFAULT_TTL_MS = 5 * 60 * 1000;
10
+ export const SQM_BUNDLE_FETCH_TIMEOUT_MS = 15_000;
11
+
12
+ export async function loadKnowledgeBundle({
13
+ repository,
14
+ cliVersion,
15
+ endpoint = process.env.SIMY_SQM_BUNDLE_URL,
16
+ token = process.env.SIMY_SQM_TOKEN || process.env.SIMY_ACCESS_TOKEN,
17
+ accountId = process.env.SIMY_SQM_ACCOUNT_ID || (token ? `token:${createHash("sha256").update(token).digest("hex")}` : "anonymous"),
18
+ organizationId = process.env.SIMY_SQM_ORGANIZATION_ID,
19
+ cacheRoot = process.env.SIMY_SQM_CACHE_DIR || path.join(process.env.SIMY_HOME?.trim() || path.join(homedir(), ".simy"), "sqm"),
20
+ ttlMs = numberEnv("SIMY_SQM_CACHE_TTL_MS", DEFAULT_TTL_MS),
21
+ offline = false,
22
+ refresh = false,
23
+ fetchImpl = globalThis.fetch,
24
+ now = Date.now(),
25
+ publicKeys = publicKeysFromEnvironment(),
26
+ }) {
27
+ if (!organizationId) {
28
+ return result(null, ["SQM organization identity is unavailable; shadow checks were skipped."], "unavailable");
29
+ }
30
+ const cacheIdentity = normalizedCacheIdentity({ endpoint, accountId, organizationId, repository });
31
+ const location = cacheLocation(cacheRoot, cacheIdentity);
32
+ const validation = { cliVersion, publicKeys, organizationId };
33
+ const cached = await readVerifiedCache(location, validation, cacheIdentity);
34
+ const fresh =
35
+ cached &&
36
+ now - cached.fetchedAt < ttlMs &&
37
+ Date.parse(cached.bundle.expires_at) > now;
38
+ if (!offline && endpoint && (!fresh || refresh)) {
39
+ try {
40
+ const fetched = await fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag: cached?.etag, fetchImpl });
41
+ if (fetched.notModified) {
42
+ if (!cached) throw new Error("SQM Cloud returned 304 without a verified local bundle.");
43
+ if (Date.parse(cached.bundle.expires_at) <= now) throw nonFallbackError("SQM Cloud returned 304 for an expired knowledge bundle.");
44
+ await writeMetadata(location, { ...cached.metadata, fetched_at: new Date(now).toISOString(), etag: fetched.etag || cached.etag });
45
+ return result(cached.bundle, [], "cache_revalidated");
46
+ }
47
+ const discoveredTrust = signingTrustForFetch({
48
+ bundle: fetched.bundle,
49
+ endpoint,
50
+ publicKeys,
51
+ signingKeyId: fetched.signingKeyId,
52
+ signingPublicKey: fetched.signingPublicKey,
53
+ });
54
+ const bundle = verifyAndValidate(fetched.bundle, {
55
+ ...validation,
56
+ publicKeys: discoveredTrust.publicKeys,
57
+ });
58
+ if (Date.parse(bundle.expires_at) <= now) {
59
+ throw nonFallbackError("SQM Cloud returned an expired knowledge bundle.");
60
+ }
61
+ if (fetched.etag && unquote(fetched.etag) !== bundle.integrity.digest) throw nonFallbackError("SQM response ETag does not match the bundle digest.");
62
+ await writeCache(location, bundle, {
63
+ ...cacheIdentity,
64
+ fetched_at: new Date(now).toISOString(),
65
+ etag: fetched.etag || `\"${bundle.integrity.digest}\"`,
66
+ signing_key_id: discoveredTrust.signingKeyId,
67
+ signing_public_key: discoveredTrust.signingPublicKey,
68
+ });
69
+ return result(bundle, [], "cloud");
70
+ } catch (error) {
71
+ if (error?.fallbackAllowed !== false && cached && Date.parse(cached.bundle.expires_at) > now) return result(cached.bundle, [`SQM Cloud unavailable; using last verified bundle: ${message(error)}`], "stale_cache");
72
+ return result(null, [`SQM knowledge could not be used; shadow checks were skipped: ${message(error)}`], "unavailable");
73
+ }
74
+ }
75
+ if (cached) {
76
+ if (Date.parse(cached.bundle.expires_at) <= now) return result(null, ["The cached SQM knowledge bundle is expired; shadow checks were skipped."], "unavailable");
77
+ const warnings = [];
78
+ if (offline) warnings.push("SQM is offline; using the last verified knowledge bundle.");
79
+ else if (!endpoint) warnings.push("SQM Cloud endpoint is not configured; using the last verified knowledge bundle.");
80
+ return result(cached.bundle, warnings, fresh ? "cache" : "stale_cache");
81
+ }
82
+ const reason = offline ? "offline mode" : "SQM Cloud endpoint is not configured";
83
+ return result(null, [`SQM has no verified cache (${reason}); shadow checks were skipped.`], "unavailable");
84
+ }
85
+
86
+ export function verifyAndValidate(bundle, { cliVersion, publicKeys, organizationId } = {}) {
87
+ try { validateKnowledgeBundle(bundle, { cliVersion }); } catch (error) { throw nonFallbackError(message(error), error); }
88
+ if (organizationId && bundle.organization_id !== organizationId) throw nonFallbackError(`SQM bundle organization mismatch: expected ${organizationId}.`);
89
+ for (const module of bundle.modules) {
90
+ if (module.scope.visibility === "organization" && module.scope.organization_id !== bundle.organization_id) throw nonFallbackError(`SQM module ${module.id} is scoped to a different organization.`);
91
+ }
92
+ const keyId = bundle.integrity.key_id;
93
+ const key = publicKeys.get(keyId);
94
+ if (!key) throw nonFallbackError(`No trusted SQM public key is configured for ${keyId}.`);
95
+ try { verifyBundleIntegrity(bundle, key); } catch (error) { throw nonFallbackError(message(error), error); }
96
+ return bundle;
97
+ }
98
+
99
+ export function publicKeysFromEnvironment(env = process.env) {
100
+ const keys = new Map();
101
+ if (env.SIMY_SQM_PUBLIC_KEYS_JSON) {
102
+ let parsed;
103
+ try { parsed = JSON.parse(env.SIMY_SQM_PUBLIC_KEYS_JSON); } catch { throw new Error("SIMY_SQM_PUBLIC_KEYS_JSON is not valid JSON."); }
104
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("SIMY_SQM_PUBLIC_KEYS_JSON must be an object keyed by key ID.");
105
+ for (const [id, value] of Object.entries(parsed)) keys.set(id, parsePublicKey(value));
106
+ }
107
+ if (env.SIMY_SQM_PUBLIC_KEY && env.SIMY_SQM_KEY_ID) keys.set(env.SIMY_SQM_KEY_ID, parsePublicKey(env.SIMY_SQM_PUBLIC_KEY));
108
+ return keys;
109
+ }
110
+
111
+ function parsePublicKey(value) {
112
+ const text = String(value || "").replace(/\\n/g, "\n").trim();
113
+ if (text.startsWith("-----BEGIN")) return createPublicKey(text);
114
+ const raw = Buffer.from(text, "base64");
115
+ if (raw.length === 32) {
116
+ const prefix = Buffer.from("302a300506032b6570032100", "hex");
117
+ return createPublicKey({ key: Buffer.concat([prefix, raw]), format: "der", type: "spki" });
118
+ }
119
+ return createPublicKey({ key: raw, format: "der", type: "spki" });
120
+ }
121
+
122
+ function signingTrustForFetch({ bundle, endpoint, publicKeys, signingKeyId, signingPublicKey }) {
123
+ const keyId = bundle?.integrity?.key_id;
124
+ if (publicKeys.has(keyId)) {
125
+ return { publicKeys, signingKeyId: null, signingPublicKey: null };
126
+ }
127
+ if (!trustedDiscoveryEndpoint(endpoint)) {
128
+ throw nonFallbackError("SQM signing-key discovery requires authenticated HTTPS or loopback.");
129
+ }
130
+ if (!signingKeyId || signingKeyId !== keyId || !signingPublicKey) {
131
+ throw nonFallbackError(`SQM Cloud did not provide the trusted public key for ${keyId}.`);
132
+ }
133
+ const discovered = new Map(publicKeys);
134
+ try {
135
+ discovered.set(keyId, parsePublicKey(signingPublicKey));
136
+ } catch (error) {
137
+ throw nonFallbackError(`SQM Cloud returned an invalid public key for ${keyId}.`, error);
138
+ }
139
+ return {
140
+ publicKeys: discovered,
141
+ signingKeyId,
142
+ signingPublicKey: String(signingPublicKey).trim(),
143
+ };
144
+ }
145
+
146
+ function trustedDiscoveryEndpoint(endpoint) {
147
+ const url = new URL(endpoint);
148
+ return url.protocol === "https:" ||
149
+ (url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname));
150
+ }
151
+
152
+ async function fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag, fetchImpl }) {
153
+ const url = new URL(endpoint);
154
+ url.searchParams.set("repository", repository);
155
+ url.searchParams.set("cli_version", cliVersion);
156
+ const headers = { Accept: "application/json" };
157
+ if (token) headers.Authorization = `Bearer ${token}`;
158
+ if (organizationId) headers["x-simy-org-id"] = organizationId;
159
+ if (etag) headers["If-None-Match"] = etag;
160
+ const response = await fetchImpl(url, {
161
+ headers,
162
+ signal: AbortSignal.timeout(SQM_BUNDLE_FETCH_TIMEOUT_MS),
163
+ });
164
+ const responseEtag = response.headers?.get?.("etag") || null;
165
+ const signingKeyId = response.headers?.get?.("x-simy-sqm-key-id") || null;
166
+ const signingPublicKey = response.headers?.get?.("x-simy-sqm-public-key") || null;
167
+ if (response.status === 304) {
168
+ return { notModified: true, etag: responseEtag, signingKeyId, signingPublicKey };
169
+ }
170
+ if (!response.ok) {
171
+ const body = await response.text().catch(() => "");
172
+ const error = new Error(`SQM Cloud returned HTTP ${response.status}${body ? `: ${body.slice(0, 300)}` : ""}.`);
173
+ error.fallbackAllowed = response.status >= 500 && response.status <= 599;
174
+ throw error;
175
+ }
176
+ try {
177
+ return {
178
+ notModified: false,
179
+ etag: responseEtag,
180
+ signingKeyId,
181
+ signingPublicKey,
182
+ bundle: await response.json(),
183
+ };
184
+ } catch (error) {
185
+ throw nonFallbackError("SQM Cloud returned an invalid JSON knowledge bundle.", error);
186
+ }
187
+ }
188
+
189
+ async function readVerifiedCache(location, options, identity) {
190
+ try {
191
+ const [bundleRaw, metadataRaw] = await Promise.all([readFile(location.bundle, "utf8"), readFile(location.metadata, "utf8")]);
192
+ const metadata = JSON.parse(metadataRaw);
193
+ for (const key of ["endpoint", "account_id", "organization_id", "repository"]) if (metadata[key] !== identity[key]) throw new Error("SQM cache identity does not match the current tenant context.");
194
+ const publicKeys = new Map(options.publicKeys);
195
+ if (metadata.signing_key_id && metadata.signing_public_key && !publicKeys.has(metadata.signing_key_id)) {
196
+ publicKeys.set(metadata.signing_key_id, parsePublicKey(metadata.signing_public_key));
197
+ }
198
+ const bundle = verifyAndValidate(JSON.parse(bundleRaw), { ...options, publicKeys });
199
+ if (metadata.digest !== bundle.integrity.digest || metadata.bundle_id !== bundle.bundle_id || metadata.version !== bundle.version) throw new Error("SQM cache metadata does not match its bundle.");
200
+ if (metadata.signing_key_id && metadata.signing_key_id !== bundle.integrity.key_id) throw new Error("SQM cache signing key does not match its bundle.");
201
+ return { bundle, metadata, etag: metadata.etag || null, fetchedAt: Date.parse(metadata.fetched_at) || 0 };
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ async function writeCache(location, bundle, metadata) {
208
+ await mkdir(location.directory, { recursive: true, mode: 0o700 });
209
+ await atomicWrite(location.bundle, `${JSON.stringify(bundle)}\n`);
210
+ await writeMetadata(location, { ...metadata, bundle_id: bundle.bundle_id, version: bundle.version, digest: bundle.integrity.digest, verified_at: new Date().toISOString() });
211
+ }
212
+
213
+ async function writeMetadata(location, metadata) {
214
+ await mkdir(location.directory, { recursive: true, mode: 0o700 });
215
+ await atomicWrite(location.metadata, `${JSON.stringify(metadata)}\n`);
216
+ }
217
+
218
+ async function atomicWrite(target, content) {
219
+ const temporary = `${target}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
220
+ await writeFile(temporary, content, { encoding: "utf8", mode: 0o600 });
221
+ await rename(temporary, target);
222
+ }
223
+
224
+ function cacheLocation(root, identity) {
225
+ const id = createHash("sha256").update(JSON.stringify(identity)).digest("hex");
226
+ const directory = path.join(root, id);
227
+ return { directory, bundle: path.join(directory, "bundle.json"), metadata: path.join(directory, "metadata.json") };
228
+ }
229
+
230
+ function normalizedCacheIdentity({ endpoint, accountId, organizationId, repository }) {
231
+ let normalizedEndpoint = "unconfigured";
232
+ if (endpoint) {
233
+ const url = new URL(endpoint);
234
+ url.hash = "";
235
+ normalizedEndpoint = url.href;
236
+ }
237
+ return { endpoint: normalizedEndpoint, account_id: String(accountId || "anonymous"), organization_id: organizationId ? String(organizationId) : null, repository: String(repository).toLowerCase() };
238
+ }
239
+
240
+ function nonFallbackError(message, cause) {
241
+ const error = new Error(message, { cause });
242
+ error.fallbackAllowed = false;
243
+ return error;
244
+ }
245
+
246
+ function result(bundle, warnings, source) { return { bundle, warnings, source }; }
247
+ function message(error) { return error instanceof Error ? error.message : String(error); }
248
+ function numberEnv(name, fallback) { const parsed = Number(process.env[name]); return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; }
249
+ function unquote(value) { return String(value).replace(/^W\//, "").replace(/^\"|\"$/g, ""); }
@@ -0,0 +1,45 @@
1
+ import { createHash, verify } from "node:crypto";
2
+
3
+ export function canonicalize(value) {
4
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
5
+ return JSON.stringify(value);
6
+ }
7
+ if (typeof value === "number") {
8
+ if (!Number.isFinite(value)) throw new TypeError("JCS does not allow non-finite numbers.");
9
+ return JSON.stringify(value);
10
+ }
11
+ if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
12
+ if (value && typeof value === "object") {
13
+ return `{${Object.keys(value)
14
+ .sort()
15
+ .map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`)
16
+ .join(",")}}`;
17
+ }
18
+ throw new TypeError(`JCS does not allow ${typeof value} values.`);
19
+ }
20
+
21
+ export function bundlePayloadBytes(bundle) {
22
+ const { integrity: _integrity, ...payload } = bundle;
23
+ return Buffer.from(canonicalize(payload), "utf8");
24
+ }
25
+
26
+ export function bundleDigest(bundle) {
27
+ return createHash("sha256").update(bundlePayloadBytes(bundle)).digest();
28
+ }
29
+
30
+ export function verifyBundleIntegrity(bundle, publicKey) {
31
+ const digest = bundleDigest(bundle);
32
+ const expected = `sha256:${digest.toString("hex")}`;
33
+ if (bundle?.integrity?.digest !== expected) {
34
+ throw new Error(`SQM bundle digest mismatch: expected ${expected}.`);
35
+ }
36
+ const encoded = String(bundle?.integrity?.signature || "");
37
+ if (!/^[A-Za-z0-9+/]+$/.test(encoded)) {
38
+ throw new Error("SQM bundle signature is not valid base64.");
39
+ }
40
+ const signature = Buffer.from(encoded, "base64");
41
+ if (signature.length !== 64 || !verify(null, digest, publicKey, signature)) {
42
+ throw new Error("SQM bundle Ed25519 signature verification failed.");
43
+ }
44
+ return expected;
45
+ }
@@ -0,0 +1,299 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ const MAX_FILE_BYTES = 1024 * 1024;
6
+ const MAX_FILES_PER_PATTERN = 5_000;
7
+
8
+ export async function executeKnowledgeBundle(bundle, repository) {
9
+ return (await evaluateKnowledgeBundle(bundle, repository)).findings;
10
+ }
11
+
12
+ export async function evaluateKnowledgeBundle(bundle, repository) {
13
+ const findings = [];
14
+ const modules = [];
15
+ for (const module of bundle.modules) {
16
+ if (["candidate", "retired"].includes(module.lifecycle.status)) continue;
17
+ if (!moduleApplies(module, repository.repository)) continue;
18
+ if (!(await matchModule(module, repository))) continue;
19
+ const exceptions = activeExceptionPatterns(module);
20
+ const ruleResults = [];
21
+ for (const rule of module.detection.checks) {
22
+ const violations = await executeRule(rule, repository);
23
+ const accepted = violations.filter((violation) => !exceptions.some((glob) => globMatches(glob, violation.file)));
24
+ ruleResults.push({ rule_id: rule.id, status: accepted.length ? "failed" : "passed", finding_count: accepted.length });
25
+ for (const violation of accepted) {
26
+ const evidenceHash = sha256(violation.evidence);
27
+ const findingId = sha256([bundle.integrity.digest, module.id, rule.id, violation.file || "repository", violation.line ?? "", violation.side || "", evidenceHash].join("\0"));
28
+ const assurance = assuranceContext(module, rule.id);
29
+ findings.push({
30
+ finding_id: `sqm:${findingId}`,
31
+ module_id: module.id,
32
+ module_version: module.version,
33
+ rule_id: rule.id,
34
+ lifecycle: module.lifecycle.status,
35
+ severity: module.lifecycle.severity,
36
+ message: rule.message,
37
+ invariant: module.invariant.statement,
38
+ remediation: module.invariant.remediation ?? null,
39
+ incident_refs: [...module.incident_refs],
40
+ file: violation.file,
41
+ line: violation.line ?? null,
42
+ side: violation.side ?? null,
43
+ evidence_hash: `sha256:${evidenceHash}`,
44
+ bundle_id: bundle.bundle_id,
45
+ bundle_version: bundle.version,
46
+ bundle_digest: bundle.integrity.digest,
47
+ state_model_ids: assurance.state_model_ids,
48
+ transition_ids: assurance.transition_ids,
49
+ stressor_ids: assurance.stressor_ids,
50
+ strength_ids: assurance.strength_ids,
51
+ scenario_ids: assurance.scenario_ids,
52
+ });
53
+ }
54
+ }
55
+ modules.push(evaluateAssurance(module, ruleResults));
56
+ }
57
+ return { findings, modules };
58
+ }
59
+
60
+ function evaluateAssurance(module, ruleResults) {
61
+ const failed = new Set(ruleResults.filter((result) => result.status === "failed").map((result) => result.rule_id));
62
+ const assurance = module.assurance;
63
+ if (!assurance) {
64
+ return {
65
+ module_id: module.id,
66
+ module_version: module.version,
67
+ lifecycle: module.lifecycle.status,
68
+ severity: module.lifecycle.severity,
69
+ rule_results: ruleResults,
70
+ state_models: [],
71
+ stressors: [],
72
+ strengths: [],
73
+ scenarios: [],
74
+ };
75
+ }
76
+ const stressors = assurance.stressors.map((stressor) => ({
77
+ id: stressor.id,
78
+ status: stressor.activation_check_refs.some((ref) => failed.has(ref)) ? "activated" : "not_activated",
79
+ check_refs: [...stressor.activation_check_refs],
80
+ }));
81
+ const strengths = assurance.strengths.map((strength) => ({
82
+ id: strength.id,
83
+ status: strength.check_refs.some((ref) => failed.has(ref)) ? "failed" : "passed",
84
+ check_refs: [...strength.check_refs],
85
+ }));
86
+ const strengthStatus = new Map(strengths.map((strength) => [strength.id, strength.status]));
87
+ const scenarios = assurance.scenarios.map((scenario) => {
88
+ let status = scenario.execution_requirement;
89
+ if (scenario.execution_requirement === "local") {
90
+ const checkFailed = scenario.check_refs.some((ref) => failed.has(ref));
91
+ const strengthFailed = scenario.expected_strength_refs.some((ref) => strengthStatus.get(ref) === "failed");
92
+ status = checkFailed || strengthFailed ? "failed" : "passed";
93
+ }
94
+ return {
95
+ id: scenario.id,
96
+ state_model_id: scenario.state_model_id,
97
+ transition_id: scenario.transition_id,
98
+ status,
99
+ stressor_refs: [...scenario.stressor_refs],
100
+ expected_strength_refs: [...scenario.expected_strength_refs],
101
+ check_refs: [...scenario.check_refs],
102
+ };
103
+ });
104
+ const scenarioStatus = new Map(scenarios.map((scenario) => [scenario.id, scenario.status]));
105
+ const stateModels = assurance.state_models.map((model) => ({
106
+ id: model.id,
107
+ version: model.version,
108
+ transitions: model.transitions.map((transition) => ({
109
+ id: transition.id,
110
+ from: transition.from,
111
+ event: transition.event,
112
+ to: transition.to,
113
+ status: transition.scenario_refs.some((ref) => scenarioStatus.get(ref) === "failed") ? "failed" :
114
+ transition.scenario_refs.some((ref) => ["requires_human", "requires_post_deploy"].includes(scenarioStatus.get(ref))) ? "skipped" : "passed",
115
+ scenario_refs: [...transition.scenario_refs],
116
+ })),
117
+ }));
118
+ return {
119
+ module_id: module.id,
120
+ module_version: module.version,
121
+ lifecycle: module.lifecycle.status,
122
+ severity: module.lifecycle.severity,
123
+ rule_results: ruleResults,
124
+ state_models: stateModels,
125
+ stressors,
126
+ strengths,
127
+ scenarios,
128
+ };
129
+ }
130
+
131
+ function assuranceContext(module, ruleId) {
132
+ const assurance = module.assurance;
133
+ if (!assurance) return { state_model_ids: [], transition_ids: [], stressor_ids: [], strength_ids: [], scenario_ids: [] };
134
+ const stressorIds = assurance.stressors.filter((item) => item.activation_check_refs.includes(ruleId)).map((item) => item.id);
135
+ const strengthIds = assurance.strengths.filter((item) => item.check_refs.includes(ruleId)).map((item) => item.id);
136
+ const scenarios = assurance.scenarios.filter((item) => item.check_refs.includes(ruleId) || item.stressor_refs.some((id) => stressorIds.includes(id)) || item.expected_strength_refs.some((id) => strengthIds.includes(id)));
137
+ const scenarioIds = scenarios.map((item) => item.id);
138
+ const stateModelIds = [...new Set(scenarios.map((item) => item.state_model_id))];
139
+ const transitionIds = [...new Set(scenarios.map((item) => item.transition_id))];
140
+ return { state_model_ids: stateModelIds, transition_ids: transitionIds, stressor_ids: stressorIds, strength_ids: strengthIds, scenario_ids: scenarioIds };
141
+ }
142
+
143
+ function moduleApplies(module, repository) {
144
+ const scoped = module.scope.repositories;
145
+ return !scoped?.length || scoped.some((item) => item.toLowerCase() === repository.toLowerCase());
146
+ }
147
+
148
+ async function matchModule(module, repository) {
149
+ for (const matcher of module.detection.matchers) {
150
+ if (!(await match(matcher, repository))) return false;
151
+ }
152
+ return true;
153
+ }
154
+
155
+ async function match(matcher, repository) {
156
+ const changed = repository.diff.filter((file) => matchesAny(matcher.paths, file.path));
157
+ if (matcher.type === "changed_path") return changed.length > 0;
158
+ const regex = safeRegex(matcher.pattern);
159
+ if (matcher.type === "diff_regex" || matcher.type === "reference") {
160
+ return changed.some((file) => file.lines.some((line) => sideMatches(matcher.side, line.side) && test(regex, line.text)));
161
+ }
162
+ if (matcher.type === "content_regex") {
163
+ const files = selectPaths(repository.paths, matcher.paths);
164
+ for (const file of files) if (test(regex, await readText(repository.root, file))) return true;
165
+ }
166
+ return false;
167
+ }
168
+
169
+ async function executeRule(rule, repository) {
170
+ switch (rule.type) {
171
+ case "diff_regex":
172
+ return diffRegex(rule, repository);
173
+ case "path_presence":
174
+ return pathPresence(rule, repository);
175
+ case "content_regex":
176
+ return contentRegex(rule, repository);
177
+ case "reference_definition":
178
+ return referenceDefinition(rule, repository);
179
+ default:
180
+ throw new Error(`Unsupported SQM checker type: ${rule.type}`);
181
+ }
182
+ }
183
+
184
+ function diffRegex(rule, repository) {
185
+ const regex = safeRegex(rule.pattern);
186
+ const matches = [];
187
+ for (const file of repository.diff.filter((item) => matchesAny(rule.paths || ["**"], item.path))) {
188
+ for (const line of file.lines) if (sideMatches(rule.side, line.side) && test(regex, line.text)) matches.push({ file: file.path, line: line.line, side: line.side, evidence: `${line.side}: ${line.text}` });
189
+ }
190
+ if (rule.expect === "present") return matches.length ? [] : [{ file: null, evidence: "Required diff pattern was not present." }];
191
+ return matches;
192
+ }
193
+
194
+ function pathPresence(rule, repository) {
195
+ const matches = selectPaths(repository.paths, rule.paths || []);
196
+ if (rule.expect === "present") return matches.length ? [] : [{ file: null, evidence: `Required repository path was not present: ${(rule.paths || []).join(", ")}` }];
197
+ return matches.map((file) => ({ file, evidence: `Forbidden repository path is present: ${file}` }));
198
+ }
199
+
200
+ async function contentRegex(rule, repository) {
201
+ const regex = safeRegex(rule.pattern);
202
+ const matches = [];
203
+ for (const file of selectPaths(repository.paths, rule.paths || ["**"])) {
204
+ const content = await readText(repository.root, file);
205
+ for (const result of matchLines(regex, content)) matches.push({ file, line: result.line, evidence: truncate(result.text) });
206
+ }
207
+ if (rule.expect === "present") return matches.length ? [] : [{ file: null, evidence: "Required content pattern was not present." }];
208
+ return matches;
209
+ }
210
+
211
+ async function referenceDefinition(rule, repository) {
212
+ const regex = safeRegex(rule.pattern);
213
+ const references = [];
214
+ for (const file of repository.diff.filter((item) => matchesAny(rule.paths || ["**"], item.path))) {
215
+ for (const line of file.lines.filter((item) => item.side === "added")) {
216
+ for (const match of allMatches(regex, line.text)) references.push({ value: match[1] || match[0], file: file.path, line: line.line });
217
+ }
218
+ }
219
+ const definitions = await Promise.all(selectPaths(repository.paths, rule.definition_paths || []).map((file) => readText(repository.root, file)));
220
+ return references
221
+ .filter((reference) => !definitions.some((content) => content.includes(reference.value)))
222
+ .map((reference) => ({ file: reference.file, line: reference.line, evidence: `Reference ${reference.value} has no definition in ${(rule.definition_paths || []).join(", ")}.` }));
223
+ }
224
+
225
+ function activeExceptionPatterns(module) {
226
+ const now = Date.now();
227
+ return (module.detection.exceptions || [])
228
+ .filter((item) => !item.expires_at || Date.parse(item.expires_at) > now)
229
+ .flatMap((item) => item.paths);
230
+ }
231
+
232
+ function selectPaths(paths, globs) {
233
+ return paths.filter((file) => matchesAny(globs, file)).slice(0, MAX_FILES_PER_PATTERN);
234
+ }
235
+
236
+ function matchesAny(globs, file) {
237
+ return globs.some((glob) => globMatches(glob, file));
238
+ }
239
+
240
+ export function globMatches(glob, file) {
241
+ const value = String(glob);
242
+ let source = "";
243
+ for (let index = 0; index < value.length; index += 1) {
244
+ const character = value[index];
245
+ if (character === "*" && value[index + 1] === "*" && value[index + 2] === "/") {
246
+ source += "(?:.*/)?";
247
+ index += 2;
248
+ } else if (character === "*" && value[index + 1] === "*") {
249
+ source += ".*";
250
+ index += 1;
251
+ } else if (character === "*") source += "[^/]*";
252
+ else if (character === "?") source += "[^/]";
253
+ else source += /[.+^${}()|[\]\\]/.test(character) ? `\\${character}` : character;
254
+ }
255
+ return new RegExp(`^${source}$`).test(file);
256
+ }
257
+
258
+ function safeRegex(pattern) {
259
+ let source = String(pattern || "");
260
+ let flags = "g";
261
+ const leadingFlags = source.match(/^\(\?([ims]+)\)/);
262
+ if (leadingFlags) {
263
+ flags += [...new Set(leadingFlags[1])].join("");
264
+ source = source.slice(leadingFlags[0].length);
265
+ }
266
+ if (source.length > 2_000 || /\\[1-9]|\(\?[=!<]|\([^)]*[+*][^)]*\)[+*{]/.test(source)) {
267
+ throw new Error("SQM regex uses an unsupported potentially unsafe construct.");
268
+ }
269
+ if (/\(\?[ims-]/.test(source)) {
270
+ throw new Error("SQM regex flags must appear once at the start of the pattern.");
271
+ }
272
+ return new RegExp(source, flags);
273
+ }
274
+
275
+ function test(regex, text) { regex.lastIndex = 0; return regex.test(text); }
276
+ function allMatches(regex, text) { regex.lastIndex = 0; return [...text.matchAll(regex)]; }
277
+ function sideMatches(expected, actual) { return !expected || expected === "either" || expected === actual; }
278
+ function truncate(value) { const text = String(value); return text.length > 300 ? `${text.slice(0, 297)}...` : text; }
279
+ function sha256(value) { return createHash("sha256").update(String(value)).digest("hex"); }
280
+
281
+ function matchLines(regex, content) {
282
+ const results = [];
283
+ for (const [index, line] of content.split(/\r?\n/).entries()) if (test(regex, line)) results.push({ line: index + 1, text: line });
284
+ return results;
285
+ }
286
+
287
+ async function readText(root, relative) {
288
+ const resolved = path.resolve(root, relative);
289
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new Error("SQM path escaped the repository root.");
290
+ try {
291
+ const fileStat = await lstat(resolved);
292
+ if (!fileStat.isFile() || fileStat.isSymbolicLink()) return "";
293
+ const buffer = await readFile(resolved);
294
+ if (buffer.length > MAX_FILE_BYTES || buffer.includes(0)) return "";
295
+ return buffer.toString("utf8");
296
+ } catch {
297
+ return "";
298
+ }
299
+ }