@anhcodo13/lattice 0.2.0 → 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.
- package/README.md +28 -17
- package/dist/cli.js +1875 -192
- package/dist/index.d.ts +269 -1
- package/dist/index.js +1323 -64
- package/governance/policy.json +43 -9
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,13 +1,194 @@
|
|
|
1
1
|
// src/governance/policy.ts
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
|
-
import { readFile } from "fs/promises";
|
|
3
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
4
4
|
import { resolve } from "path";
|
|
5
5
|
import { valid } from "semver";
|
|
6
|
+
|
|
7
|
+
// src/analysis/config.ts
|
|
8
|
+
import { readFile } from "fs/promises";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
var defaultThresholds = {
|
|
11
|
+
maxFanIn: 8,
|
|
12
|
+
maxFanOut: 10,
|
|
13
|
+
maxChainDepth: 6,
|
|
14
|
+
maxComponentLines: 200,
|
|
15
|
+
maxJsxDepth: 6,
|
|
16
|
+
maxComplexity: 15
|
|
17
|
+
};
|
|
18
|
+
var defaultWeights = {
|
|
19
|
+
architecture: 40,
|
|
20
|
+
imports: 25,
|
|
21
|
+
components: 20,
|
|
22
|
+
complexity: 15
|
|
23
|
+
};
|
|
24
|
+
function record(value) {
|
|
25
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
function patterns(value, label) {
|
|
28
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every((item) => typeof item === "string" && item.trim())) {
|
|
29
|
+
throw new Error(`${label} must be a nonempty string array`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function parseAnalysisConfig(value) {
|
|
34
|
+
if (!record(value)) throw new Error("Analysis configuration must be an object");
|
|
35
|
+
const analysis = value.analysis === void 0 ? {} : value.analysis;
|
|
36
|
+
if (!record(analysis)) throw new Error("analysis must be an object");
|
|
37
|
+
const overrides = analysis.thresholds === void 0 ? {} : analysis.thresholds;
|
|
38
|
+
if (!record(overrides)) throw new Error("analysis.thresholds must be an object");
|
|
39
|
+
const thresholds = { ...defaultThresholds };
|
|
40
|
+
for (const [name, limit] of Object.entries(overrides)) {
|
|
41
|
+
if (!Object.hasOwn(thresholds, name) || !Number.isInteger(limit) || limit < 0) {
|
|
42
|
+
throw new Error(`Invalid analysis threshold ${name}`);
|
|
43
|
+
}
|
|
44
|
+
thresholds[name] = limit;
|
|
45
|
+
}
|
|
46
|
+
const rawWeights = analysis.weights === void 0 ? defaultWeights : analysis.weights;
|
|
47
|
+
if (!record(rawWeights) || Object.keys(rawWeights).length !== Object.keys(defaultWeights).length) {
|
|
48
|
+
throw new Error("analysis.weights must define every category and total 100");
|
|
49
|
+
}
|
|
50
|
+
const weights = { ...defaultWeights };
|
|
51
|
+
for (const [name, weight] of Object.entries(rawWeights)) {
|
|
52
|
+
if (!Object.hasOwn(weights, name) || typeof weight !== "number" || !Number.isFinite(weight) || weight < 0) {
|
|
53
|
+
throw new Error(`Invalid analysis weight ${name}`);
|
|
54
|
+
}
|
|
55
|
+
weights[name] = weight;
|
|
56
|
+
}
|
|
57
|
+
if (Object.values(weights).reduce((sum, weight) => sum + weight, 0) !== 100) {
|
|
58
|
+
throw new Error("analysis.weights must total 100");
|
|
59
|
+
}
|
|
60
|
+
const rawPolicies = value.policies ?? [];
|
|
61
|
+
if (!Array.isArray(rawPolicies)) throw new Error("policies must be an array");
|
|
62
|
+
const policies = [];
|
|
63
|
+
const ids = /* @__PURE__ */ new Set();
|
|
64
|
+
for (const raw of rawPolicies) {
|
|
65
|
+
if (!record(raw) || typeof raw.id !== "string" || !/^[a-z][a-z0-9-]*$/.test(raw.id) || typeof raw.description !== "string" || !raw.description.trim()) {
|
|
66
|
+
throw new Error("Invalid policy id or description");
|
|
67
|
+
}
|
|
68
|
+
if (ids.has(raw.id)) throw new Error(`Duplicate policy id ${raw.id}`);
|
|
69
|
+
ids.add(raw.id);
|
|
70
|
+
if (raw.enabled === false) continue;
|
|
71
|
+
if (raw.enabled !== void 0 && raw.enabled !== true) throw new Error(`Invalid enabled flag in ${raw.id}`);
|
|
72
|
+
const from = patterns(raw.from, `${raw.id}.from`);
|
|
73
|
+
const hasAllow = raw.allow !== void 0;
|
|
74
|
+
const hasDisallow = raw.disallow !== void 0;
|
|
75
|
+
if (hasAllow === hasDisallow) throw new Error(`Policy ${raw.id} must choose exactly one of allow or disallow`);
|
|
76
|
+
if (!["info", "warning", "error"].includes(String(raw.severity))) throw new Error(`Invalid severity in ${raw.id}`);
|
|
77
|
+
if (raw.recommendation !== void 0 && typeof raw.recommendation !== "string") {
|
|
78
|
+
throw new Error(`Invalid recommendation in ${raw.id}`);
|
|
79
|
+
}
|
|
80
|
+
policies.push({
|
|
81
|
+
id: raw.id,
|
|
82
|
+
description: raw.description,
|
|
83
|
+
from,
|
|
84
|
+
...hasAllow ? { allow: patterns(raw.allow, `${raw.id}.allow`) } : { disallow: patterns(raw.disallow, `${raw.id}.disallow`) },
|
|
85
|
+
severity: raw.severity,
|
|
86
|
+
recommendation: raw.recommendation
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const rawAliases = analysis.aliases ?? {};
|
|
90
|
+
if (!record(rawAliases)) throw new Error("analysis.aliases must be an object");
|
|
91
|
+
const aliases = {};
|
|
92
|
+
for (const [prefix, target] of Object.entries(rawAliases)) {
|
|
93
|
+
if (!prefix || prefix.startsWith(".") || !prefix.endsWith("/") || prefix.includes("\\") || prefix.includes("//") || typeof target !== "string" || !target.endsWith("/") || target.startsWith("/") || target.includes("\\") || target.split("/").slice(0, -1).some((segment) => !segment || segment === "." || segment === "..")) {
|
|
94
|
+
throw new Error(`Invalid analysis alias ${prefix}`);
|
|
95
|
+
}
|
|
96
|
+
aliases[prefix] = target;
|
|
97
|
+
}
|
|
98
|
+
return { thresholds, weights, policies, aliases };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/governance/policy.ts
|
|
6
102
|
var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
7
103
|
var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
8
|
-
function
|
|
104
|
+
function record2(value) {
|
|
9
105
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
106
|
}
|
|
107
|
+
var builtInRuleIds = /* @__PURE__ */ new Set([
|
|
108
|
+
"module-cycle",
|
|
109
|
+
"duplicate-import",
|
|
110
|
+
"high-fan-out",
|
|
111
|
+
"high-fan-in",
|
|
112
|
+
"orphan-module",
|
|
113
|
+
"unused-export",
|
|
114
|
+
"large-component",
|
|
115
|
+
"deep-jsx",
|
|
116
|
+
"high-complexity",
|
|
117
|
+
"deep-dependency-chain"
|
|
118
|
+
]);
|
|
119
|
+
function parseAssessmentPolicy(value) {
|
|
120
|
+
if (!record2(value) || !record2(value.architecture)) throw new Error("assessment.architecture is required");
|
|
121
|
+
const architecture = value.architecture;
|
|
122
|
+
if (typeof architecture.minimumScore !== "number" || !Number.isFinite(architecture.minimumScore) || architecture.minimumScore < 0 || architecture.minimumScore > 100) throw new Error("assessment.architecture.minimumScore must be 0\u2013100");
|
|
123
|
+
if (!Number.isSafeInteger(architecture.maximumErrors) || architecture.maximumErrors < 0) {
|
|
124
|
+
throw new Error("assessment.architecture.maximumErrors must be a nonnegative integer");
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(architecture.blockedRuleIds) || !architecture.blockedRuleIds.every((item) => typeof item === "string") || new Set(architecture.blockedRuleIds).size !== architecture.blockedRuleIds.length) {
|
|
127
|
+
throw new Error("assessment.architecture.blockedRuleIds must be a unique string array");
|
|
128
|
+
}
|
|
129
|
+
const rawAnalysis = architecture.analysis ?? {};
|
|
130
|
+
if (!record2(rawAnalysis)) throw new Error("assessment.architecture.analysis must be an object");
|
|
131
|
+
if (Array.isArray(rawAnalysis.policies) && rawAnalysis.policies.some((item) => record2(item) && item.enabled === false)) {
|
|
132
|
+
throw new Error("Central architecture policies cannot be disabled");
|
|
133
|
+
}
|
|
134
|
+
const analysis = parseAnalysisConfig({ analysis: rawAnalysis, policies: rawAnalysis.policies });
|
|
135
|
+
for (const policy of analysis.policies) {
|
|
136
|
+
if (builtInRuleIds.has(policy.id)) throw new Error(`Architecture boundary ID ${policy.id} conflicts with a built-in rule`);
|
|
137
|
+
}
|
|
138
|
+
const known = /* @__PURE__ */ new Set([...builtInRuleIds, ...analysis.policies.map((item) => item.id)]);
|
|
139
|
+
for (const id of architecture.blockedRuleIds) {
|
|
140
|
+
if (!known.has(id)) throw new Error(`Unknown blocked rule ID ${id}`);
|
|
141
|
+
}
|
|
142
|
+
let evidence;
|
|
143
|
+
if (value.evidence !== void 0) {
|
|
144
|
+
if (!record2(value.evidence)) throw new Error("assessment.evidence must be an object");
|
|
145
|
+
const gates = value.evidence;
|
|
146
|
+
const nonnegative2 = (source, key, label) => {
|
|
147
|
+
const result = source[key];
|
|
148
|
+
if (!Number.isSafeInteger(result) || result < 0) throw new Error(`${label}.${key} must be a nonnegative integer`);
|
|
149
|
+
return result;
|
|
150
|
+
};
|
|
151
|
+
if (!Number.isFinite(gates.maximumAgeHours) || gates.maximumAgeHours <= 0 || gates.maximumAgeHours > 168) {
|
|
152
|
+
throw new Error("assessment.evidence.maximumAgeHours must be greater than 0 and at most 168");
|
|
153
|
+
}
|
|
154
|
+
for (const domain of ["dependencySecurity", "codeQuality", "codeSecurity", "compatibility"]) {
|
|
155
|
+
if (!record2(gates[domain])) throw new Error(`assessment.evidence.${domain} is required`);
|
|
156
|
+
}
|
|
157
|
+
const dependencySecurity = gates.dependencySecurity;
|
|
158
|
+
const codeQuality = gates.codeQuality;
|
|
159
|
+
const codeSecurity = gates.codeSecurity;
|
|
160
|
+
const compatibility = gates.compatibility;
|
|
161
|
+
const minimumLineCoverage = codeQuality.minimumLineCoverage;
|
|
162
|
+
if (typeof minimumLineCoverage !== "number" || !Number.isFinite(minimumLineCoverage) || minimumLineCoverage < 0 || minimumLineCoverage > 100) {
|
|
163
|
+
throw new Error("assessment.evidence.codeQuality.minimumLineCoverage must be 0\u2013100");
|
|
164
|
+
}
|
|
165
|
+
if (typeof codeQuality.requireTypecheck !== "boolean" || typeof codeQuality.requireTests !== "boolean") {
|
|
166
|
+
throw new Error("assessment.evidence.codeQuality requires boolean requireTypecheck and requireTests");
|
|
167
|
+
}
|
|
168
|
+
const projects = compatibility.requiredProjects;
|
|
169
|
+
if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
|
|
170
|
+
throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
|
|
171
|
+
}
|
|
172
|
+
evidence = {
|
|
173
|
+
maximumAgeHours: gates.maximumAgeHours,
|
|
174
|
+
dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
|
|
175
|
+
codeQuality: {
|
|
176
|
+
maximumLintErrors: nonnegative2(codeQuality, "maximumLintErrors", "assessment.evidence.codeQuality"),
|
|
177
|
+
minimumLineCoverage,
|
|
178
|
+
requireTypecheck: codeQuality.requireTypecheck,
|
|
179
|
+
requireTests: codeQuality.requireTests
|
|
180
|
+
},
|
|
181
|
+
codeSecurity: { maximumHigh: nonnegative2(codeSecurity, "maximumHigh", "assessment.evidence.codeSecurity"), maximumCritical: nonnegative2(codeSecurity, "maximumCritical", "assessment.evidence.codeSecurity"), maximumSecrets: nonnegative2(codeSecurity, "maximumSecrets", "assessment.evidence.codeSecurity") },
|
|
182
|
+
compatibility: { requiredProjects: projects, minimumTestsPerProject: nonnegative2(compatibility, "minimumTestsPerProject", "assessment.evidence.compatibility") }
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return { architecture: {
|
|
186
|
+
minimumScore: architecture.minimumScore,
|
|
187
|
+
maximumErrors: architecture.maximumErrors,
|
|
188
|
+
blockedRuleIds: architecture.blockedRuleIds,
|
|
189
|
+
analysis
|
|
190
|
+
}, ...evidence ? { evidence } : {} };
|
|
191
|
+
}
|
|
11
192
|
function parsePolicy(raw, source) {
|
|
12
193
|
let parsed;
|
|
13
194
|
try {
|
|
@@ -15,18 +196,18 @@ function parsePolicy(raw, source) {
|
|
|
15
196
|
} catch {
|
|
16
197
|
throw new Error(`Policy at ${source} is not valid JSON`);
|
|
17
198
|
}
|
|
18
|
-
if (!
|
|
199
|
+
if (!record2(parsed) || parsed.schemaVersion !== 2) {
|
|
19
200
|
throw new Error("Policy schemaVersion must be 2");
|
|
20
201
|
}
|
|
21
202
|
if (typeof parsed.policyVersion !== "string" || !exactVersion.test(parsed.policyVersion) || valid(parsed.policyVersion) === null) {
|
|
22
203
|
throw new Error("Policy policyVersion must be an exact version");
|
|
23
204
|
}
|
|
24
|
-
if (!
|
|
205
|
+
if (!record2(parsed.profiles) || Object.keys(parsed.profiles).length === 0) {
|
|
25
206
|
throw new Error("Policy profiles must be a nonempty object");
|
|
26
207
|
}
|
|
27
|
-
const
|
|
208
|
+
const profiles3 = {};
|
|
28
209
|
for (const [name, rules] of Object.entries(parsed.profiles)) {
|
|
29
|
-
if (!
|
|
210
|
+
if (!record2(rules) || Object.keys(rules).length === 0) {
|
|
30
211
|
throw new Error(`Policy profile ${name} must list packages`);
|
|
31
212
|
}
|
|
32
213
|
const versions = {};
|
|
@@ -36,12 +217,13 @@ function parsePolicy(raw, source) {
|
|
|
36
217
|
}
|
|
37
218
|
versions[packageName] = version;
|
|
38
219
|
}
|
|
39
|
-
|
|
220
|
+
profiles3[name] = versions;
|
|
40
221
|
}
|
|
41
222
|
return {
|
|
42
223
|
schemaVersion: 2,
|
|
43
224
|
policyVersion: parsed.policyVersion,
|
|
44
|
-
profiles:
|
|
225
|
+
profiles: profiles3,
|
|
226
|
+
...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
|
|
45
227
|
source,
|
|
46
228
|
sha256: createHash("sha256").update(raw).digest("hex")
|
|
47
229
|
};
|
|
@@ -60,7 +242,7 @@ async function loadPolicy(source) {
|
|
|
60
242
|
}
|
|
61
243
|
return parsePolicy(await response.text(), source);
|
|
62
244
|
}
|
|
63
|
-
return parsePolicy(await
|
|
245
|
+
return parsePolicy(await readFile2(resolve(source), "utf8"), source);
|
|
64
246
|
}
|
|
65
247
|
|
|
66
248
|
// src/governance/check.ts
|
|
@@ -68,8 +250,8 @@ import { resolve as resolve3, relative as relative3, sep as sep2 } from "path";
|
|
|
68
250
|
import { satisfies, subset, valid as valid2, validRange } from "semver";
|
|
69
251
|
|
|
70
252
|
// src/governance/discover.ts
|
|
71
|
-
import { readFile as
|
|
72
|
-
import { join, relative } from "path";
|
|
253
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
254
|
+
import { join as join2, relative } from "path";
|
|
73
255
|
import fastGlob from "fast-glob";
|
|
74
256
|
var profiles = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
|
|
75
257
|
function isRecord(value) {
|
|
@@ -109,15 +291,16 @@ async function discoverApps(scanRoot, workspaceRoot) {
|
|
|
109
291
|
cwd: scanRoot,
|
|
110
292
|
onlyFiles: true,
|
|
111
293
|
unique: true,
|
|
294
|
+
dot: true,
|
|
112
295
|
followSymbolicLinks: false,
|
|
113
|
-
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/.next/**", "**/coverage/**"]
|
|
296
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/out/**", "**/.next/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**"]
|
|
114
297
|
});
|
|
115
298
|
const apps = [];
|
|
116
299
|
for (const path of manifests.sort()) {
|
|
117
|
-
const directory =
|
|
300
|
+
const directory = join2(scanRoot, path, "..");
|
|
118
301
|
let parsed;
|
|
119
302
|
try {
|
|
120
|
-
parsed = JSON.parse(await
|
|
303
|
+
parsed = JSON.parse(await readFile3(join2(scanRoot, path), "utf8"));
|
|
121
304
|
} catch {
|
|
122
305
|
throw new Error(`Invalid package.json: ${path}`);
|
|
123
306
|
}
|
|
@@ -136,10 +319,10 @@ async function discoverApps(scanRoot, workspaceRoot) {
|
|
|
136
319
|
}
|
|
137
320
|
|
|
138
321
|
// src/governance/lockfiles.ts
|
|
139
|
-
import { access, readFile as
|
|
140
|
-
import { dirname, join as
|
|
322
|
+
import { access, readFile as readFile4 } from "fs/promises";
|
|
323
|
+
import { dirname, join as join3, relative as relative2, resolve as resolve2, sep } from "path";
|
|
141
324
|
import YAML from "yaml";
|
|
142
|
-
function
|
|
325
|
+
function record3(value) {
|
|
143
326
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
144
327
|
}
|
|
145
328
|
async function exists(path) {
|
|
@@ -159,8 +342,8 @@ async function findLockfile(appDirectory, workspaceRoot) {
|
|
|
159
342
|
const boundary = resolve2(workspaceRoot);
|
|
160
343
|
if (!within(boundary, directory)) throw new Error("Application is outside workspace root");
|
|
161
344
|
while (within(boundary, directory)) {
|
|
162
|
-
const npm =
|
|
163
|
-
const pnpm =
|
|
345
|
+
const npm = join3(directory, "package-lock.json");
|
|
346
|
+
const pnpm = join3(directory, "pnpm-lock.yaml");
|
|
164
347
|
const [hasNpm, hasPnpm] = await Promise.all([exists(npm), exists(pnpm)]);
|
|
165
348
|
if (hasNpm && hasPnpm) throw new Error(`Ambiguous lockfiles in ${directory}`);
|
|
166
349
|
if (hasNpm) return { path: npm, kind: "npm", root: directory };
|
|
@@ -173,7 +356,7 @@ async function findLockfile(appDirectory, workspaceRoot) {
|
|
|
173
356
|
function section(importer, name) {
|
|
174
357
|
for (const key of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
175
358
|
const entries = importer[key];
|
|
176
|
-
if (
|
|
359
|
+
if (record3(entries) && Object.hasOwn(entries, name)) return entries[name];
|
|
177
360
|
}
|
|
178
361
|
return void 0;
|
|
179
362
|
}
|
|
@@ -184,12 +367,12 @@ function readNpm(raw, root, app, name) {
|
|
|
184
367
|
} catch {
|
|
185
368
|
throw new Error("Malformed npm lockfile JSON");
|
|
186
369
|
}
|
|
187
|
-
if (!
|
|
370
|
+
if (!record3(lock) || lock.lockfileVersion !== 3 || !record3(lock.packages)) {
|
|
188
371
|
throw new Error("Unsupported or malformed npm lockfile");
|
|
189
372
|
}
|
|
190
373
|
const importerKey = relative2(root, app.directory).replaceAll("\\", "/");
|
|
191
374
|
const importer = lock.packages[importerKey];
|
|
192
|
-
if (!
|
|
375
|
+
if (!record3(importer)) throw new Error(`Missing npm lockfile importer ${importerKey || "."}`);
|
|
193
376
|
const specifier = section(importer, name);
|
|
194
377
|
if (specifier !== void 0 && typeof specifier !== "string") {
|
|
195
378
|
throw new Error(`Malformed npm lockfile specifier for ${name}`);
|
|
@@ -202,13 +385,13 @@ function readNpm(raw, root, app, name) {
|
|
|
202
385
|
if (resolved !== void 0 || segments.length === 0) break;
|
|
203
386
|
segments.pop();
|
|
204
387
|
}
|
|
205
|
-
if (specifier !== void 0 && !
|
|
388
|
+
if (specifier !== void 0 && !record3(resolved)) {
|
|
206
389
|
throw new Error(`Missing npm lockfile package resolution for ${name}`);
|
|
207
390
|
}
|
|
208
|
-
if (
|
|
391
|
+
if (record3(resolved) && typeof resolved.version !== "string") {
|
|
209
392
|
throw new Error(`Malformed npm lockfile package version for ${name}`);
|
|
210
393
|
}
|
|
211
|
-
return { specifier, version:
|
|
394
|
+
return { specifier, version: record3(resolved) ? resolved.version : void 0 };
|
|
212
395
|
}
|
|
213
396
|
function readPnpm(raw, root, app, name) {
|
|
214
397
|
let lock;
|
|
@@ -217,21 +400,21 @@ function readPnpm(raw, root, app, name) {
|
|
|
217
400
|
} catch {
|
|
218
401
|
throw new Error("Malformed pnpm lockfile YAML");
|
|
219
402
|
}
|
|
220
|
-
if (!
|
|
403
|
+
if (!record3(lock) || String(lock.lockfileVersion) !== "9.0" || !record3(lock.importers) || !record3(lock.packages) || !record3(lock.snapshots)) {
|
|
221
404
|
throw new Error("Unsupported or malformed pnpm lockfile");
|
|
222
405
|
}
|
|
223
406
|
const importerKey = relative2(root, app.directory).replaceAll("\\", "/") || ".";
|
|
224
407
|
const importer = lock.importers[importerKey];
|
|
225
|
-
if (!
|
|
408
|
+
if (!record3(importer)) throw new Error(`Missing pnpm lockfile importer ${importerKey}`);
|
|
226
409
|
const entry = section(importer, name);
|
|
227
410
|
if (entry === void 0) return {};
|
|
228
|
-
if (!
|
|
411
|
+
if (!record3(entry) || typeof entry.specifier !== "string" || typeof entry.version !== "string") {
|
|
229
412
|
throw new Error(`Malformed pnpm lockfile dependency for ${name}`);
|
|
230
413
|
}
|
|
231
414
|
const version = entry.version.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1];
|
|
232
415
|
if (!version) throw new Error(`Unsupported pnpm resolution for ${name}`);
|
|
233
416
|
const packageKeys = [`${name}@${version}`, `/${name}@${version}`];
|
|
234
|
-
if (!packageKeys.some((key) =>
|
|
417
|
+
if (!packageKeys.some((key) => record3(lock.packages) && Object.hasOwn(lock.packages, key))) {
|
|
235
418
|
throw new Error(`Missing pnpm package entry for ${name}@${version}`);
|
|
236
419
|
}
|
|
237
420
|
const snapshotKeys = [`${name}@${entry.version}`, `/${name}@${entry.version}`, ...packageKeys];
|
|
@@ -242,11 +425,44 @@ function readPnpm(raw, root, app, name) {
|
|
|
242
425
|
}
|
|
243
426
|
async function readResolution(app, workspaceRoot, name) {
|
|
244
427
|
const lockfile = await findLockfile(app.directory, workspaceRoot);
|
|
245
|
-
const raw = await
|
|
428
|
+
const raw = await readFile4(lockfile.path, "utf8");
|
|
246
429
|
return lockfile.kind === "npm" ? readNpm(raw, lockfile.root, app, name) : readPnpm(raw, lockfile.root, app, name);
|
|
247
430
|
}
|
|
248
431
|
|
|
249
432
|
// src/governance/check.ts
|
|
433
|
+
async function checkApp(app, workspaceRoot, policy) {
|
|
434
|
+
const requirements = policy.profiles[app.profile];
|
|
435
|
+
if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
|
|
436
|
+
const findings2 = [];
|
|
437
|
+
for (const [packageName, expected] of Object.entries(requirements)) {
|
|
438
|
+
const declared = app.dependencies[packageName];
|
|
439
|
+
const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
|
|
440
|
+
const reasons = [];
|
|
441
|
+
const declaredRange = declared === void 0 ? null : validRange(declared);
|
|
442
|
+
if (declared === void 0) reasons.push("required manifest declaration missing");
|
|
443
|
+
else if (declaredRange === null) reasons.push("unsupported manifest range");
|
|
444
|
+
else if (!subset(declaredRange, expected)) reasons.push("manifest range allows versions below minimum");
|
|
445
|
+
if (resolution.specifier !== declared) reasons.push("lockfile specifier differs");
|
|
446
|
+
if (resolution.version === void 0) reasons.push("lockfile resolution missing");
|
|
447
|
+
else if (!valid2(resolution.version)) reasons.push("invalid lockfile resolution");
|
|
448
|
+
else {
|
|
449
|
+
if (!satisfies(resolution.version, expected)) reasons.push("lockfile resolution does not satisfy minimum");
|
|
450
|
+
if (declaredRange !== null && !satisfies(resolution.version, declaredRange)) {
|
|
451
|
+
reasons.push("lockfile resolution outside manifest range");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (reasons.length) findings2.push({
|
|
455
|
+
app: app.relativePath,
|
|
456
|
+
packageName,
|
|
457
|
+
expected,
|
|
458
|
+
declared,
|
|
459
|
+
lockfileSpecifier: resolution.specifier,
|
|
460
|
+
locked: resolution.version,
|
|
461
|
+
reason: reasons.join("; ")
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
return findings2;
|
|
465
|
+
}
|
|
250
466
|
async function checkGovernance(options) {
|
|
251
467
|
const scanRoot = resolve3(options.scanRoot);
|
|
252
468
|
const workspaceRoot = resolve3(options.workspaceRoot ?? scanRoot);
|
|
@@ -256,49 +472,1092 @@ async function checkGovernance(options) {
|
|
|
256
472
|
}
|
|
257
473
|
const apps = await discoverApps(scanRoot, workspaceRoot);
|
|
258
474
|
if (apps.length === 0) throw new Error("No managed applications found");
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
475
|
+
const findings2 = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
|
|
476
|
+
return {
|
|
477
|
+
appsChecked: apps.length,
|
|
478
|
+
findings: findings2,
|
|
479
|
+
policyVersion: options.policy.policyVersion,
|
|
480
|
+
policySource: options.policy.source,
|
|
481
|
+
policySha256: options.policy.sha256
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/governance/assess.ts
|
|
486
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
487
|
+
import { basename, join as join8, relative as relative7, resolve as resolve8, sep as sep6 } from "path";
|
|
488
|
+
|
|
489
|
+
// src/analysis/graph.ts
|
|
490
|
+
function buildGraph(_modules) {
|
|
491
|
+
const modules = [..._modules].sort((a, b) => a.path.localeCompare(b.path));
|
|
492
|
+
const edges = modules.flatMap((module) => module.imports.filter((imported) => imported.resolved).map((imported) => ({
|
|
493
|
+
from: module.path,
|
|
494
|
+
to: imported.resolved,
|
|
495
|
+
line: imported.line,
|
|
496
|
+
specifier: imported.specifier,
|
|
497
|
+
importedNames: imported.importedNames,
|
|
498
|
+
isTypeOnly: imported.isTypeOnly
|
|
499
|
+
})));
|
|
500
|
+
return { modules, edges };
|
|
501
|
+
}
|
|
502
|
+
function findCycles(graph) {
|
|
503
|
+
const adjacent = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly).map((edge) => edge.to)]));
|
|
504
|
+
const index = /* @__PURE__ */ new Map();
|
|
505
|
+
const low = /* @__PURE__ */ new Map();
|
|
506
|
+
const stack = [];
|
|
507
|
+
const inStack = /* @__PURE__ */ new Set();
|
|
508
|
+
const cycles = [];
|
|
509
|
+
let counter = 0;
|
|
510
|
+
function visit(path) {
|
|
511
|
+
index.set(path, counter);
|
|
512
|
+
low.set(path, counter++);
|
|
513
|
+
stack.push(path);
|
|
514
|
+
inStack.add(path);
|
|
515
|
+
for (const target of adjacent.get(path) ?? []) {
|
|
516
|
+
if (!index.has(target)) {
|
|
517
|
+
visit(target);
|
|
518
|
+
low.set(path, Math.min(low.get(path), low.get(target)));
|
|
519
|
+
} else if (inStack.has(target)) {
|
|
520
|
+
low.set(path, Math.min(low.get(path), index.get(target)));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (low.get(path) !== index.get(path)) return;
|
|
524
|
+
const component = [];
|
|
525
|
+
while (stack.length) {
|
|
526
|
+
const current = stack.pop();
|
|
527
|
+
inStack.delete(current);
|
|
528
|
+
component.push(current);
|
|
529
|
+
if (current === path) break;
|
|
530
|
+
}
|
|
531
|
+
if (component.length > 1 || (adjacent.get(path) ?? []).includes(path)) cycles.push(component.sort());
|
|
532
|
+
}
|
|
533
|
+
for (const module of graph.modules) if (!index.has(module.path)) visit(module.path);
|
|
534
|
+
return cycles.sort((a, b) => a[0].localeCompare(b[0]));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/analysis/rules.ts
|
|
538
|
+
import { matchesGlob } from "path";
|
|
539
|
+
function evaluateRules(graph, config) {
|
|
540
|
+
const findings2 = [];
|
|
541
|
+
const outgoing = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly)]));
|
|
542
|
+
const incoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path && !edge.isTypeOnly)]));
|
|
543
|
+
const allIncoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path)]));
|
|
544
|
+
for (const cycle of findCycles(graph)) {
|
|
545
|
+
findings2.push({
|
|
546
|
+
ruleId: "module-cycle",
|
|
547
|
+
category: "architecture",
|
|
548
|
+
severity: "error",
|
|
549
|
+
file: cycle[0],
|
|
550
|
+
line: 1,
|
|
551
|
+
evidence: `Circular imports connect ${cycle.join(" \u2192 ")} \u2192 ${cycle[0]}.`,
|
|
552
|
+
recommendation: "Move shared contracts into a lower-level module and remove one import direction."
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
for (const module of graph.modules) {
|
|
556
|
+
const out = outgoing.get(module.path) ?? [];
|
|
557
|
+
const into = incoming.get(module.path) ?? [];
|
|
558
|
+
const seen = /* @__PURE__ */ new Set();
|
|
559
|
+
for (const imported of module.imports) {
|
|
560
|
+
if (imported.isTypeOnly) continue;
|
|
561
|
+
if (seen.has(imported.specifier)) {
|
|
562
|
+
findings2.push({
|
|
563
|
+
ruleId: "duplicate-import",
|
|
564
|
+
category: "imports",
|
|
565
|
+
severity: "warning",
|
|
566
|
+
file: module.path,
|
|
567
|
+
line: imported.line,
|
|
568
|
+
evidence: `Repeated import from ${imported.specifier}.`,
|
|
569
|
+
recommendation: "Combine imports from the same module."
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
seen.add(imported.specifier);
|
|
573
|
+
}
|
|
574
|
+
if (out.length > config.thresholds.maxFanOut) {
|
|
575
|
+
findings2.push({
|
|
576
|
+
ruleId: "high-fan-out",
|
|
577
|
+
category: "architecture",
|
|
578
|
+
severity: "warning",
|
|
579
|
+
file: module.path,
|
|
580
|
+
line: 1,
|
|
581
|
+
evidence: `${out.length} outgoing module dependencies exceed ${config.thresholds.maxFanOut}.`,
|
|
582
|
+
recommendation: "Separate unrelated responsibilities and narrow direct dependencies."
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
if (into.length > config.thresholds.maxFanIn) {
|
|
586
|
+
findings2.push({
|
|
587
|
+
ruleId: "high-fan-in",
|
|
588
|
+
category: "architecture",
|
|
589
|
+
severity: "warning",
|
|
590
|
+
file: module.path,
|
|
591
|
+
line: 1,
|
|
592
|
+
evidence: `${into.length} incoming module dependencies exceed ${config.thresholds.maxFanIn}.`,
|
|
593
|
+
recommendation: "Stabilize this shared interface before making breaking changes."
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
if (into.length === 0 && out.length === 0 && graph.modules.length > 1 && !module.isRoute) {
|
|
597
|
+
findings2.push({
|
|
598
|
+
ruleId: "orphan-module",
|
|
599
|
+
category: "architecture",
|
|
600
|
+
severity: "info",
|
|
601
|
+
file: module.path,
|
|
602
|
+
line: 1,
|
|
603
|
+
evidence: "No local module imports to or from this file were resolved.",
|
|
604
|
+
recommendation: "Confirm whether the file is an entry point or unused code."
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
if (into.length > 0 && !module.isRoute) {
|
|
608
|
+
const usedNames = new Set((allIncoming.get(module.path) ?? []).flatMap((edge) => edge.importedNames));
|
|
609
|
+
if (!usedNames.has("*")) {
|
|
610
|
+
for (const name of module.exports) {
|
|
611
|
+
if (name === "*" || module.typeExports.includes(name)) continue;
|
|
612
|
+
if (!usedNames.has(name)) {
|
|
613
|
+
findings2.push({
|
|
614
|
+
ruleId: "unused-export",
|
|
615
|
+
category: "imports",
|
|
616
|
+
severity: "info",
|
|
617
|
+
file: module.path,
|
|
618
|
+
line: 1,
|
|
619
|
+
evidence: `Export ${name} has no resolved local importer.`,
|
|
620
|
+
recommendation: "Remove the export or document its external entry-point usage."
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (module.componentCount > 0 && module.lines > config.thresholds.maxComponentLines) {
|
|
627
|
+
findings2.push({
|
|
628
|
+
ruleId: "large-component",
|
|
629
|
+
category: "components",
|
|
630
|
+
severity: "warning",
|
|
631
|
+
file: module.path,
|
|
632
|
+
line: 1,
|
|
633
|
+
evidence: `A component file has ${module.lines} lines; limit is ${config.thresholds.maxComponentLines}.`,
|
|
634
|
+
recommendation: "Extract cohesive child components or hooks."
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
if (module.jsxDepth > config.thresholds.maxJsxDepth) {
|
|
638
|
+
findings2.push({
|
|
639
|
+
ruleId: "deep-jsx",
|
|
640
|
+
category: "components",
|
|
641
|
+
severity: "warning",
|
|
642
|
+
file: module.path,
|
|
643
|
+
line: 1,
|
|
644
|
+
evidence: `JSX nesting depth ${module.jsxDepth} exceeds ${config.thresholds.maxJsxDepth}.`,
|
|
645
|
+
recommendation: "Split deeply nested JSX into named components."
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
if (module.complexity > config.thresholds.maxComplexity) {
|
|
649
|
+
findings2.push({
|
|
650
|
+
ruleId: "high-complexity",
|
|
651
|
+
category: "complexity",
|
|
652
|
+
severity: "warning",
|
|
653
|
+
file: module.path,
|
|
654
|
+
line: 1,
|
|
655
|
+
evidence: `Approximate branch complexity ${module.complexity} exceeds ${config.thresholds.maxComplexity}.`,
|
|
656
|
+
recommendation: "Simplify branches and extract focused functions."
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
for (const policy of config.policies) {
|
|
660
|
+
if (!policy.from.some((pattern) => matchesGlob(module.path, pattern))) continue;
|
|
661
|
+
for (const edge of out) {
|
|
662
|
+
const allowed = policy.allow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
663
|
+
const disallowed = policy.disallow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
664
|
+
if (policy.allow && !allowed || policy.disallow && disallowed) {
|
|
665
|
+
findings2.push({
|
|
666
|
+
ruleId: policy.id,
|
|
667
|
+
category: "architecture",
|
|
668
|
+
severity: policy.severity,
|
|
669
|
+
file: module.path,
|
|
670
|
+
line: edge.line,
|
|
671
|
+
evidence: `${module.path} imports ${edge.to}, violating ${policy.description}.`,
|
|
672
|
+
recommendation: policy.recommendation ?? "Refactor the import to respect this module boundary."
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const maxDepth = config.thresholds.maxChainDepth;
|
|
679
|
+
for (const module of graph.modules) {
|
|
680
|
+
if (maxDepth <= 0) continue;
|
|
681
|
+
const visit = (path, visited, depth) => {
|
|
682
|
+
if (depth > maxDepth) return true;
|
|
683
|
+
for (const edge of outgoing.get(path) ?? []) {
|
|
684
|
+
if (visited.has(edge.to)) continue;
|
|
685
|
+
const next = new Set(visited);
|
|
686
|
+
next.add(edge.to);
|
|
687
|
+
if (visit(edge.to, next, depth + 1)) return true;
|
|
688
|
+
}
|
|
689
|
+
return false;
|
|
690
|
+
};
|
|
691
|
+
if (visit(module.path, /* @__PURE__ */ new Set([module.path]), 0)) {
|
|
692
|
+
findings2.push({
|
|
693
|
+
ruleId: "deep-dependency-chain",
|
|
694
|
+
category: "architecture",
|
|
695
|
+
severity: "warning",
|
|
696
|
+
file: module.path,
|
|
697
|
+
line: 1,
|
|
698
|
+
evidence: `A dependency chain exceeds ${maxDepth} local hops.`,
|
|
699
|
+
recommendation: "Introduce a stable boundary or flatten unnecessary layers."
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return findings2.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// src/analysis/scan.ts
|
|
707
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
708
|
+
import { join as join5, resolve as resolve5 } from "path";
|
|
709
|
+
import fastGlob2 from "fast-glob";
|
|
710
|
+
|
|
711
|
+
// src/analysis/parse.ts
|
|
712
|
+
import ts from "typescript";
|
|
713
|
+
function hasModifier(node, kind) {
|
|
714
|
+
return ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) === true;
|
|
715
|
+
}
|
|
716
|
+
function containsJsx(node) {
|
|
717
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true;
|
|
718
|
+
return ts.forEachChild(node, containsJsx) === true;
|
|
719
|
+
}
|
|
720
|
+
function importNames(clause) {
|
|
721
|
+
if (!clause) return [];
|
|
722
|
+
const names = [];
|
|
723
|
+
if (clause.name) names.push("default");
|
|
724
|
+
if (clause.namedBindings) {
|
|
725
|
+
if (ts.isNamespaceImport(clause.namedBindings)) names.push("*");
|
|
726
|
+
else for (const item of clause.namedBindings.elements) names.push(item.propertyName?.text ?? item.name.text);
|
|
727
|
+
}
|
|
728
|
+
return names;
|
|
729
|
+
}
|
|
730
|
+
function sourceKind(path) {
|
|
731
|
+
if (path.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
|
732
|
+
if (path.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
|
733
|
+
if (path.endsWith(".js") || path.endsWith(".mjs") || path.endsWith(".cjs")) return ts.ScriptKind.JS;
|
|
734
|
+
return ts.ScriptKind.TS;
|
|
735
|
+
}
|
|
736
|
+
function parseModule(path, contents) {
|
|
737
|
+
const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
|
|
738
|
+
const imports = [];
|
|
739
|
+
const exports = [];
|
|
740
|
+
const typeExports = [];
|
|
741
|
+
let jsxDepth = 0;
|
|
742
|
+
let complexity = 1;
|
|
743
|
+
let componentCount = 0;
|
|
744
|
+
for (const node of source.statements) {
|
|
745
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
746
|
+
imports.push({
|
|
747
|
+
specifier: node.moduleSpecifier.text,
|
|
748
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
749
|
+
importedNames: importNames(node.importClause),
|
|
750
|
+
isTypeOnly: node.importClause?.isTypeOnly ?? false
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
if (ts.isExportDeclaration(node)) {
|
|
754
|
+
if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
755
|
+
imports.push({
|
|
756
|
+
specifier: node.moduleSpecifier.text,
|
|
757
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
758
|
+
importedNames: node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements.map((item) => item.propertyName?.text ?? item.name.text) : ["*"],
|
|
759
|
+
isTypeOnly: node.isTypeOnly
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
763
|
+
exports.push(...node.exportClause.elements.map((item) => item.name.text));
|
|
764
|
+
if (node.isTypeOnly) typeExports.push(...node.exportClause.elements.map((item) => item.name.text));
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) {
|
|
768
|
+
if (hasModifier(node, ts.SyntaxKind.DefaultKeyword)) exports.push("default");
|
|
769
|
+
else if (ts.isVariableStatement(node)) {
|
|
770
|
+
for (const declaration of node.declarationList.declarations) {
|
|
771
|
+
if (ts.isIdentifier(declaration.name)) exports.push(declaration.name.text);
|
|
772
|
+
}
|
|
773
|
+
} else if ("name" in node && node.name && ts.isIdentifier(node.name)) {
|
|
774
|
+
const name = node.name.text;
|
|
775
|
+
exports.push(name);
|
|
776
|
+
if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) typeExports.push(name);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (ts.isFunctionDeclaration(node) && node.name && /^[A-Z]/.test(node.name.text) && containsJsx(node)) {
|
|
780
|
+
componentCount++;
|
|
781
|
+
}
|
|
782
|
+
if (ts.isVariableStatement(node)) {
|
|
783
|
+
for (const declaration of node.declarationList.declarations) {
|
|
784
|
+
if (ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text) && declaration.initializer && containsJsx(declaration.initializer)) {
|
|
785
|
+
componentCount++;
|
|
278
786
|
}
|
|
279
787
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function visit(node, depth) {
|
|
791
|
+
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
|
|
792
|
+
const argument = node.arguments[0];
|
|
793
|
+
if (argument && ts.isStringLiteralLike(argument)) {
|
|
794
|
+
imports.push({
|
|
795
|
+
specifier: argument.text,
|
|
796
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
797
|
+
importedNames: ["*"],
|
|
798
|
+
isTypeOnly: false
|
|
289
799
|
});
|
|
290
800
|
}
|
|
291
801
|
}
|
|
802
|
+
const isJsx = ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
|
|
803
|
+
const nextDepth = depth + (isJsx ? 1 : 0);
|
|
804
|
+
jsxDepth = Math.max(jsxDepth, nextDepth);
|
|
805
|
+
if (ts.isIfStatement(node) || ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isCatchClause(node) || ts.isConditionalExpression(node) || ts.isCaseBlock(node)) complexity++;
|
|
806
|
+
if (ts.isBinaryExpression(node) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind)) {
|
|
807
|
+
complexity++;
|
|
808
|
+
}
|
|
809
|
+
ts.forEachChild(node, (child) => visit(child, nextDepth));
|
|
292
810
|
}
|
|
811
|
+
visit(source, 0);
|
|
293
812
|
return {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
813
|
+
path,
|
|
814
|
+
imports,
|
|
815
|
+
exports: [...new Set(exports)].sort(),
|
|
816
|
+
typeExports: [...new Set(typeExports)].sort(),
|
|
817
|
+
lines: contents.split(/\r?\n/).length,
|
|
818
|
+
jsxDepth,
|
|
819
|
+
componentCount,
|
|
820
|
+
complexity,
|
|
821
|
+
isRoute: /(^|\/)(app|pages)\/.+(page|layout|route|index)\.[cm]?[jt]sx?$/.test(path) || /(^|\/)app\/(page|layout|route)\.[cm]?[jt]sx?$/.test(path)
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
function moduleParseDiagnostics(path, contents) {
|
|
825
|
+
const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
|
|
826
|
+
const diagnostics = source.parseDiagnostics ?? [];
|
|
827
|
+
const messages = diagnostics.map((diagnostic) => {
|
|
828
|
+
const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1;
|
|
829
|
+
return `${path}:${line}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`;
|
|
830
|
+
});
|
|
831
|
+
function visit(node) {
|
|
832
|
+
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
|
|
833
|
+
const argument = node.arguments[0];
|
|
834
|
+
if (!argument || !ts.isStringLiteralLike(argument)) {
|
|
835
|
+
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
836
|
+
messages.push(`${path}:${line}: nonliteral dynamic import or require cannot be analyzed`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
ts.forEachChild(node, visit);
|
|
840
|
+
}
|
|
841
|
+
visit(source);
|
|
842
|
+
return messages;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// src/analysis/resolve.ts
|
|
846
|
+
import { readFile as readFile5, stat } from "fs/promises";
|
|
847
|
+
import { dirname as dirname2, extname, join as join4, relative as relative4, resolve as resolve4, sep as sep3 } from "path";
|
|
848
|
+
import ts2 from "typescript";
|
|
849
|
+
async function compilerOptions(root) {
|
|
850
|
+
const path = join4(root, "tsconfig.json");
|
|
851
|
+
let content;
|
|
852
|
+
try {
|
|
853
|
+
content = await readFile5(path, "utf8");
|
|
854
|
+
} catch (error) {
|
|
855
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return { options: {}, diagnostics: [] };
|
|
856
|
+
throw error;
|
|
857
|
+
}
|
|
858
|
+
try {
|
|
859
|
+
const json = ts2.parseConfigFileTextToJson(path, content);
|
|
860
|
+
if (json.error) return { options: {}, diagnostics: [`tsconfig.json: ${ts2.flattenDiagnosticMessageText(json.error.messageText, " ")}`] };
|
|
861
|
+
const parsed = ts2.parseJsonConfigFileContent(json.config, ts2.sys, root);
|
|
862
|
+
return { options: parsed.options, diagnostics: parsed.errors.map((error) => `tsconfig.json: ${ts2.flattenDiagnosticMessageText(error.messageText, " ")}`) };
|
|
863
|
+
} catch (error) {
|
|
864
|
+
return { options: {}, diagnostics: [`tsconfig.json: ${error instanceof Error ? error.message : String(error)}`] };
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
function propertyName(name) {
|
|
868
|
+
if (ts2.isIdentifier(name) || ts2.isStringLiteral(name)) return name.text;
|
|
869
|
+
return void 0;
|
|
870
|
+
}
|
|
871
|
+
function staticAliasTarget(node) {
|
|
872
|
+
if (ts2.isStringLiteralLike(node)) return node.text;
|
|
873
|
+
if (ts2.isCallExpression(node) || ts2.isNewExpression(node)) {
|
|
874
|
+
for (const argument of node.arguments ?? []) {
|
|
875
|
+
const found = staticAliasTarget(argument);
|
|
876
|
+
if (found?.startsWith("./")) return found;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return void 0;
|
|
880
|
+
}
|
|
881
|
+
async function viteAliasDiagnostics(root, options, aliases) {
|
|
882
|
+
const diagnostics = [];
|
|
883
|
+
for (const file of ["vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs", "vite.config.cts", "vite.config.cjs"]) {
|
|
884
|
+
let visit2 = function(node) {
|
|
885
|
+
if (ts2.isPropertyAssignment(node) && propertyName(node.name) === "resolve") {
|
|
886
|
+
if (!ts2.isObjectLiteralExpression(node.initializer)) {
|
|
887
|
+
diagnostics.push(`${file}: dynamic Vite resolve configuration cannot be verified`);
|
|
888
|
+
ts2.forEachChild(node, visit2);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
for (const property of node.initializer.properties) {
|
|
892
|
+
if (!ts2.isPropertyAssignment(property) || !propertyName(property.name)) {
|
|
893
|
+
diagnostics.push(`${file}: dynamic Vite resolve entry cannot be verified`);
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
if (propertyName(property.name) !== "alias") continue;
|
|
897
|
+
if (!ts2.isObjectLiteralExpression(property.initializer)) {
|
|
898
|
+
diagnostics.push(`${file}: dynamic Vite alias configuration cannot be verified`);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
for (const alias of property.initializer.properties) {
|
|
902
|
+
if (!ts2.isPropertyAssignment(alias) || !propertyName(alias.name)) {
|
|
903
|
+
diagnostics.push(`${file}: dynamic Vite alias entry cannot be verified`);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const key = propertyName(alias.name);
|
|
907
|
+
const prefix = key.endsWith("/") ? key : `${key}/`;
|
|
908
|
+
const centralTarget = aliases[prefix];
|
|
909
|
+
const tsTarget = options.paths?.[`${key}/*`]?.[0];
|
|
910
|
+
const mapped = centralTarget ?? (tsTarget?.endsWith("/*") ? tsTarget.slice(0, -1).replace(/^\.\//, "") : void 0);
|
|
911
|
+
if (!mapped) {
|
|
912
|
+
diagnostics.push(`${file}: Vite alias ${key} needs a central analysis.aliases or matching tsconfig paths mapping`);
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
const target = staticAliasTarget(alias.initializer);
|
|
916
|
+
if (!target?.startsWith("./")) {
|
|
917
|
+
diagnostics.push(`${file}: Vite alias ${key} target cannot be verified statically`);
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
const expected = `${target.slice(2).replace(/\/$/, "")}/`;
|
|
921
|
+
if (mapped !== expected) diagnostics.push(`${file}: Vite alias ${key} maps to ${expected}, but assessment uses ${mapped}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
ts2.forEachChild(node, visit2);
|
|
926
|
+
};
|
|
927
|
+
var visit = visit2;
|
|
928
|
+
let sourceText;
|
|
929
|
+
try {
|
|
930
|
+
sourceText = await readFile5(join4(root, file), "utf8");
|
|
931
|
+
} catch (error) {
|
|
932
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue;
|
|
933
|
+
throw error;
|
|
934
|
+
}
|
|
935
|
+
const source = ts2.createSourceFile(file, sourceText, ts2.ScriptTarget.Latest, true);
|
|
936
|
+
const defaultExport = source.statements.find((statement) => ts2.isExportAssignment(statement) && !statement.isExportEquals);
|
|
937
|
+
if (!defaultExport || !ts2.isExportAssignment(defaultExport)) {
|
|
938
|
+
diagnostics.push(`${file}: Vite configuration export cannot be verified statically`);
|
|
939
|
+
} else {
|
|
940
|
+
const expression = defaultExport.expression;
|
|
941
|
+
const config = ts2.isCallExpression(expression) && expression.expression.getText(source) === "defineConfig" ? expression.arguments[0] : expression;
|
|
942
|
+
if (!config || !ts2.isObjectLiteralExpression(config)) {
|
|
943
|
+
diagnostics.push(`${file}: Vite configuration export cannot be verified statically`);
|
|
944
|
+
} else if (config.properties.some((property) => !ts2.isPropertyAssignment(property) || !propertyName(property.name))) {
|
|
945
|
+
diagnostics.push(`${file}: dynamic Vite configuration entry cannot be verified`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
visit2(source);
|
|
949
|
+
}
|
|
950
|
+
return diagnostics;
|
|
951
|
+
}
|
|
952
|
+
async function existingNonSourceAsset(path) {
|
|
953
|
+
if (/\.[cm]?[jt]sx?$/.test(extname(path)) || path.endsWith(".d.ts")) return false;
|
|
954
|
+
return (await stat(path).catch(() => null))?.isFile() === true;
|
|
955
|
+
}
|
|
956
|
+
async function resolveImportsDetailed(root, modules, aliases = {}) {
|
|
957
|
+
const { options, diagnostics } = await compilerOptions(root);
|
|
958
|
+
diagnostics.push(...await viteAliasDiagnostics(root, options, aliases));
|
|
959
|
+
const files = new Set(modules.map((module) => module.path));
|
|
960
|
+
for (const module of modules) {
|
|
961
|
+
const from = join4(root, module.path);
|
|
962
|
+
for (const imported of module.imports) {
|
|
963
|
+
const specifier = imported.specifier.split("?")[0];
|
|
964
|
+
const matchingAlias = Object.keys(aliases).sort((a, b) => b.length - a.length).find((prefix) => specifier.startsWith(prefix));
|
|
965
|
+
const aliasTarget = matchingAlias ? resolve4(root, aliases[matchingAlias], specifier.slice(matchingAlias.length)) : void 0;
|
|
966
|
+
const candidate = aliasTarget ? relative4(dirname2(from), aliasTarget).replaceAll("\\", "/") : specifier;
|
|
967
|
+
const lookup = aliasTarget && !candidate.startsWith(".") ? `./${candidate}` : candidate;
|
|
968
|
+
const result = ts2.resolveModuleName(lookup, from, options, ts2.sys).resolvedModule;
|
|
969
|
+
const localAlias = Object.keys(options.paths ?? {}).some((pattern) => {
|
|
970
|
+
const [prefix, suffix = ""] = pattern.split("*");
|
|
971
|
+
return specifier.startsWith(prefix) && specifier.endsWith(suffix);
|
|
972
|
+
});
|
|
973
|
+
const isLocal = specifier.startsWith(".") || localAlias || aliasTarget !== void 0 || /^[~@#]\//.test(specifier);
|
|
974
|
+
if (!result) {
|
|
975
|
+
const assetPath = aliasTarget ?? (specifier.startsWith(".") ? resolve4(dirname2(from), specifier) : void 0);
|
|
976
|
+
const assetRelative = assetPath ? relative4(root, assetPath) : void 0;
|
|
977
|
+
if (assetRelative && (assetRelative === ".." || assetRelative.startsWith(`..${sep3}`))) {
|
|
978
|
+
diagnostics.push(`${module.path}:${imported.line}: local import escapes app root ${imported.specifier}`);
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
if (assetPath && await existingNonSourceAsset(assetPath)) continue;
|
|
982
|
+
if (isLocal) diagnostics.push(`${module.path}:${imported.line}: unresolved local import ${imported.specifier}`);
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
const path = relative4(root, resolve4(result.resolvedFileName));
|
|
986
|
+
if (path === ".." || path.startsWith(`..${sep3}`)) {
|
|
987
|
+
if (isLocal) diagnostics.push(`${module.path}:${imported.line}: local import escapes app root ${imported.specifier}`);
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
const normalized = path.replaceAll("\\", "/");
|
|
991
|
+
if (files.has(normalized)) imported.resolved = normalized;
|
|
992
|
+
else if (isLocal && !normalized.endsWith(".d.ts")) diagnostics.push(`${module.path}:${imported.line}: local import outside analyzed source ${imported.specifier}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return diagnostics;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// src/analysis/scan.ts
|
|
999
|
+
async function scanProjectDetailed(projectRoot, aliases = {}) {
|
|
1000
|
+
const root = resolve5(projectRoot);
|
|
1001
|
+
const files = await fastGlob2("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
|
|
1002
|
+
cwd: root,
|
|
1003
|
+
onlyFiles: true,
|
|
1004
|
+
unique: true,
|
|
1005
|
+
dot: true,
|
|
1006
|
+
followSymbolicLinks: false,
|
|
1007
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/.next/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**", "**/*.d.ts"]
|
|
1008
|
+
});
|
|
1009
|
+
const parsed = await Promise.all(files.sort().map(async (path) => {
|
|
1010
|
+
const normalized = path.replaceAll("\\", "/");
|
|
1011
|
+
const contents = await readFile6(join5(root, path), "utf8");
|
|
1012
|
+
return { module: parseModule(normalized, contents), diagnostics: moduleParseDiagnostics(normalized, contents) };
|
|
1013
|
+
}));
|
|
1014
|
+
const modules = parsed.map((item) => item.module);
|
|
1015
|
+
const diagnostics = parsed.flatMap((item) => item.diagnostics);
|
|
1016
|
+
diagnostics.push(...await resolveImportsDetailed(root, modules, aliases));
|
|
1017
|
+
return { modules, diagnostics };
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// src/analysis/score.ts
|
|
1021
|
+
function round(value) {
|
|
1022
|
+
return Math.round(value * 100) / 100;
|
|
1023
|
+
}
|
|
1024
|
+
function grade(score) {
|
|
1025
|
+
if (score >= 95) return "A+";
|
|
1026
|
+
if (score >= 90) return "A";
|
|
1027
|
+
if (score >= 80) return "B";
|
|
1028
|
+
if (score >= 70) return "C";
|
|
1029
|
+
if (score >= 55) return "D";
|
|
1030
|
+
return "F";
|
|
1031
|
+
}
|
|
1032
|
+
function scoreFindings(findings2, config) {
|
|
1033
|
+
const penalties = { error: 18, warning: 7, info: 2 };
|
|
1034
|
+
const categories = Object.entries(config.weights).map(([id, weight]) => {
|
|
1035
|
+
const categoryFindings = findings2.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
|
|
1036
|
+
const occurrences = /* @__PURE__ */ new Map();
|
|
1037
|
+
let deduction = 0;
|
|
1038
|
+
for (const finding of categoryFindings) {
|
|
1039
|
+
const occurrence = (occurrences.get(finding.ruleId) ?? 0) + 1;
|
|
1040
|
+
occurrences.set(finding.ruleId, occurrence);
|
|
1041
|
+
deduction += penalties[finding.severity] / Math.sqrt(occurrence);
|
|
1042
|
+
}
|
|
1043
|
+
deduction = Math.min(100, deduction);
|
|
1044
|
+
return { id, weight, score: round(100 - deduction), deduction: round(deduction), findingCount: categoryFindings.length };
|
|
1045
|
+
});
|
|
1046
|
+
const weightedDeduction = round(categories.reduce((sum, category) => sum + category.deduction * category.weight / 100, 0));
|
|
1047
|
+
const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings2.length - 10) * 0.15));
|
|
1048
|
+
const criticalAdjustment = round(Math.min(10, findings2.filter((finding) => finding.severity === "error").length * 1.5));
|
|
1049
|
+
const score = round(Math.max(0, Math.min(100, 100 - weightedDeduction - maintenanceAdjustment - criticalAdjustment)));
|
|
1050
|
+
return { score, grade: grade(score), weightedDeduction, maintenanceAdjustment, criticalAdjustment, categories };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// src/governance/inventory.ts
|
|
1054
|
+
import { createHash as createHash2 } from "crypto";
|
|
1055
|
+
import { readFile as readFile7, realpath } from "fs/promises";
|
|
1056
|
+
import { isAbsolute, join as join6, relative as relative5, resolve as resolve6, sep as sep4 } from "path";
|
|
1057
|
+
var profiles2 = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
|
|
1058
|
+
async function loadInventory(source, scanRoot) {
|
|
1059
|
+
const absolute = resolve6(source);
|
|
1060
|
+
const root = await realpath(scanRoot);
|
|
1061
|
+
const inventoryPath = await realpath(absolute);
|
|
1062
|
+
const inventoryRelative = relative5(root, inventoryPath);
|
|
1063
|
+
if (inventoryRelative === ".." || inventoryRelative.startsWith(`..${sep4}`)) throw new Error("Inventory must be inside scan root");
|
|
1064
|
+
const raw = await readFile7(inventoryPath, "utf8");
|
|
1065
|
+
let value;
|
|
1066
|
+
try {
|
|
1067
|
+
value = JSON.parse(raw);
|
|
1068
|
+
} catch {
|
|
1069
|
+
throw new Error("Invalid app inventory JSON");
|
|
1070
|
+
}
|
|
1071
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Invalid app inventory object");
|
|
1072
|
+
const parsed = value;
|
|
1073
|
+
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.apps) || parsed.apps.length === 0) {
|
|
1074
|
+
throw new Error("Inventory schemaVersion 1 and nonempty apps required");
|
|
1075
|
+
}
|
|
1076
|
+
const apps = [];
|
|
1077
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1078
|
+
for (const entry of parsed.apps) {
|
|
1079
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error("Invalid app inventory entry");
|
|
1080
|
+
const app = entry;
|
|
1081
|
+
const path = app.path;
|
|
1082
|
+
if (typeof path !== "string" || path !== "." && (path === "" || path.startsWith("./") || path.includes("\\") || path.split("/").some((segment) => !segment || segment === "." || segment === "..")) || isAbsolute(path)) {
|
|
1083
|
+
throw new Error(`Invalid app inventory path ${String(path)}`);
|
|
1084
|
+
}
|
|
1085
|
+
if (typeof app.profile !== "string" || !profiles2.has(app.profile)) throw new Error(`Invalid profile for ${path}`);
|
|
1086
|
+
if (seen.has(path)) throw new Error(`Duplicate inventory app ${path}`);
|
|
1087
|
+
seen.add(path);
|
|
1088
|
+
try {
|
|
1089
|
+
const actual = await realpath(join6(root, path));
|
|
1090
|
+
const rel = relative5(root, actual);
|
|
1091
|
+
if (rel === ".." || rel.startsWith(`..${sep4}`)) throw new Error(`App path escapes scan root: ${path}`);
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
if (error instanceof Error && error.message.includes("escapes scan root")) throw error;
|
|
1094
|
+
}
|
|
1095
|
+
apps.push({ path, profile: app.profile });
|
|
1096
|
+
}
|
|
1097
|
+
return { source: absolute, sha256: createHash2("sha256").update(raw).digest("hex"), apps };
|
|
1098
|
+
}
|
|
1099
|
+
function compareInventory(inventory, discovered, scanRoot) {
|
|
1100
|
+
const expected = new Map(inventory.apps.map((app) => [app.path, app.profile]));
|
|
1101
|
+
const actual = new Map(discovered.map((app) => [relative5(scanRoot, app.directory).replaceAll("\\", "/") || ".", app.profile]));
|
|
1102
|
+
const issues = [];
|
|
1103
|
+
for (const [path, profile] of expected) {
|
|
1104
|
+
if (!actual.has(path)) issues.push(`Expected app ${path} was not discovered`);
|
|
1105
|
+
else if (actual.get(path) !== profile) issues.push(`App ${path} has profile ${actual.get(path)}; inventory requires ${profile}`);
|
|
1106
|
+
}
|
|
1107
|
+
for (const path of actual.keys()) if (!expected.has(path)) issues.push(`Discovered app ${path} is absent from inventory`);
|
|
1108
|
+
return issues;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// src/governance/evidence.ts
|
|
1112
|
+
import { createHash as createHash3 } from "crypto";
|
|
1113
|
+
import { execFile } from "child_process";
|
|
1114
|
+
import { createReadStream } from "fs";
|
|
1115
|
+
import { lstat, readFile as readFile8, realpath as realpath2 } from "fs/promises";
|
|
1116
|
+
import { dirname as dirname3, join as join7, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
|
|
1117
|
+
import { promisify } from "util";
|
|
1118
|
+
import fg from "fast-glob";
|
|
1119
|
+
var execFileAsync = promisify(execFile);
|
|
1120
|
+
function object(value) {
|
|
1121
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1122
|
+
}
|
|
1123
|
+
function string(value) {
|
|
1124
|
+
return typeof value === "string" && value.length > 0;
|
|
1125
|
+
}
|
|
1126
|
+
function nonnegative(value) {
|
|
1127
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
1128
|
+
}
|
|
1129
|
+
function errors(value) {
|
|
1130
|
+
return Array.isArray(value) && value.every(string);
|
|
1131
|
+
}
|
|
1132
|
+
function findings(value) {
|
|
1133
|
+
return Array.isArray(value) && value.every((item) => object(item) && string(item.id) && string(item.severity) && string(item.subject) && string(item.message) && (item.remediation === void 0 || string(item.remediation)));
|
|
1134
|
+
}
|
|
1135
|
+
function base(value) {
|
|
1136
|
+
return object(value) && string(value.tool) && string(value.version) && typeof value.complete === "boolean" && errors(value.errors);
|
|
1137
|
+
}
|
|
1138
|
+
function validateApp(value) {
|
|
1139
|
+
if (!object(value) || !string(value.path) || !string(value.profile)) return false;
|
|
1140
|
+
const dependency = value.dependencySecurity;
|
|
1141
|
+
const quality = value.codeQuality;
|
|
1142
|
+
const security = value.codeSecurity;
|
|
1143
|
+
const compatibility = value.compatibility;
|
|
1144
|
+
if (!base(dependency) || !nonnegative(dependency.packagesScanned) || !findings(dependency.advisories)) return false;
|
|
1145
|
+
if (!object(quality) || !object(quality.lint) || !string(quality.lint.tool) || !string(quality.lint.version) || typeof quality.lint.complete !== "boolean" || !base(quality.typecheck) || !base(quality.tests) || !base(quality.coverage)) return false;
|
|
1146
|
+
if (!nonnegative(quality.lint.filesScanned) || !nonnegative(quality.lint.errorCount) || !nonnegative(quality.lint.warnings) || !errors(quality.lint.errorsDetail) || typeof quality.typecheck.passed !== "boolean" || quality.typecheck.applicable !== void 0 && typeof quality.typecheck.applicable !== "boolean" || !nonnegative(quality.tests.passed) || !nonnegative(quality.tests.failed) || typeof quality.coverage.linesPercent !== "number" || !Number.isFinite(quality.coverage.linesPercent) || quality.coverage.linesPercent < 0 || quality.coverage.linesPercent > 100) return false;
|
|
1147
|
+
if (!object(security) || !base(security.sast) || !base(security.secrets) || !nonnegative(security.sast.filesScanned) || !nonnegative(security.secrets.filesScanned) || !findings(security.sast.findings) || !findings(security.secrets.findings)) return false;
|
|
1148
|
+
if (!base(compatibility) || !Array.isArray(compatibility.projects) || !compatibility.projects.every((project) => object(project) && string(project.name) && nonnegative(project.passed) && nonnegative(project.failed) && nonnegative(project.skipped) && (project.browserVersion === void 0 || string(project.browserVersion)) && (project.engine === void 0 || string(project.engine)) && (project.mobile === void 0 || typeof project.mobile === "boolean") && (project.device === void 0 || string(project.device)) && (project.viewportWidths === void 0 || Array.isArray(project.viewportWidths) && project.viewportWidths.every(nonnegative)))) return false;
|
|
1149
|
+
return true;
|
|
1150
|
+
}
|
|
1151
|
+
async function hashFile(hash, path) {
|
|
1152
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
1153
|
+
}
|
|
1154
|
+
async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
|
|
1155
|
+
const root = resolve7(scanRoot);
|
|
1156
|
+
const workspace = resolve7(workspaceRoot);
|
|
1157
|
+
const scanRelative = relative6(workspace, root);
|
|
1158
|
+
if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
|
|
1159
|
+
const paths = await fg("**/*", {
|
|
1160
|
+
cwd: root,
|
|
1161
|
+
dot: true,
|
|
1162
|
+
onlyFiles: true,
|
|
1163
|
+
followSymbolicLinks: false,
|
|
1164
|
+
ignore: [
|
|
1165
|
+
"**/.git/**",
|
|
1166
|
+
"**/node_modules/**",
|
|
1167
|
+
"**/dist/**",
|
|
1168
|
+
"**/build/**",
|
|
1169
|
+
"**/.next/**",
|
|
1170
|
+
"**/coverage/**",
|
|
1171
|
+
"**/playwright-report/**",
|
|
1172
|
+
"**/test-results/**",
|
|
1173
|
+
"**/.nklattice-governance/**",
|
|
1174
|
+
"**/.nklattice-report/**",
|
|
1175
|
+
"**/AGENTS.md",
|
|
1176
|
+
"**/*.tsbuildinfo",
|
|
1177
|
+
"**/next-env.d.ts"
|
|
1178
|
+
]
|
|
1179
|
+
});
|
|
1180
|
+
paths.sort();
|
|
1181
|
+
const hash = createHash3("sha256");
|
|
1182
|
+
for (const path of paths) {
|
|
1183
|
+
const absolute = resolve7(root, path);
|
|
1184
|
+
const normalized = relative6(root, absolute);
|
|
1185
|
+
if (normalized === ".." || normalized.startsWith(`..${sep5}`)) throw new Error("Input path escapes scan root");
|
|
1186
|
+
if (!(await lstat(absolute)).isFile()) continue;
|
|
1187
|
+
hash.update(path.replaceAll("\\", "/"));
|
|
1188
|
+
hash.update("\0");
|
|
1189
|
+
await hashFile(hash, absolute);
|
|
1190
|
+
hash.update("\0");
|
|
1191
|
+
}
|
|
1192
|
+
let ancestor = root;
|
|
1193
|
+
while (ancestor === workspace || ancestor !== dirname3(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
|
|
1194
|
+
for (const name of [
|
|
1195
|
+
"package.json",
|
|
1196
|
+
"package-lock.json",
|
|
1197
|
+
"pnpm-lock.yaml",
|
|
1198
|
+
"pnpm-workspace.yaml",
|
|
1199
|
+
"tsconfig.json",
|
|
1200
|
+
"eslint.config.js",
|
|
1201
|
+
"eslint.config.mjs",
|
|
1202
|
+
"eslint.config.cjs",
|
|
1203
|
+
"eslint.config.ts",
|
|
1204
|
+
"eslint.config.mts",
|
|
1205
|
+
"vitest.config.js",
|
|
1206
|
+
"vitest.config.mjs",
|
|
1207
|
+
"vitest.config.ts",
|
|
1208
|
+
"vitest.config.mts",
|
|
1209
|
+
"playwright.config.js",
|
|
1210
|
+
"playwright.config.mjs",
|
|
1211
|
+
"playwright.config.ts",
|
|
1212
|
+
"playwright.config.mts",
|
|
1213
|
+
".semgrep.yml",
|
|
1214
|
+
".semgrep.yaml",
|
|
1215
|
+
"semgrep.yml",
|
|
1216
|
+
"semgrep.yaml"
|
|
1217
|
+
]) {
|
|
1218
|
+
const path = join7(ancestor, name);
|
|
1219
|
+
try {
|
|
1220
|
+
const stat2 = await lstat(path);
|
|
1221
|
+
if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
|
|
1222
|
+
if (!stat2.isFile()) continue;
|
|
1223
|
+
hash.update(relative6(workspace, path).replaceAll("\\", "/"));
|
|
1224
|
+
hash.update("\0");
|
|
1225
|
+
await hashFile(hash, path);
|
|
1226
|
+
hash.update("\0");
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
if (error.code !== "ENOENT") throw error;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (ancestor === workspace) break;
|
|
1232
|
+
ancestor = dirname3(ancestor);
|
|
1233
|
+
}
|
|
1234
|
+
return hash.digest("hex");
|
|
1235
|
+
}
|
|
1236
|
+
async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
|
|
1237
|
+
const source = resolve7(path);
|
|
1238
|
+
const stat2 = await lstat(source);
|
|
1239
|
+
if (!stat2.isFile() || stat2.isSymbolicLink() || stat2.size > 1e7) throw new Error("Evidence must be a regular JSON file under 10 MB");
|
|
1240
|
+
const root = resolve7(scanRoot);
|
|
1241
|
+
const relativePath = relative6(root, source);
|
|
1242
|
+
if (relativePath !== ".." && !relativePath.startsWith(`..${sep5}`)) throw new Error("Evidence file must be outside the scan root");
|
|
1243
|
+
const actualRoot = await realpath2(root);
|
|
1244
|
+
const actualSource = await realpath2(source);
|
|
1245
|
+
const actualRelative = relative6(actualRoot, actualSource);
|
|
1246
|
+
if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
|
|
1247
|
+
let parsed;
|
|
1248
|
+
try {
|
|
1249
|
+
parsed = JSON.parse(await readFile8(source, "utf8"));
|
|
1250
|
+
} catch {
|
|
1251
|
+
throw new Error("Evidence is not valid JSON");
|
|
1252
|
+
}
|
|
1253
|
+
return validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours);
|
|
1254
|
+
}
|
|
1255
|
+
async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
|
|
1256
|
+
if (!object(parsed) || parsed.schemaVersion !== 1 || !string(parsed.generatedAt) || !string(parsed.policySha256) || !string(parsed.inputSha256) || !string(parsed.commit) || !/^[0-9a-f]{40}$/.test(parsed.commit) || !Array.isArray(parsed.apps) || !parsed.apps.every(validateApp)) throw new Error("Evidence schema is invalid or incomplete");
|
|
1257
|
+
if (parsed.policySha256 !== policySha256) throw new Error("Evidence was generated for another policy");
|
|
1258
|
+
const generatedAt = Date.parse(parsed.generatedAt);
|
|
1259
|
+
if (!Number.isFinite(generatedAt) || generatedAt > Date.now() + 6e4 || Date.now() - generatedAt > maximumAgeHours * 36e5) {
|
|
1260
|
+
throw new Error("Evidence is stale or has an invalid timestamp");
|
|
1261
|
+
}
|
|
1262
|
+
if (parsed.inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) throw new Error("Evidence inputs changed after scanning");
|
|
1263
|
+
const current = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot })).stdout.trim();
|
|
1264
|
+
if (!/^[0-9a-f]{40}$/.test(current) || parsed.commit !== current) throw new Error("Evidence commit differs from the checked-out commit");
|
|
1265
|
+
const appPaths = parsed.apps.map((app) => app.path);
|
|
1266
|
+
if (new Set(appPaths).size !== appPaths.length) throw new Error("Evidence contains duplicate app paths");
|
|
1267
|
+
return parsed;
|
|
1268
|
+
}
|
|
1269
|
+
var incomplete = (issues, tool, version, scanned) => ({ status: "incomplete", issues, tool, version, scanned });
|
|
1270
|
+
function evaluateAppEvidence(app, policy) {
|
|
1271
|
+
if (!app) {
|
|
1272
|
+
const missing = incomplete(["No fresh scan evidence for this app"]);
|
|
1273
|
+
return { dependencySecurity: { ...missing }, codeQuality: { ...missing }, codeSecurity: { ...missing }, compatibility: { ...missing } };
|
|
1274
|
+
}
|
|
1275
|
+
const dependency = app.dependencySecurity;
|
|
1276
|
+
let dependencySecurity;
|
|
1277
|
+
if (!dependency.complete || dependency.packagesScanned === 0 || dependency.errors.length) {
|
|
1278
|
+
dependencySecurity = { ...incomplete(["Dependency advisory scan did not cover the lockfile", ...dependency.errors], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories };
|
|
1279
|
+
} else {
|
|
1280
|
+
const high2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "high").length;
|
|
1281
|
+
const critical2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "critical").length;
|
|
1282
|
+
const unknown = dependency.advisories.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
|
|
1283
|
+
dependencySecurity = unknown.length ? { ...incomplete([`${unknown.length} advisories have unknown severity`], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories } : {
|
|
1284
|
+
status: high2 > policy.dependencySecurity.maximumHigh || critical2 > policy.dependencySecurity.maximumCritical ? "fail" : "pass",
|
|
1285
|
+
issues: [
|
|
1286
|
+
...high2 > policy.dependencySecurity.maximumHigh ? [`${high2} high advisories exceed ${policy.dependencySecurity.maximumHigh}`] : [],
|
|
1287
|
+
...critical2 > policy.dependencySecurity.maximumCritical ? [`${critical2} critical advisories exceed ${policy.dependencySecurity.maximumCritical}`] : []
|
|
1288
|
+
],
|
|
1289
|
+
tool: dependency.tool,
|
|
1290
|
+
version: dependency.version,
|
|
1291
|
+
scanned: dependency.packagesScanned,
|
|
1292
|
+
findings: dependency.advisories
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
const quality = app.codeQuality;
|
|
1296
|
+
const qualityMissing = [
|
|
1297
|
+
...!quality.lint.complete || quality.lint.filesScanned === 0 ? ["ESLint did not scan source files", ...quality.lint.errorsDetail] : [],
|
|
1298
|
+
...policy.codeQuality.requireTypecheck && !quality.typecheck.complete ? ["TypeScript typecheck did not complete", ...quality.typecheck.errors] : [],
|
|
1299
|
+
...policy.codeQuality.requireTests && (!quality.tests.complete || quality.tests.passed + quality.tests.failed === 0) ? ["Tests did not run", ...quality.tests.errors] : [],
|
|
1300
|
+
...!quality.coverage.complete ? ["Line coverage is unavailable", ...quality.coverage.errors] : []
|
|
1301
|
+
];
|
|
1302
|
+
const qualityFailures = [
|
|
1303
|
+
...quality.lint.errorCount > policy.codeQuality.maximumLintErrors ? [`${quality.lint.errorCount} ESLint errors exceed ${policy.codeQuality.maximumLintErrors}`] : [],
|
|
1304
|
+
...policy.codeQuality.requireTypecheck && !quality.typecheck.passed ? ["TypeScript typecheck failed"] : [],
|
|
1305
|
+
...quality.tests.failed > 0 ? [`${quality.tests.failed} tests failed`] : [],
|
|
1306
|
+
...quality.coverage.linesPercent < policy.codeQuality.minimumLineCoverage ? [`Line coverage ${quality.coverage.linesPercent}% is below ${policy.codeQuality.minimumLineCoverage}%`] : []
|
|
1307
|
+
];
|
|
1308
|
+
const qualityVersion = `ESLint ${quality.lint.version} \xB7 TypeScript ${quality.typecheck.version} \xB7 Vitest ${quality.tests.version}`;
|
|
1309
|
+
const qualityMetrics = {
|
|
1310
|
+
lintErrors: quality.lint.errorCount,
|
|
1311
|
+
lintWarnings: quality.lint.warnings,
|
|
1312
|
+
typecheck: quality.typecheck.applicable === false ? "not applicable" : quality.typecheck.passed ? "pass" : "fail",
|
|
1313
|
+
testsPassed: quality.tests.passed,
|
|
1314
|
+
testsFailed: quality.tests.failed,
|
|
1315
|
+
lineCoveragePercent: quality.coverage.linesPercent
|
|
1316
|
+
};
|
|
1317
|
+
const codeQuality = qualityMissing.length ? { ...incomplete([...qualityMissing, ...qualityFailures], "ESLint / TypeScript / tests", qualityVersion, quality.lint.filesScanned), metrics: qualityMetrics } : {
|
|
1318
|
+
status: qualityFailures.length ? "fail" : "pass",
|
|
1319
|
+
issues: qualityFailures,
|
|
1320
|
+
tool: "ESLint / TypeScript / tests",
|
|
1321
|
+
version: qualityVersion,
|
|
1322
|
+
scanned: quality.lint.filesScanned,
|
|
1323
|
+
metrics: qualityMetrics
|
|
1324
|
+
};
|
|
1325
|
+
const security = app.codeSecurity;
|
|
1326
|
+
const securityMissing = [
|
|
1327
|
+
...!security.sast.complete || security.sast.filesScanned === 0 ? ["SAST did not scan source files", ...security.sast.errors] : [],
|
|
1328
|
+
...!security.secrets.complete || security.secrets.filesScanned === 0 ? ["Secret scanner did not scan source files", ...security.secrets.errors] : []
|
|
1329
|
+
];
|
|
1330
|
+
const high = security.sast.findings.filter((item) => item.severity.toLowerCase() === "high").length;
|
|
1331
|
+
const critical = security.sast.findings.filter((item) => item.severity.toLowerCase() === "critical").length;
|
|
1332
|
+
const unknownSecurity = security.sast.findings.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
|
|
1333
|
+
if (unknownSecurity.length) securityMissing.push(`${unknownSecurity.length} SAST findings have unknown severity`);
|
|
1334
|
+
const securityFailures = [
|
|
1335
|
+
...high > policy.codeSecurity.maximumHigh ? [`${high} high SAST findings exceed ${policy.codeSecurity.maximumHigh}`] : [],
|
|
1336
|
+
...critical > policy.codeSecurity.maximumCritical ? [`${critical} critical SAST findings exceed ${policy.codeSecurity.maximumCritical}`] : [],
|
|
1337
|
+
...security.secrets.findings.length > policy.codeSecurity.maximumSecrets ? [`${security.secrets.findings.length} detected secrets exceed ${policy.codeSecurity.maximumSecrets}`] : []
|
|
1338
|
+
];
|
|
1339
|
+
const securityVersion = `Semgrep ${security.sast.version} \xB7 Gitleaks ${security.secrets.version}`;
|
|
1340
|
+
const codeSecurity = securityMissing.length ? { ...incomplete([...securityMissing, ...securityFailures], "Semgrep / Gitleaks", securityVersion, security.sast.filesScanned), findings: [...security.sast.findings, ...security.secrets.findings] } : {
|
|
1341
|
+
status: securityFailures.length ? "fail" : "pass",
|
|
1342
|
+
issues: securityFailures,
|
|
1343
|
+
tool: "Semgrep / Gitleaks",
|
|
1344
|
+
version: securityVersion,
|
|
1345
|
+
scanned: security.sast.filesScanned,
|
|
1346
|
+
findings: [...security.sast.findings, ...security.secrets.findings]
|
|
1347
|
+
};
|
|
1348
|
+
const compatibility = app.compatibility;
|
|
1349
|
+
const projectNames = new Set(compatibility.projects.map((project) => project.name));
|
|
1350
|
+
const missingProjects = policy.compatibility.requiredProjects.filter((name) => !projectNames.has(name));
|
|
1351
|
+
const incompleteProjects = compatibility.projects.filter((project) => project.passed + project.failed < policy.compatibility.minimumTestsPerProject);
|
|
1352
|
+
const missingBrowserVersions = compatibility.projects.filter((project) => !project.browserVersion);
|
|
1353
|
+
const expectedEngines = {
|
|
1354
|
+
"chromium-desktop": "chromium",
|
|
1355
|
+
"firefox-desktop": "firefox",
|
|
1356
|
+
"webkit-desktop": "webkit",
|
|
1357
|
+
"android-chrome": "chromium",
|
|
1358
|
+
"iphone-safari": "webkit"
|
|
1359
|
+
};
|
|
1360
|
+
const invalidEngines = compatibility.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
|
|
1361
|
+
const invalidDevices = compatibility.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
|
|
1362
|
+
const missingViewports = compatibility.projects.filter((project) => new Set(project.viewportWidths ?? []).size < 2);
|
|
1363
|
+
const compatibilityMissing = [
|
|
1364
|
+
...!compatibility.complete ? ["Compatibility test run did not complete", ...compatibility.errors] : [],
|
|
1365
|
+
...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
|
|
1366
|
+
...incompleteProjects.map((project) => `${project.name} ran fewer than ${policy.compatibility.minimumTestsPerProject} tests`),
|
|
1367
|
+
...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
|
|
1368
|
+
...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
|
|
1369
|
+
...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
|
|
1370
|
+
...missingViewports.map((project) => `${project.name} did not test two viewport widths`)
|
|
1371
|
+
];
|
|
1372
|
+
const failedProjects = compatibility.projects.filter((project) => project.failed > 0);
|
|
1373
|
+
const compatibilityDomain = compatibilityMissing.length ? { ...incomplete([...compatibilityMissing, ...failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`)], compatibility.tool, compatibility.version), matrix: compatibility.projects } : {
|
|
1374
|
+
status: failedProjects.length ? "fail" : "pass",
|
|
1375
|
+
issues: failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`),
|
|
1376
|
+
tool: compatibility.tool,
|
|
1377
|
+
version: compatibility.version,
|
|
1378
|
+
scanned: compatibility.projects.reduce((count, project) => count + project.passed + project.failed, 0),
|
|
1379
|
+
matrix: compatibility.projects
|
|
1380
|
+
};
|
|
1381
|
+
return { dependencySecurity, codeQuality, codeSecurity, compatibility: compatibilityDomain };
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// src/governance/assess.ts
|
|
1385
|
+
var notEvaluated = [
|
|
1386
|
+
"vulnerability databases",
|
|
1387
|
+
"licenses",
|
|
1388
|
+
"secret detection",
|
|
1389
|
+
"test execution",
|
|
1390
|
+
"runtime behavior",
|
|
1391
|
+
"branch protection",
|
|
1392
|
+
"repository access controls",
|
|
1393
|
+
"cross-repository rollout status"
|
|
1394
|
+
];
|
|
1395
|
+
function statusOf(statuses) {
|
|
1396
|
+
if (statuses.includes("incomplete")) return "incomplete";
|
|
1397
|
+
if (statuses.includes("fail")) return "fail";
|
|
1398
|
+
return "pass";
|
|
1399
|
+
}
|
|
1400
|
+
function escapeHtml(value) {
|
|
1401
|
+
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
1402
|
+
}
|
|
1403
|
+
function renderAssessmentHtml(report) {
|
|
1404
|
+
const labels = {
|
|
1405
|
+
inventory: "App inventory",
|
|
1406
|
+
dependencies: "Dependency policy",
|
|
1407
|
+
architecture: "Architecture",
|
|
1408
|
+
dependencySecurity: "Dependency security",
|
|
1409
|
+
codeQuality: "Code quality",
|
|
1410
|
+
codeSecurity: "Code security",
|
|
1411
|
+
compatibility: "Browser compatibility"
|
|
1412
|
+
};
|
|
1413
|
+
const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
|
|
1414
|
+
const overview = domainNames.map((name) => {
|
|
1415
|
+
const statuses = report.apps.map((app) => app.domains[name]?.status ?? "incomplete");
|
|
1416
|
+
const status = statusOf(statuses);
|
|
1417
|
+
const passed = statuses.filter((item) => item === "pass").length;
|
|
1418
|
+
return `<li><span>${escapeHtml(labels[name] ?? name)}</span><strong class="${status}">${escapeHtml(status)}</strong><small>${passed}/${report.apps.length} apps passed</small></li>`;
|
|
1419
|
+
}).join("");
|
|
1420
|
+
const apps = report.apps.map((app) => {
|
|
1421
|
+
const domains = Object.entries(app.domains).map(([name, domain]) => {
|
|
1422
|
+
const evidence = domain;
|
|
1423
|
+
const identity = evidence.tool ? `<p><small>${escapeHtml(evidence.tool)} ${escapeHtml(evidence.version ?? "")} \xB7 scanned ${escapeHtml(evidence.scanned ?? "unknown")}</small></p>` : "";
|
|
1424
|
+
const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(value)}`).join(" \xB7 ")}</small></p>` : "";
|
|
1425
|
+
const findings3 = evidence.findings?.length ? `<details><summary>${evidence.findings.length} finding(s)</summary><ul>${evidence.findings.map((item) => `<li><strong>${escapeHtml(item.severity)}</strong> ${escapeHtml(item.id)} \xB7 <code>${escapeHtml(item.subject)}</code> \u2014 ${escapeHtml(item.message)}${item.remediation ? ` \xB7 ${escapeHtml(item.remediation)}` : ""}</li>`).join("")}</ul></details>` : "";
|
|
1426
|
+
const matrix = evidence.matrix?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>Browser / device</th><th>Widths</th></tr></thead><tbody>${evidence.matrix.map((item) => `<tr><th scope="row">${escapeHtml(item.name)}</th><td>${item.passed}</td><td>${item.failed}</td><td>${item.skipped}</td><td>${escapeHtml([item.browserVersion, item.device].filter(Boolean).join(" \xB7 ") || "Not recorded")}</td><td>${escapeHtml(item.viewportWidths?.join(", ") ?? "Not recorded")}</td></tr>`).join("")}</tbody></table></div>` : "";
|
|
1427
|
+
return `<tr><th scope="row">${escapeHtml(labels[name] ?? name)}</th><td class="${domain.status}">${escapeHtml(domain.status)}</td><td>${identity}${metrics}${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : "No issues"}${findings3}${matrix}</td></tr>`;
|
|
1428
|
+
}).join("");
|
|
1429
|
+
const architecture = app.domains.architecture;
|
|
1430
|
+
const categoryRows = architecture.scoreBreakdown?.categories.map((category) => `<tr><th scope="row">${escapeHtml(category.id)}</th><td>${escapeHtml(category.weight)}%</td><td>${escapeHtml(category.score)}</td><td>${escapeHtml(category.findingCount)}</td></tr>`).join("") ?? "";
|
|
1431
|
+
const findings2 = architecture.findings.map((finding) => `<tr><td>${escapeHtml(finding.severity)}</td><td>${escapeHtml(finding.ruleId)}</td><td><code>${escapeHtml(finding.file)}:${finding.line}</code></td><td>${escapeHtml(finding.evidence)}</td></tr>`).join("");
|
|
1432
|
+
return `<section class="app"><h2>${escapeHtml(app.path)} <span class="${app.status}">${escapeHtml(app.status)}</span></h2><p>Profile: ${escapeHtml(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${findings2 ? `<details><summary>Architecture findings (${architecture.findings.length})</summary><div class="scroll"><table><thead><tr><th>Severity</th><th>Rule</th><th>Location</th><th>Evidence</th></tr></thead><tbody>${findings2}</tbody></table></div></details>` : ""}</section>`;
|
|
1433
|
+
}).join("");
|
|
1434
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>NKLattice governance assessment</title><style>
|
|
1435
|
+
:root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#f3f6f4;color:#1b2925}body{max-width:1160px;margin:auto;padding:2rem 1rem}header,.app,.meta,.overview{background:white;border:1px solid #d9e1dc;border-radius:12px;padding:1.4rem;margin:0 0 1.2rem}header{background:#13231d;color:#f5f8f4;padding:2rem}header small{color:#9de2bc;font-weight:700;letter-spacing:.12em}header p{color:#c8d6ce}h1{font-size:clamp(2rem,5vw,3.5rem);line-height:1.08;margin:.3rem 0 1rem}h2{font-size:1.35rem;margin:0 0 .75rem}p{margin:.5rem 0}code{overflow-wrap:anywhere}.pass{color:#146c43;font-weight:700}.fail{color:#b3242b;font-weight:700}.incomplete{color:#8a5500;font-weight:700}header .pass{color:#9de2bc}.scroll{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d9e1dc;padding:.8rem}th:first-child{width:12rem}ul{padding-left:1.2rem;margin:.1rem 0}li+li{margin-top:.3rem}small{color:#50645c}details{border-top:1px solid #d9e1dc;margin-top:1rem;padding-top:1rem}summary{cursor:pointer;font-weight:650}details[open] summary{margin-bottom:.6rem}.domain-overview{list-style:none;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.65rem;padding:0;margin:1rem 0 0}.domain-overview li{border:1px solid #d9e1dc;background:#f8faf8;border-radius:8px;padding:.9rem;display:grid;grid-template-columns:1fr auto;gap:.35rem}.domain-overview li small{grid-column:1/-1}.domain-overview li+li{margin:0}
|
|
1436
|
+
@media(max-width:800px){.domain-overview{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:480px){.domain-overview{grid-template-columns:1fr}}</style></head><body><header><small>NKLATTICE \xB7 CONFIGURED POLICY ASSESSMENT</small><h1>Project governance <span class="${report.status}">${escapeHtml(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s) \xB7 policy ${escapeHtml(report.policy.version)}</p></header><section class="overview"><h2>Assessment by domain</h2><p>Each status is based on the checked apps and fresh scanner evidence. Open an app below for findings and browser versions.</p><ul class="domain-overview">${overview}</ul></section><section class="meta"><h2>Evidence identity</h2><p>Policy ${escapeHtml(report.policy.version)} \xB7 <code>${escapeHtml(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml(basename(report.inventory.source))}</code></p>${report.evidence ? `<p>Scanners: ${escapeHtml(report.evidence.generatedAt)} \xB7 commit <code>${escapeHtml(report.evidence.commit)}</code> \xB7 input <code>${escapeHtml(report.evidence.inputSha256)}</code></p>` : "<p>Scanner evidence unavailable.</p>"}${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml).join(", ")}.</p></section></body></html>
|
|
1437
|
+
`;
|
|
1438
|
+
}
|
|
1439
|
+
async function assessGovernance(options) {
|
|
1440
|
+
const scanRoot = resolve8(options.scanRoot);
|
|
1441
|
+
const workspaceRoot = resolve8(options.workspaceRoot ?? scanRoot);
|
|
1442
|
+
const scanRelative = relative7(workspaceRoot, scanRoot);
|
|
1443
|
+
if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
|
|
1444
|
+
if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
|
|
1445
|
+
const assessment = parseAssessmentPolicy(options.policy.assessment);
|
|
1446
|
+
const outputDirectory = resolve8(options.outputDirectory ?? join8(scanRoot, ".nklattice-governance"));
|
|
1447
|
+
const inventorySource = resolve8(options.inventory ?? join8(scanRoot, ".nklattice-apps.json"));
|
|
1448
|
+
let inventory;
|
|
1449
|
+
const issues = [];
|
|
1450
|
+
try {
|
|
1451
|
+
inventory = await loadInventory(inventorySource, scanRoot);
|
|
1452
|
+
} catch (error) {
|
|
1453
|
+
issues.push(`Inventory: ${error instanceof Error ? error.message : String(error)}`);
|
|
1454
|
+
}
|
|
1455
|
+
let apps = [];
|
|
1456
|
+
try {
|
|
1457
|
+
apps = await discoverApps(scanRoot, workspaceRoot);
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
issues.push(`Discovery: ${error instanceof Error ? error.message : String(error)}`);
|
|
1460
|
+
}
|
|
1461
|
+
if (apps.length === 0) issues.push("No managed applications discovered");
|
|
1462
|
+
if (inventory) issues.push(...compareInventory(inventory, apps, scanRoot));
|
|
1463
|
+
if (options.expectedApps !== void 0 && apps.length !== options.expectedApps) {
|
|
1464
|
+
issues.push(`Expected ${options.expectedApps} managed apps; discovered ${apps.length}`);
|
|
1465
|
+
}
|
|
1466
|
+
let evidence;
|
|
1467
|
+
if (assessment.evidence) {
|
|
1468
|
+
if (!options.evidence && !options.trustedEvidence) issues.push("Scan evidence is required by the policy; run governance collect and pass --evidence");
|
|
1469
|
+
else try {
|
|
1470
|
+
evidence = options.trustedEvidence ? await validateScanEvidence(options.trustedEvidence, scanRoot, workspaceRoot, options.policy.sha256, assessment.evidence.maximumAgeHours) : await loadScanEvidence(options.evidence, scanRoot, workspaceRoot, options.policy.sha256, assessment.evidence.maximumAgeHours);
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
issues.push(`Scan evidence: ${error instanceof Error ? error.message : String(error)}`);
|
|
1473
|
+
}
|
|
1474
|
+
if (evidence) {
|
|
1475
|
+
const discovered = new Set(apps.map((app) => relative7(scanRoot, app.directory).replaceAll("\\", "/") || "."));
|
|
1476
|
+
for (const item of evidence.apps) if (!discovered.has(item.path)) issues.push(`Scan evidence contains unrecognized app ${item.path}`);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
const results = [];
|
|
1480
|
+
for (const app of apps) {
|
|
1481
|
+
const appPath = relative7(scanRoot, app.directory).replaceAll("\\", "/") || ".";
|
|
1482
|
+
const inventoryIssues = issues.filter((issue) => issue.includes(appPath) || issue.startsWith("Inventory:") || issue.startsWith("Discovery:"));
|
|
1483
|
+
const inventoryDomain = { status: inventoryIssues.length ? "incomplete" : "pass", issues: inventoryIssues };
|
|
1484
|
+
const dependencyDomain = { status: "pass", issues: [], findings: [] };
|
|
1485
|
+
try {
|
|
1486
|
+
dependencyDomain.findings = await checkApp(app, workspaceRoot, options.policy);
|
|
1487
|
+
if (dependencyDomain.findings.length) {
|
|
1488
|
+
dependencyDomain.status = "fail";
|
|
1489
|
+
dependencyDomain.issues = dependencyDomain.findings.map((item) => `${item.packageName}: ${item.reason}; required ${item.expected}, declared ${item.declared ?? "missing"}, locked ${item.locked ?? "missing"}`);
|
|
1490
|
+
}
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
dependencyDomain.status = "incomplete";
|
|
1493
|
+
dependencyDomain.issues = [error instanceof Error ? error.message : String(error)];
|
|
1494
|
+
}
|
|
1495
|
+
const architectureDomain = {
|
|
1496
|
+
status: "pass",
|
|
1497
|
+
issues: [],
|
|
1498
|
+
filesScanned: 0,
|
|
1499
|
+
localEdges: 0,
|
|
1500
|
+
errors: 0,
|
|
1501
|
+
findings: [],
|
|
1502
|
+
diagnostics: []
|
|
1503
|
+
};
|
|
1504
|
+
try {
|
|
1505
|
+
const scan = await scanProjectDetailed(app.directory, assessment.architecture.analysis.aliases);
|
|
1506
|
+
architectureDomain.filesScanned = scan.modules.length;
|
|
1507
|
+
architectureDomain.diagnostics = scan.diagnostics;
|
|
1508
|
+
const graph = buildGraph(scan.modules);
|
|
1509
|
+
architectureDomain.localEdges = graph.edges.length;
|
|
1510
|
+
architectureDomain.findings = evaluateRules(graph, assessment.architecture.analysis);
|
|
1511
|
+
const score = scoreFindings(architectureDomain.findings, assessment.architecture.analysis);
|
|
1512
|
+
architectureDomain.score = score.score;
|
|
1513
|
+
architectureDomain.grade = score.grade;
|
|
1514
|
+
architectureDomain.scoreBreakdown = score;
|
|
1515
|
+
architectureDomain.errors = architectureDomain.findings.filter((item) => item.severity === "error").length;
|
|
1516
|
+
if (scan.modules.length === 0) architectureDomain.diagnostics.push("No supported source files found");
|
|
1517
|
+
if (architectureDomain.diagnostics.length) {
|
|
1518
|
+
architectureDomain.status = "incomplete";
|
|
1519
|
+
architectureDomain.issues.push(...architectureDomain.diagnostics);
|
|
1520
|
+
}
|
|
1521
|
+
if (score.score < assessment.architecture.minimumScore) {
|
|
1522
|
+
architectureDomain.issues.push(`Score ${score.score} is below minimum ${assessment.architecture.minimumScore}`);
|
|
1523
|
+
}
|
|
1524
|
+
if (architectureDomain.errors > assessment.architecture.maximumErrors) {
|
|
1525
|
+
architectureDomain.issues.push(`${architectureDomain.errors} error findings exceed maximum ${assessment.architecture.maximumErrors}`);
|
|
1526
|
+
}
|
|
1527
|
+
const blocked = architectureDomain.findings.filter((item) => assessment.architecture.blockedRuleIds.includes(item.ruleId));
|
|
1528
|
+
for (const finding of blocked) architectureDomain.issues.push(`Blocked ${finding.ruleId} at ${finding.file}:${finding.line}: ${finding.evidence}`);
|
|
1529
|
+
if (architectureDomain.status !== "incomplete" && architectureDomain.issues.length) architectureDomain.status = "fail";
|
|
1530
|
+
} catch (error) {
|
|
1531
|
+
architectureDomain.status = "incomplete";
|
|
1532
|
+
architectureDomain.issues.push(error instanceof Error ? error.message : String(error));
|
|
1533
|
+
}
|
|
1534
|
+
const extra = assessment.evidence ? evaluateAppEvidence(evidence?.apps.find((item) => item.path === appPath && item.profile === app.profile), assessment.evidence) : {};
|
|
1535
|
+
const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain, ...extra };
|
|
1536
|
+
results.push({ path: appPath, profile: app.profile, status: statusOf(Object.values(domains).map((domain) => domain.status)), domains });
|
|
1537
|
+
}
|
|
1538
|
+
const report = {
|
|
1539
|
+
schema: "urn:nklattice:governance-assessment:1",
|
|
1540
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1541
|
+
status: issues.length ? "incomplete" : statusOf(results.map((app) => app.status)),
|
|
1542
|
+
policy: { version: options.policy.policyVersion, source: options.policy.source, sha256: options.policy.sha256 },
|
|
1543
|
+
...evidence ? { evidence: { source: options.evidence ? resolve8(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
|
|
1544
|
+
inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
|
|
1545
|
+
scope: {
|
|
1546
|
+
domains: ["explicit app inventory", "dependency minimums", "source architecture", ...assessment.evidence ? ["known dependency advisories", "source quality", "source security", "browser and mobile emulation"] : []],
|
|
1547
|
+
notEvaluated: assessment.evidence ? ["licenses", "physical Android/iOS devices", "runtime performance", "cryptographic evidence attestation", "branch protection", "repository access controls", "cross-repository rollout status"] : notEvaluated
|
|
1548
|
+
},
|
|
1549
|
+
issues,
|
|
1550
|
+
apps: results
|
|
299
1551
|
};
|
|
1552
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
1553
|
+
await writeFile(join8(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
|
|
1554
|
+
`);
|
|
1555
|
+
await writeFile(join8(outputDirectory, "report.html"), renderAssessmentHtml(report));
|
|
1556
|
+
return report;
|
|
300
1557
|
}
|
|
301
1558
|
export {
|
|
1559
|
+
assessGovernance,
|
|
302
1560
|
checkGovernance,
|
|
303
|
-
loadPolicy
|
|
1561
|
+
loadPolicy,
|
|
1562
|
+
renderAssessmentHtml
|
|
304
1563
|
};
|