@anhcodo13/lattice 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -16
- package/dist/cli.js +923 -150
- package/dist/index.d.ts +117 -1
- package/dist/index.js +965 -63
- package/governance/policy.json +14 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,13 +1,151 @@
|
|
|
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
|
+
return { architecture: {
|
|
143
|
+
minimumScore: architecture.minimumScore,
|
|
144
|
+
maximumErrors: architecture.maximumErrors,
|
|
145
|
+
blockedRuleIds: architecture.blockedRuleIds,
|
|
146
|
+
analysis
|
|
147
|
+
} };
|
|
148
|
+
}
|
|
11
149
|
function parsePolicy(raw, source) {
|
|
12
150
|
let parsed;
|
|
13
151
|
try {
|
|
@@ -15,18 +153,18 @@ function parsePolicy(raw, source) {
|
|
|
15
153
|
} catch {
|
|
16
154
|
throw new Error(`Policy at ${source} is not valid JSON`);
|
|
17
155
|
}
|
|
18
|
-
if (!
|
|
156
|
+
if (!record2(parsed) || parsed.schemaVersion !== 2) {
|
|
19
157
|
throw new Error("Policy schemaVersion must be 2");
|
|
20
158
|
}
|
|
21
159
|
if (typeof parsed.policyVersion !== "string" || !exactVersion.test(parsed.policyVersion) || valid(parsed.policyVersion) === null) {
|
|
22
160
|
throw new Error("Policy policyVersion must be an exact version");
|
|
23
161
|
}
|
|
24
|
-
if (!
|
|
162
|
+
if (!record2(parsed.profiles) || Object.keys(parsed.profiles).length === 0) {
|
|
25
163
|
throw new Error("Policy profiles must be a nonempty object");
|
|
26
164
|
}
|
|
27
|
-
const
|
|
165
|
+
const profiles3 = {};
|
|
28
166
|
for (const [name, rules] of Object.entries(parsed.profiles)) {
|
|
29
|
-
if (!
|
|
167
|
+
if (!record2(rules) || Object.keys(rules).length === 0) {
|
|
30
168
|
throw new Error(`Policy profile ${name} must list packages`);
|
|
31
169
|
}
|
|
32
170
|
const versions = {};
|
|
@@ -36,12 +174,13 @@ function parsePolicy(raw, source) {
|
|
|
36
174
|
}
|
|
37
175
|
versions[packageName] = version;
|
|
38
176
|
}
|
|
39
|
-
|
|
177
|
+
profiles3[name] = versions;
|
|
40
178
|
}
|
|
41
179
|
return {
|
|
42
180
|
schemaVersion: 2,
|
|
43
181
|
policyVersion: parsed.policyVersion,
|
|
44
|
-
profiles:
|
|
182
|
+
profiles: profiles3,
|
|
183
|
+
...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
|
|
45
184
|
source,
|
|
46
185
|
sha256: createHash("sha256").update(raw).digest("hex")
|
|
47
186
|
};
|
|
@@ -60,7 +199,7 @@ async function loadPolicy(source) {
|
|
|
60
199
|
}
|
|
61
200
|
return parsePolicy(await response.text(), source);
|
|
62
201
|
}
|
|
63
|
-
return parsePolicy(await
|
|
202
|
+
return parsePolicy(await readFile2(resolve(source), "utf8"), source);
|
|
64
203
|
}
|
|
65
204
|
|
|
66
205
|
// src/governance/check.ts
|
|
@@ -68,8 +207,8 @@ import { resolve as resolve3, relative as relative3, sep as sep2 } from "path";
|
|
|
68
207
|
import { satisfies, subset, valid as valid2, validRange } from "semver";
|
|
69
208
|
|
|
70
209
|
// src/governance/discover.ts
|
|
71
|
-
import { readFile as
|
|
72
|
-
import { join, relative } from "path";
|
|
210
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
211
|
+
import { join as join2, relative } from "path";
|
|
73
212
|
import fastGlob from "fast-glob";
|
|
74
213
|
var profiles = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
|
|
75
214
|
function isRecord(value) {
|
|
@@ -109,15 +248,16 @@ async function discoverApps(scanRoot, workspaceRoot) {
|
|
|
109
248
|
cwd: scanRoot,
|
|
110
249
|
onlyFiles: true,
|
|
111
250
|
unique: true,
|
|
251
|
+
dot: true,
|
|
112
252
|
followSymbolicLinks: false,
|
|
113
|
-
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/.next/**", "**/coverage/**"]
|
|
253
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/out/**", "**/.next/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**"]
|
|
114
254
|
});
|
|
115
255
|
const apps = [];
|
|
116
256
|
for (const path of manifests.sort()) {
|
|
117
|
-
const directory =
|
|
257
|
+
const directory = join2(scanRoot, path, "..");
|
|
118
258
|
let parsed;
|
|
119
259
|
try {
|
|
120
|
-
parsed = JSON.parse(await
|
|
260
|
+
parsed = JSON.parse(await readFile3(join2(scanRoot, path), "utf8"));
|
|
121
261
|
} catch {
|
|
122
262
|
throw new Error(`Invalid package.json: ${path}`);
|
|
123
263
|
}
|
|
@@ -136,10 +276,10 @@ async function discoverApps(scanRoot, workspaceRoot) {
|
|
|
136
276
|
}
|
|
137
277
|
|
|
138
278
|
// src/governance/lockfiles.ts
|
|
139
|
-
import { access, readFile as
|
|
140
|
-
import { dirname, join as
|
|
279
|
+
import { access, readFile as readFile4 } from "fs/promises";
|
|
280
|
+
import { dirname, join as join3, relative as relative2, resolve as resolve2, sep } from "path";
|
|
141
281
|
import YAML from "yaml";
|
|
142
|
-
function
|
|
282
|
+
function record3(value) {
|
|
143
283
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
144
284
|
}
|
|
145
285
|
async function exists(path) {
|
|
@@ -159,8 +299,8 @@ async function findLockfile(appDirectory, workspaceRoot) {
|
|
|
159
299
|
const boundary = resolve2(workspaceRoot);
|
|
160
300
|
if (!within(boundary, directory)) throw new Error("Application is outside workspace root");
|
|
161
301
|
while (within(boundary, directory)) {
|
|
162
|
-
const npm =
|
|
163
|
-
const pnpm =
|
|
302
|
+
const npm = join3(directory, "package-lock.json");
|
|
303
|
+
const pnpm = join3(directory, "pnpm-lock.yaml");
|
|
164
304
|
const [hasNpm, hasPnpm] = await Promise.all([exists(npm), exists(pnpm)]);
|
|
165
305
|
if (hasNpm && hasPnpm) throw new Error(`Ambiguous lockfiles in ${directory}`);
|
|
166
306
|
if (hasNpm) return { path: npm, kind: "npm", root: directory };
|
|
@@ -173,7 +313,7 @@ async function findLockfile(appDirectory, workspaceRoot) {
|
|
|
173
313
|
function section(importer, name) {
|
|
174
314
|
for (const key of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
175
315
|
const entries = importer[key];
|
|
176
|
-
if (
|
|
316
|
+
if (record3(entries) && Object.hasOwn(entries, name)) return entries[name];
|
|
177
317
|
}
|
|
178
318
|
return void 0;
|
|
179
319
|
}
|
|
@@ -184,12 +324,12 @@ function readNpm(raw, root, app, name) {
|
|
|
184
324
|
} catch {
|
|
185
325
|
throw new Error("Malformed npm lockfile JSON");
|
|
186
326
|
}
|
|
187
|
-
if (!
|
|
327
|
+
if (!record3(lock) || lock.lockfileVersion !== 3 || !record3(lock.packages)) {
|
|
188
328
|
throw new Error("Unsupported or malformed npm lockfile");
|
|
189
329
|
}
|
|
190
330
|
const importerKey = relative2(root, app.directory).replaceAll("\\", "/");
|
|
191
331
|
const importer = lock.packages[importerKey];
|
|
192
|
-
if (!
|
|
332
|
+
if (!record3(importer)) throw new Error(`Missing npm lockfile importer ${importerKey || "."}`);
|
|
193
333
|
const specifier = section(importer, name);
|
|
194
334
|
if (specifier !== void 0 && typeof specifier !== "string") {
|
|
195
335
|
throw new Error(`Malformed npm lockfile specifier for ${name}`);
|
|
@@ -202,13 +342,13 @@ function readNpm(raw, root, app, name) {
|
|
|
202
342
|
if (resolved !== void 0 || segments.length === 0) break;
|
|
203
343
|
segments.pop();
|
|
204
344
|
}
|
|
205
|
-
if (specifier !== void 0 && !
|
|
345
|
+
if (specifier !== void 0 && !record3(resolved)) {
|
|
206
346
|
throw new Error(`Missing npm lockfile package resolution for ${name}`);
|
|
207
347
|
}
|
|
208
|
-
if (
|
|
348
|
+
if (record3(resolved) && typeof resolved.version !== "string") {
|
|
209
349
|
throw new Error(`Malformed npm lockfile package version for ${name}`);
|
|
210
350
|
}
|
|
211
|
-
return { specifier, version:
|
|
351
|
+
return { specifier, version: record3(resolved) ? resolved.version : void 0 };
|
|
212
352
|
}
|
|
213
353
|
function readPnpm(raw, root, app, name) {
|
|
214
354
|
let lock;
|
|
@@ -217,21 +357,21 @@ function readPnpm(raw, root, app, name) {
|
|
|
217
357
|
} catch {
|
|
218
358
|
throw new Error("Malformed pnpm lockfile YAML");
|
|
219
359
|
}
|
|
220
|
-
if (!
|
|
360
|
+
if (!record3(lock) || String(lock.lockfileVersion) !== "9.0" || !record3(lock.importers) || !record3(lock.packages) || !record3(lock.snapshots)) {
|
|
221
361
|
throw new Error("Unsupported or malformed pnpm lockfile");
|
|
222
362
|
}
|
|
223
363
|
const importerKey = relative2(root, app.directory).replaceAll("\\", "/") || ".";
|
|
224
364
|
const importer = lock.importers[importerKey];
|
|
225
|
-
if (!
|
|
365
|
+
if (!record3(importer)) throw new Error(`Missing pnpm lockfile importer ${importerKey}`);
|
|
226
366
|
const entry = section(importer, name);
|
|
227
367
|
if (entry === void 0) return {};
|
|
228
|
-
if (!
|
|
368
|
+
if (!record3(entry) || typeof entry.specifier !== "string" || typeof entry.version !== "string") {
|
|
229
369
|
throw new Error(`Malformed pnpm lockfile dependency for ${name}`);
|
|
230
370
|
}
|
|
231
371
|
const version = entry.version.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1];
|
|
232
372
|
if (!version) throw new Error(`Unsupported pnpm resolution for ${name}`);
|
|
233
373
|
const packageKeys = [`${name}@${version}`, `/${name}@${version}`];
|
|
234
|
-
if (!packageKeys.some((key) =>
|
|
374
|
+
if (!packageKeys.some((key) => record3(lock.packages) && Object.hasOwn(lock.packages, key))) {
|
|
235
375
|
throw new Error(`Missing pnpm package entry for ${name}@${version}`);
|
|
236
376
|
}
|
|
237
377
|
const snapshotKeys = [`${name}@${entry.version}`, `/${name}@${entry.version}`, ...packageKeys];
|
|
@@ -242,11 +382,44 @@ function readPnpm(raw, root, app, name) {
|
|
|
242
382
|
}
|
|
243
383
|
async function readResolution(app, workspaceRoot, name) {
|
|
244
384
|
const lockfile = await findLockfile(app.directory, workspaceRoot);
|
|
245
|
-
const raw = await
|
|
385
|
+
const raw = await readFile4(lockfile.path, "utf8");
|
|
246
386
|
return lockfile.kind === "npm" ? readNpm(raw, lockfile.root, app, name) : readPnpm(raw, lockfile.root, app, name);
|
|
247
387
|
}
|
|
248
388
|
|
|
249
389
|
// src/governance/check.ts
|
|
390
|
+
async function checkApp(app, workspaceRoot, policy) {
|
|
391
|
+
const requirements = policy.profiles[app.profile];
|
|
392
|
+
if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
|
|
393
|
+
const findings = [];
|
|
394
|
+
for (const [packageName, expected] of Object.entries(requirements)) {
|
|
395
|
+
const declared = app.dependencies[packageName];
|
|
396
|
+
const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
|
|
397
|
+
const reasons = [];
|
|
398
|
+
const declaredRange = declared === void 0 ? null : validRange(declared);
|
|
399
|
+
if (declared === void 0) reasons.push("required manifest declaration missing");
|
|
400
|
+
else if (declaredRange === null) reasons.push("unsupported manifest range");
|
|
401
|
+
else if (!subset(declaredRange, expected)) reasons.push("manifest range allows versions below minimum");
|
|
402
|
+
if (resolution.specifier !== declared) reasons.push("lockfile specifier differs");
|
|
403
|
+
if (resolution.version === void 0) reasons.push("lockfile resolution missing");
|
|
404
|
+
else if (!valid2(resolution.version)) reasons.push("invalid lockfile resolution");
|
|
405
|
+
else {
|
|
406
|
+
if (!satisfies(resolution.version, expected)) reasons.push("lockfile resolution does not satisfy minimum");
|
|
407
|
+
if (declaredRange !== null && !satisfies(resolution.version, declaredRange)) {
|
|
408
|
+
reasons.push("lockfile resolution outside manifest range");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (reasons.length) findings.push({
|
|
412
|
+
app: app.relativePath,
|
|
413
|
+
packageName,
|
|
414
|
+
expected,
|
|
415
|
+
declared,
|
|
416
|
+
lockfileSpecifier: resolution.specifier,
|
|
417
|
+
locked: resolution.version,
|
|
418
|
+
reason: reasons.join("; ")
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return findings;
|
|
422
|
+
}
|
|
250
423
|
async function checkGovernance(options) {
|
|
251
424
|
const scanRoot = resolve3(options.scanRoot);
|
|
252
425
|
const workspaceRoot = resolve3(options.workspaceRoot ?? scanRoot);
|
|
@@ -256,49 +429,778 @@ async function checkGovernance(options) {
|
|
|
256
429
|
}
|
|
257
430
|
const apps = await discoverApps(scanRoot, workspaceRoot);
|
|
258
431
|
if (apps.length === 0) throw new Error("No managed applications found");
|
|
432
|
+
const findings = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
|
|
433
|
+
return {
|
|
434
|
+
appsChecked: apps.length,
|
|
435
|
+
findings,
|
|
436
|
+
policyVersion: options.policy.policyVersion,
|
|
437
|
+
policySource: options.policy.source,
|
|
438
|
+
policySha256: options.policy.sha256
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// src/governance/assess.ts
|
|
443
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
444
|
+
import { join as join7, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
|
|
445
|
+
|
|
446
|
+
// src/analysis/graph.ts
|
|
447
|
+
function buildGraph(_modules) {
|
|
448
|
+
const modules = [..._modules].sort((a, b) => a.path.localeCompare(b.path));
|
|
449
|
+
const edges = modules.flatMap((module) => module.imports.filter((imported) => imported.resolved).map((imported) => ({
|
|
450
|
+
from: module.path,
|
|
451
|
+
to: imported.resolved,
|
|
452
|
+
line: imported.line,
|
|
453
|
+
specifier: imported.specifier,
|
|
454
|
+
importedNames: imported.importedNames,
|
|
455
|
+
isTypeOnly: imported.isTypeOnly
|
|
456
|
+
})));
|
|
457
|
+
return { modules, edges };
|
|
458
|
+
}
|
|
459
|
+
function findCycles(graph) {
|
|
460
|
+
const adjacent = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly).map((edge) => edge.to)]));
|
|
461
|
+
const index = /* @__PURE__ */ new Map();
|
|
462
|
+
const low = /* @__PURE__ */ new Map();
|
|
463
|
+
const stack = [];
|
|
464
|
+
const inStack = /* @__PURE__ */ new Set();
|
|
465
|
+
const cycles = [];
|
|
466
|
+
let counter = 0;
|
|
467
|
+
function visit(path) {
|
|
468
|
+
index.set(path, counter);
|
|
469
|
+
low.set(path, counter++);
|
|
470
|
+
stack.push(path);
|
|
471
|
+
inStack.add(path);
|
|
472
|
+
for (const target of adjacent.get(path) ?? []) {
|
|
473
|
+
if (!index.has(target)) {
|
|
474
|
+
visit(target);
|
|
475
|
+
low.set(path, Math.min(low.get(path), low.get(target)));
|
|
476
|
+
} else if (inStack.has(target)) {
|
|
477
|
+
low.set(path, Math.min(low.get(path), index.get(target)));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (low.get(path) !== index.get(path)) return;
|
|
481
|
+
const component = [];
|
|
482
|
+
while (stack.length) {
|
|
483
|
+
const current = stack.pop();
|
|
484
|
+
inStack.delete(current);
|
|
485
|
+
component.push(current);
|
|
486
|
+
if (current === path) break;
|
|
487
|
+
}
|
|
488
|
+
if (component.length > 1 || (adjacent.get(path) ?? []).includes(path)) cycles.push(component.sort());
|
|
489
|
+
}
|
|
490
|
+
for (const module of graph.modules) if (!index.has(module.path)) visit(module.path);
|
|
491
|
+
return cycles.sort((a, b) => a[0].localeCompare(b[0]));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// src/analysis/rules.ts
|
|
495
|
+
import { matchesGlob } from "path";
|
|
496
|
+
function evaluateRules(graph, config) {
|
|
259
497
|
const findings = [];
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
498
|
+
const outgoing = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly)]));
|
|
499
|
+
const incoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path && !edge.isTypeOnly)]));
|
|
500
|
+
const allIncoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path)]));
|
|
501
|
+
for (const cycle of findCycles(graph)) {
|
|
502
|
+
findings.push({
|
|
503
|
+
ruleId: "module-cycle",
|
|
504
|
+
category: "architecture",
|
|
505
|
+
severity: "error",
|
|
506
|
+
file: cycle[0],
|
|
507
|
+
line: 1,
|
|
508
|
+
evidence: `Circular imports connect ${cycle.join(" \u2192 ")} \u2192 ${cycle[0]}.`,
|
|
509
|
+
recommendation: "Move shared contracts into a lower-level module and remove one import direction."
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
for (const module of graph.modules) {
|
|
513
|
+
const out = outgoing.get(module.path) ?? [];
|
|
514
|
+
const into = incoming.get(module.path) ?? [];
|
|
515
|
+
const seen = /* @__PURE__ */ new Set();
|
|
516
|
+
for (const imported of module.imports) {
|
|
517
|
+
if (imported.isTypeOnly) continue;
|
|
518
|
+
if (seen.has(imported.specifier)) {
|
|
519
|
+
findings.push({
|
|
520
|
+
ruleId: "duplicate-import",
|
|
521
|
+
category: "imports",
|
|
522
|
+
severity: "warning",
|
|
523
|
+
file: module.path,
|
|
524
|
+
line: imported.line,
|
|
525
|
+
evidence: `Repeated import from ${imported.specifier}.`,
|
|
526
|
+
recommendation: "Combine imports from the same module."
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
seen.add(imported.specifier);
|
|
530
|
+
}
|
|
531
|
+
if (out.length > config.thresholds.maxFanOut) {
|
|
532
|
+
findings.push({
|
|
533
|
+
ruleId: "high-fan-out",
|
|
534
|
+
category: "architecture",
|
|
535
|
+
severity: "warning",
|
|
536
|
+
file: module.path,
|
|
537
|
+
line: 1,
|
|
538
|
+
evidence: `${out.length} outgoing module dependencies exceed ${config.thresholds.maxFanOut}.`,
|
|
539
|
+
recommendation: "Separate unrelated responsibilities and narrow direct dependencies."
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (into.length > config.thresholds.maxFanIn) {
|
|
543
|
+
findings.push({
|
|
544
|
+
ruleId: "high-fan-in",
|
|
545
|
+
category: "architecture",
|
|
546
|
+
severity: "warning",
|
|
547
|
+
file: module.path,
|
|
548
|
+
line: 1,
|
|
549
|
+
evidence: `${into.length} incoming module dependencies exceed ${config.thresholds.maxFanIn}.`,
|
|
550
|
+
recommendation: "Stabilize this shared interface before making breaking changes."
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
if (into.length === 0 && out.length === 0 && graph.modules.length > 1 && !module.isRoute) {
|
|
554
|
+
findings.push({
|
|
555
|
+
ruleId: "orphan-module",
|
|
556
|
+
category: "architecture",
|
|
557
|
+
severity: "info",
|
|
558
|
+
file: module.path,
|
|
559
|
+
line: 1,
|
|
560
|
+
evidence: "No local module imports to or from this file were resolved.",
|
|
561
|
+
recommendation: "Confirm whether the file is an entry point or unused code."
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
if (into.length > 0 && !module.isRoute) {
|
|
565
|
+
const usedNames = new Set((allIncoming.get(module.path) ?? []).flatMap((edge) => edge.importedNames));
|
|
566
|
+
if (!usedNames.has("*")) {
|
|
567
|
+
for (const name of module.exports) {
|
|
568
|
+
if (name === "*" || module.typeExports.includes(name)) continue;
|
|
569
|
+
if (!usedNames.has(name)) {
|
|
570
|
+
findings.push({
|
|
571
|
+
ruleId: "unused-export",
|
|
572
|
+
category: "imports",
|
|
573
|
+
severity: "info",
|
|
574
|
+
file: module.path,
|
|
575
|
+
line: 1,
|
|
576
|
+
evidence: `Export ${name} has no resolved local importer.`,
|
|
577
|
+
recommendation: "Remove the export or document its external entry-point usage."
|
|
578
|
+
});
|
|
579
|
+
}
|
|
278
580
|
}
|
|
279
581
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
582
|
+
}
|
|
583
|
+
if (module.componentCount > 0 && module.lines > config.thresholds.maxComponentLines) {
|
|
584
|
+
findings.push({
|
|
585
|
+
ruleId: "large-component",
|
|
586
|
+
category: "components",
|
|
587
|
+
severity: "warning",
|
|
588
|
+
file: module.path,
|
|
589
|
+
line: 1,
|
|
590
|
+
evidence: `A component file has ${module.lines} lines; limit is ${config.thresholds.maxComponentLines}.`,
|
|
591
|
+
recommendation: "Extract cohesive child components or hooks."
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
if (module.jsxDepth > config.thresholds.maxJsxDepth) {
|
|
595
|
+
findings.push({
|
|
596
|
+
ruleId: "deep-jsx",
|
|
597
|
+
category: "components",
|
|
598
|
+
severity: "warning",
|
|
599
|
+
file: module.path,
|
|
600
|
+
line: 1,
|
|
601
|
+
evidence: `JSX nesting depth ${module.jsxDepth} exceeds ${config.thresholds.maxJsxDepth}.`,
|
|
602
|
+
recommendation: "Split deeply nested JSX into named components."
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
if (module.complexity > config.thresholds.maxComplexity) {
|
|
606
|
+
findings.push({
|
|
607
|
+
ruleId: "high-complexity",
|
|
608
|
+
category: "complexity",
|
|
609
|
+
severity: "warning",
|
|
610
|
+
file: module.path,
|
|
611
|
+
line: 1,
|
|
612
|
+
evidence: `Approximate branch complexity ${module.complexity} exceeds ${config.thresholds.maxComplexity}.`,
|
|
613
|
+
recommendation: "Simplify branches and extract focused functions."
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
for (const policy of config.policies) {
|
|
617
|
+
if (!policy.from.some((pattern) => matchesGlob(module.path, pattern))) continue;
|
|
618
|
+
for (const edge of out) {
|
|
619
|
+
const allowed = policy.allow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
620
|
+
const disallowed = policy.disallow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
621
|
+
if (policy.allow && !allowed || policy.disallow && disallowed) {
|
|
622
|
+
findings.push({
|
|
623
|
+
ruleId: policy.id,
|
|
624
|
+
category: "architecture",
|
|
625
|
+
severity: policy.severity,
|
|
626
|
+
file: module.path,
|
|
627
|
+
line: edge.line,
|
|
628
|
+
evidence: `${module.path} imports ${edge.to}, violating ${policy.description}.`,
|
|
629
|
+
recommendation: policy.recommendation ?? "Refactor the import to respect this module boundary."
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const maxDepth = config.thresholds.maxChainDepth;
|
|
636
|
+
for (const module of graph.modules) {
|
|
637
|
+
if (maxDepth <= 0) continue;
|
|
638
|
+
const visit = (path, visited, depth) => {
|
|
639
|
+
if (depth > maxDepth) return true;
|
|
640
|
+
for (const edge of outgoing.get(path) ?? []) {
|
|
641
|
+
if (visited.has(edge.to)) continue;
|
|
642
|
+
const next = new Set(visited);
|
|
643
|
+
next.add(edge.to);
|
|
644
|
+
if (visit(edge.to, next, depth + 1)) return true;
|
|
645
|
+
}
|
|
646
|
+
return false;
|
|
647
|
+
};
|
|
648
|
+
if (visit(module.path, /* @__PURE__ */ new Set([module.path]), 0)) {
|
|
649
|
+
findings.push({
|
|
650
|
+
ruleId: "deep-dependency-chain",
|
|
651
|
+
category: "architecture",
|
|
652
|
+
severity: "warning",
|
|
653
|
+
file: module.path,
|
|
654
|
+
line: 1,
|
|
655
|
+
evidence: `A dependency chain exceeds ${maxDepth} local hops.`,
|
|
656
|
+
recommendation: "Introduce a stable boundary or flatten unnecessary layers."
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/analysis/scan.ts
|
|
664
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
665
|
+
import { join as join5, resolve as resolve5 } from "path";
|
|
666
|
+
import fastGlob2 from "fast-glob";
|
|
667
|
+
|
|
668
|
+
// src/analysis/parse.ts
|
|
669
|
+
import ts from "typescript";
|
|
670
|
+
function hasModifier(node, kind) {
|
|
671
|
+
return ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) === true;
|
|
672
|
+
}
|
|
673
|
+
function containsJsx(node) {
|
|
674
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true;
|
|
675
|
+
return ts.forEachChild(node, containsJsx) === true;
|
|
676
|
+
}
|
|
677
|
+
function importNames(clause) {
|
|
678
|
+
if (!clause) return [];
|
|
679
|
+
const names = [];
|
|
680
|
+
if (clause.name) names.push("default");
|
|
681
|
+
if (clause.namedBindings) {
|
|
682
|
+
if (ts.isNamespaceImport(clause.namedBindings)) names.push("*");
|
|
683
|
+
else for (const item of clause.namedBindings.elements) names.push(item.propertyName?.text ?? item.name.text);
|
|
684
|
+
}
|
|
685
|
+
return names;
|
|
686
|
+
}
|
|
687
|
+
function sourceKind(path) {
|
|
688
|
+
if (path.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
|
689
|
+
if (path.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
|
690
|
+
if (path.endsWith(".js") || path.endsWith(".mjs") || path.endsWith(".cjs")) return ts.ScriptKind.JS;
|
|
691
|
+
return ts.ScriptKind.TS;
|
|
692
|
+
}
|
|
693
|
+
function parseModule(path, contents) {
|
|
694
|
+
const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
|
|
695
|
+
const imports = [];
|
|
696
|
+
const exports = [];
|
|
697
|
+
const typeExports = [];
|
|
698
|
+
let jsxDepth = 0;
|
|
699
|
+
let complexity = 1;
|
|
700
|
+
let componentCount = 0;
|
|
701
|
+
for (const node of source.statements) {
|
|
702
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
703
|
+
imports.push({
|
|
704
|
+
specifier: node.moduleSpecifier.text,
|
|
705
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
706
|
+
importedNames: importNames(node.importClause),
|
|
707
|
+
isTypeOnly: node.importClause?.isTypeOnly ?? false
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
if (ts.isExportDeclaration(node)) {
|
|
711
|
+
if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
712
|
+
imports.push({
|
|
713
|
+
specifier: node.moduleSpecifier.text,
|
|
714
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
715
|
+
importedNames: node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements.map((item) => item.propertyName?.text ?? item.name.text) : ["*"],
|
|
716
|
+
isTypeOnly: node.isTypeOnly
|
|
289
717
|
});
|
|
290
718
|
}
|
|
719
|
+
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
720
|
+
exports.push(...node.exportClause.elements.map((item) => item.name.text));
|
|
721
|
+
if (node.isTypeOnly) typeExports.push(...node.exportClause.elements.map((item) => item.name.text));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) {
|
|
725
|
+
if (hasModifier(node, ts.SyntaxKind.DefaultKeyword)) exports.push("default");
|
|
726
|
+
else if (ts.isVariableStatement(node)) {
|
|
727
|
+
for (const declaration of node.declarationList.declarations) {
|
|
728
|
+
if (ts.isIdentifier(declaration.name)) exports.push(declaration.name.text);
|
|
729
|
+
}
|
|
730
|
+
} else if ("name" in node && node.name && ts.isIdentifier(node.name)) {
|
|
731
|
+
const name = node.name.text;
|
|
732
|
+
exports.push(name);
|
|
733
|
+
if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) typeExports.push(name);
|
|
734
|
+
}
|
|
291
735
|
}
|
|
736
|
+
if (ts.isFunctionDeclaration(node) && node.name && /^[A-Z]/.test(node.name.text) && containsJsx(node)) {
|
|
737
|
+
componentCount++;
|
|
738
|
+
}
|
|
739
|
+
if (ts.isVariableStatement(node)) {
|
|
740
|
+
for (const declaration of node.declarationList.declarations) {
|
|
741
|
+
if (ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text) && declaration.initializer && containsJsx(declaration.initializer)) {
|
|
742
|
+
componentCount++;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
function visit(node, depth) {
|
|
748
|
+
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
|
|
749
|
+
const argument = node.arguments[0];
|
|
750
|
+
if (argument && ts.isStringLiteralLike(argument)) {
|
|
751
|
+
imports.push({
|
|
752
|
+
specifier: argument.text,
|
|
753
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
754
|
+
importedNames: ["*"],
|
|
755
|
+
isTypeOnly: false
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
const isJsx = ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
|
|
760
|
+
const nextDepth = depth + (isJsx ? 1 : 0);
|
|
761
|
+
jsxDepth = Math.max(jsxDepth, nextDepth);
|
|
762
|
+
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++;
|
|
763
|
+
if (ts.isBinaryExpression(node) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind)) {
|
|
764
|
+
complexity++;
|
|
765
|
+
}
|
|
766
|
+
ts.forEachChild(node, (child) => visit(child, nextDepth));
|
|
292
767
|
}
|
|
768
|
+
visit(source, 0);
|
|
293
769
|
return {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
770
|
+
path,
|
|
771
|
+
imports,
|
|
772
|
+
exports: [...new Set(exports)].sort(),
|
|
773
|
+
typeExports: [...new Set(typeExports)].sort(),
|
|
774
|
+
lines: contents.split(/\r?\n/).length,
|
|
775
|
+
jsxDepth,
|
|
776
|
+
componentCount,
|
|
777
|
+
complexity,
|
|
778
|
+
isRoute: /(^|\/)(app|pages)\/.+(page|layout|route|index)\.[cm]?[jt]sx?$/.test(path) || /(^|\/)app\/(page|layout|route)\.[cm]?[jt]sx?$/.test(path)
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
function moduleParseDiagnostics(path, contents) {
|
|
782
|
+
const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
|
|
783
|
+
const diagnostics = source.parseDiagnostics ?? [];
|
|
784
|
+
const messages = diagnostics.map((diagnostic) => {
|
|
785
|
+
const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1;
|
|
786
|
+
return `${path}:${line}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`;
|
|
787
|
+
});
|
|
788
|
+
function visit(node) {
|
|
789
|
+
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
|
|
790
|
+
const argument = node.arguments[0];
|
|
791
|
+
if (!argument || !ts.isStringLiteralLike(argument)) {
|
|
792
|
+
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
793
|
+
messages.push(`${path}:${line}: nonliteral dynamic import or require cannot be analyzed`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
ts.forEachChild(node, visit);
|
|
797
|
+
}
|
|
798
|
+
visit(source);
|
|
799
|
+
return messages;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// src/analysis/resolve.ts
|
|
803
|
+
import { readFile as readFile5, stat } from "fs/promises";
|
|
804
|
+
import { dirname as dirname2, extname, join as join4, relative as relative4, resolve as resolve4, sep as sep3 } from "path";
|
|
805
|
+
import ts2 from "typescript";
|
|
806
|
+
async function compilerOptions(root) {
|
|
807
|
+
const path = join4(root, "tsconfig.json");
|
|
808
|
+
let content;
|
|
809
|
+
try {
|
|
810
|
+
content = await readFile5(path, "utf8");
|
|
811
|
+
} catch (error) {
|
|
812
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return { options: {}, diagnostics: [] };
|
|
813
|
+
throw error;
|
|
814
|
+
}
|
|
815
|
+
try {
|
|
816
|
+
const json = ts2.parseConfigFileTextToJson(path, content);
|
|
817
|
+
if (json.error) return { options: {}, diagnostics: [`tsconfig.json: ${ts2.flattenDiagnosticMessageText(json.error.messageText, " ")}`] };
|
|
818
|
+
const parsed = ts2.parseJsonConfigFileContent(json.config, ts2.sys, root);
|
|
819
|
+
return { options: parsed.options, diagnostics: parsed.errors.map((error) => `tsconfig.json: ${ts2.flattenDiagnosticMessageText(error.messageText, " ")}`) };
|
|
820
|
+
} catch (error) {
|
|
821
|
+
return { options: {}, diagnostics: [`tsconfig.json: ${error instanceof Error ? error.message : String(error)}`] };
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
function propertyName(name) {
|
|
825
|
+
if (ts2.isIdentifier(name) || ts2.isStringLiteral(name)) return name.text;
|
|
826
|
+
return void 0;
|
|
827
|
+
}
|
|
828
|
+
function staticAliasTarget(node) {
|
|
829
|
+
if (ts2.isStringLiteralLike(node)) return node.text;
|
|
830
|
+
if (ts2.isCallExpression(node) || ts2.isNewExpression(node)) {
|
|
831
|
+
for (const argument of node.arguments ?? []) {
|
|
832
|
+
const found = staticAliasTarget(argument);
|
|
833
|
+
if (found?.startsWith("./")) return found;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return void 0;
|
|
837
|
+
}
|
|
838
|
+
async function viteAliasDiagnostics(root, options, aliases) {
|
|
839
|
+
const diagnostics = [];
|
|
840
|
+
for (const file of ["vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs", "vite.config.cts", "vite.config.cjs"]) {
|
|
841
|
+
let visit2 = function(node) {
|
|
842
|
+
if (ts2.isPropertyAssignment(node) && propertyName(node.name) === "resolve") {
|
|
843
|
+
if (!ts2.isObjectLiteralExpression(node.initializer)) {
|
|
844
|
+
diagnostics.push(`${file}: dynamic Vite resolve configuration cannot be verified`);
|
|
845
|
+
ts2.forEachChild(node, visit2);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
for (const property of node.initializer.properties) {
|
|
849
|
+
if (!ts2.isPropertyAssignment(property) || !propertyName(property.name)) {
|
|
850
|
+
diagnostics.push(`${file}: dynamic Vite resolve entry cannot be verified`);
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (propertyName(property.name) !== "alias") continue;
|
|
854
|
+
if (!ts2.isObjectLiteralExpression(property.initializer)) {
|
|
855
|
+
diagnostics.push(`${file}: dynamic Vite alias configuration cannot be verified`);
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
for (const alias of property.initializer.properties) {
|
|
859
|
+
if (!ts2.isPropertyAssignment(alias) || !propertyName(alias.name)) {
|
|
860
|
+
diagnostics.push(`${file}: dynamic Vite alias entry cannot be verified`);
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
const key = propertyName(alias.name);
|
|
864
|
+
const prefix = key.endsWith("/") ? key : `${key}/`;
|
|
865
|
+
const centralTarget = aliases[prefix];
|
|
866
|
+
const tsTarget = options.paths?.[`${key}/*`]?.[0];
|
|
867
|
+
const mapped = centralTarget ?? (tsTarget?.endsWith("/*") ? tsTarget.slice(0, -1).replace(/^\.\//, "") : void 0);
|
|
868
|
+
if (!mapped) {
|
|
869
|
+
diagnostics.push(`${file}: Vite alias ${key} needs a central analysis.aliases or matching tsconfig paths mapping`);
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
const target = staticAliasTarget(alias.initializer);
|
|
873
|
+
if (!target?.startsWith("./")) {
|
|
874
|
+
diagnostics.push(`${file}: Vite alias ${key} target cannot be verified statically`);
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
const expected = `${target.slice(2).replace(/\/$/, "")}/`;
|
|
878
|
+
if (mapped !== expected) diagnostics.push(`${file}: Vite alias ${key} maps to ${expected}, but assessment uses ${mapped}`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
ts2.forEachChild(node, visit2);
|
|
883
|
+
};
|
|
884
|
+
var visit = visit2;
|
|
885
|
+
let sourceText;
|
|
886
|
+
try {
|
|
887
|
+
sourceText = await readFile5(join4(root, file), "utf8");
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue;
|
|
890
|
+
throw error;
|
|
891
|
+
}
|
|
892
|
+
const source = ts2.createSourceFile(file, sourceText, ts2.ScriptTarget.Latest, true);
|
|
893
|
+
const defaultExport = source.statements.find((statement) => ts2.isExportAssignment(statement) && !statement.isExportEquals);
|
|
894
|
+
if (!defaultExport || !ts2.isExportAssignment(defaultExport)) {
|
|
895
|
+
diagnostics.push(`${file}: Vite configuration export cannot be verified statically`);
|
|
896
|
+
} else {
|
|
897
|
+
const expression = defaultExport.expression;
|
|
898
|
+
const config = ts2.isCallExpression(expression) && expression.expression.getText(source) === "defineConfig" ? expression.arguments[0] : expression;
|
|
899
|
+
if (!config || !ts2.isObjectLiteralExpression(config)) {
|
|
900
|
+
diagnostics.push(`${file}: Vite configuration export cannot be verified statically`);
|
|
901
|
+
} else if (config.properties.some((property) => !ts2.isPropertyAssignment(property) || !propertyName(property.name))) {
|
|
902
|
+
diagnostics.push(`${file}: dynamic Vite configuration entry cannot be verified`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
visit2(source);
|
|
906
|
+
}
|
|
907
|
+
return diagnostics;
|
|
908
|
+
}
|
|
909
|
+
async function existingNonSourceAsset(path) {
|
|
910
|
+
if (/\.[cm]?[jt]sx?$/.test(extname(path)) || path.endsWith(".d.ts")) return false;
|
|
911
|
+
return (await stat(path).catch(() => null))?.isFile() === true;
|
|
912
|
+
}
|
|
913
|
+
async function resolveImportsDetailed(root, modules, aliases = {}) {
|
|
914
|
+
const { options, diagnostics } = await compilerOptions(root);
|
|
915
|
+
diagnostics.push(...await viteAliasDiagnostics(root, options, aliases));
|
|
916
|
+
const files = new Set(modules.map((module) => module.path));
|
|
917
|
+
for (const module of modules) {
|
|
918
|
+
const from = join4(root, module.path);
|
|
919
|
+
for (const imported of module.imports) {
|
|
920
|
+
const specifier = imported.specifier.split("?")[0];
|
|
921
|
+
const matchingAlias = Object.keys(aliases).sort((a, b) => b.length - a.length).find((prefix) => specifier.startsWith(prefix));
|
|
922
|
+
const aliasTarget = matchingAlias ? resolve4(root, aliases[matchingAlias], specifier.slice(matchingAlias.length)) : void 0;
|
|
923
|
+
const candidate = aliasTarget ? relative4(dirname2(from), aliasTarget).replaceAll("\\", "/") : specifier;
|
|
924
|
+
const lookup = aliasTarget && !candidate.startsWith(".") ? `./${candidate}` : candidate;
|
|
925
|
+
const result = ts2.resolveModuleName(lookup, from, options, ts2.sys).resolvedModule;
|
|
926
|
+
const localAlias = Object.keys(options.paths ?? {}).some((pattern) => {
|
|
927
|
+
const [prefix, suffix = ""] = pattern.split("*");
|
|
928
|
+
return specifier.startsWith(prefix) && specifier.endsWith(suffix);
|
|
929
|
+
});
|
|
930
|
+
const isLocal = specifier.startsWith(".") || localAlias || aliasTarget !== void 0 || /^[~@#]\//.test(specifier);
|
|
931
|
+
if (!result) {
|
|
932
|
+
const assetPath = aliasTarget ?? (specifier.startsWith(".") ? resolve4(dirname2(from), specifier) : void 0);
|
|
933
|
+
const assetRelative = assetPath ? relative4(root, assetPath) : void 0;
|
|
934
|
+
if (assetRelative && (assetRelative === ".." || assetRelative.startsWith(`..${sep3}`))) {
|
|
935
|
+
diagnostics.push(`${module.path}:${imported.line}: local import escapes app root ${imported.specifier}`);
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
if (assetPath && await existingNonSourceAsset(assetPath)) continue;
|
|
939
|
+
if (isLocal) diagnostics.push(`${module.path}:${imported.line}: unresolved local import ${imported.specifier}`);
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
const path = relative4(root, resolve4(result.resolvedFileName));
|
|
943
|
+
if (path === ".." || path.startsWith(`..${sep3}`)) {
|
|
944
|
+
if (isLocal) diagnostics.push(`${module.path}:${imported.line}: local import escapes app root ${imported.specifier}`);
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
const normalized = path.replaceAll("\\", "/");
|
|
948
|
+
if (files.has(normalized)) imported.resolved = normalized;
|
|
949
|
+
else if (isLocal && !normalized.endsWith(".d.ts")) diagnostics.push(`${module.path}:${imported.line}: local import outside analyzed source ${imported.specifier}`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return diagnostics;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// src/analysis/scan.ts
|
|
956
|
+
async function scanProjectDetailed(projectRoot, aliases = {}) {
|
|
957
|
+
const root = resolve5(projectRoot);
|
|
958
|
+
const files = await fastGlob2("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
|
|
959
|
+
cwd: root,
|
|
960
|
+
onlyFiles: true,
|
|
961
|
+
unique: true,
|
|
962
|
+
dot: true,
|
|
963
|
+
followSymbolicLinks: false,
|
|
964
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/.next/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**", "**/*.d.ts"]
|
|
965
|
+
});
|
|
966
|
+
const parsed = await Promise.all(files.sort().map(async (path) => {
|
|
967
|
+
const normalized = path.replaceAll("\\", "/");
|
|
968
|
+
const contents = await readFile6(join5(root, path), "utf8");
|
|
969
|
+
return { module: parseModule(normalized, contents), diagnostics: moduleParseDiagnostics(normalized, contents) };
|
|
970
|
+
}));
|
|
971
|
+
const modules = parsed.map((item) => item.module);
|
|
972
|
+
const diagnostics = parsed.flatMap((item) => item.diagnostics);
|
|
973
|
+
diagnostics.push(...await resolveImportsDetailed(root, modules, aliases));
|
|
974
|
+
return { modules, diagnostics };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// src/analysis/score.ts
|
|
978
|
+
function round(value) {
|
|
979
|
+
return Math.round(value * 100) / 100;
|
|
980
|
+
}
|
|
981
|
+
function grade(score) {
|
|
982
|
+
if (score >= 95) return "A+";
|
|
983
|
+
if (score >= 90) return "A";
|
|
984
|
+
if (score >= 80) return "B";
|
|
985
|
+
if (score >= 70) return "C";
|
|
986
|
+
if (score >= 55) return "D";
|
|
987
|
+
return "F";
|
|
988
|
+
}
|
|
989
|
+
function scoreFindings(findings, config) {
|
|
990
|
+
const penalties = { error: 18, warning: 7, info: 2 };
|
|
991
|
+
const categories = Object.entries(config.weights).map(([id, weight]) => {
|
|
992
|
+
const categoryFindings = findings.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
|
|
993
|
+
const occurrences = /* @__PURE__ */ new Map();
|
|
994
|
+
let deduction = 0;
|
|
995
|
+
for (const finding of categoryFindings) {
|
|
996
|
+
const occurrence = (occurrences.get(finding.ruleId) ?? 0) + 1;
|
|
997
|
+
occurrences.set(finding.ruleId, occurrence);
|
|
998
|
+
deduction += penalties[finding.severity] / Math.sqrt(occurrence);
|
|
999
|
+
}
|
|
1000
|
+
deduction = Math.min(100, deduction);
|
|
1001
|
+
return { id, weight, score: round(100 - deduction), deduction: round(deduction), findingCount: categoryFindings.length };
|
|
1002
|
+
});
|
|
1003
|
+
const weightedDeduction = round(categories.reduce((sum, category) => sum + category.deduction * category.weight / 100, 0));
|
|
1004
|
+
const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings.length - 10) * 0.15));
|
|
1005
|
+
const criticalAdjustment = round(Math.min(10, findings.filter((finding) => finding.severity === "error").length * 1.5));
|
|
1006
|
+
const score = round(Math.max(0, Math.min(100, 100 - weightedDeduction - maintenanceAdjustment - criticalAdjustment)));
|
|
1007
|
+
return { score, grade: grade(score), weightedDeduction, maintenanceAdjustment, criticalAdjustment, categories };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// src/governance/inventory.ts
|
|
1011
|
+
import { createHash as createHash2 } from "crypto";
|
|
1012
|
+
import { readFile as readFile7, realpath } from "fs/promises";
|
|
1013
|
+
import { isAbsolute, join as join6, relative as relative5, resolve as resolve6, sep as sep4 } from "path";
|
|
1014
|
+
var profiles2 = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
|
|
1015
|
+
async function loadInventory(source, scanRoot) {
|
|
1016
|
+
const absolute = resolve6(source);
|
|
1017
|
+
const root = await realpath(scanRoot);
|
|
1018
|
+
const inventoryPath = await realpath(absolute);
|
|
1019
|
+
const inventoryRelative = relative5(root, inventoryPath);
|
|
1020
|
+
if (inventoryRelative === ".." || inventoryRelative.startsWith(`..${sep4}`)) throw new Error("Inventory must be inside scan root");
|
|
1021
|
+
const raw = await readFile7(inventoryPath, "utf8");
|
|
1022
|
+
let value;
|
|
1023
|
+
try {
|
|
1024
|
+
value = JSON.parse(raw);
|
|
1025
|
+
} catch {
|
|
1026
|
+
throw new Error("Invalid app inventory JSON");
|
|
1027
|
+
}
|
|
1028
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Invalid app inventory object");
|
|
1029
|
+
const parsed = value;
|
|
1030
|
+
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.apps) || parsed.apps.length === 0) {
|
|
1031
|
+
throw new Error("Inventory schemaVersion 1 and nonempty apps required");
|
|
1032
|
+
}
|
|
1033
|
+
const apps = [];
|
|
1034
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1035
|
+
for (const entry of parsed.apps) {
|
|
1036
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error("Invalid app inventory entry");
|
|
1037
|
+
const app = entry;
|
|
1038
|
+
const path = app.path;
|
|
1039
|
+
if (typeof path !== "string" || path !== "." && (path === "" || path.startsWith("./") || path.includes("\\") || path.split("/").some((segment) => !segment || segment === "." || segment === "..")) || isAbsolute(path)) {
|
|
1040
|
+
throw new Error(`Invalid app inventory path ${String(path)}`);
|
|
1041
|
+
}
|
|
1042
|
+
if (typeof app.profile !== "string" || !profiles2.has(app.profile)) throw new Error(`Invalid profile for ${path}`);
|
|
1043
|
+
if (seen.has(path)) throw new Error(`Duplicate inventory app ${path}`);
|
|
1044
|
+
seen.add(path);
|
|
1045
|
+
try {
|
|
1046
|
+
const actual = await realpath(join6(root, path));
|
|
1047
|
+
const rel = relative5(root, actual);
|
|
1048
|
+
if (rel === ".." || rel.startsWith(`..${sep4}`)) throw new Error(`App path escapes scan root: ${path}`);
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
if (error instanceof Error && error.message.includes("escapes scan root")) throw error;
|
|
1051
|
+
}
|
|
1052
|
+
apps.push({ path, profile: app.profile });
|
|
1053
|
+
}
|
|
1054
|
+
return { source: absolute, sha256: createHash2("sha256").update(raw).digest("hex"), apps };
|
|
1055
|
+
}
|
|
1056
|
+
function compareInventory(inventory, discovered, scanRoot) {
|
|
1057
|
+
const expected = new Map(inventory.apps.map((app) => [app.path, app.profile]));
|
|
1058
|
+
const actual = new Map(discovered.map((app) => [relative5(scanRoot, app.directory).replaceAll("\\", "/") || ".", app.profile]));
|
|
1059
|
+
const issues = [];
|
|
1060
|
+
for (const [path, profile] of expected) {
|
|
1061
|
+
if (!actual.has(path)) issues.push(`Expected app ${path} was not discovered`);
|
|
1062
|
+
else if (actual.get(path) !== profile) issues.push(`App ${path} has profile ${actual.get(path)}; inventory requires ${profile}`);
|
|
1063
|
+
}
|
|
1064
|
+
for (const path of actual.keys()) if (!expected.has(path)) issues.push(`Discovered app ${path} is absent from inventory`);
|
|
1065
|
+
return issues;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// src/governance/assess.ts
|
|
1069
|
+
var notEvaluated = [
|
|
1070
|
+
"vulnerability databases",
|
|
1071
|
+
"licenses",
|
|
1072
|
+
"secret detection",
|
|
1073
|
+
"test execution",
|
|
1074
|
+
"runtime behavior",
|
|
1075
|
+
"branch protection",
|
|
1076
|
+
"repository access controls",
|
|
1077
|
+
"cross-repository rollout status"
|
|
1078
|
+
];
|
|
1079
|
+
function statusOf(statuses) {
|
|
1080
|
+
if (statuses.includes("incomplete")) return "incomplete";
|
|
1081
|
+
if (statuses.includes("fail")) return "fail";
|
|
1082
|
+
return "pass";
|
|
1083
|
+
}
|
|
1084
|
+
function escapeHtml(value) {
|
|
1085
|
+
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
1086
|
+
}
|
|
1087
|
+
function renderAssessmentHtml(report) {
|
|
1088
|
+
const apps = report.apps.map((app) => {
|
|
1089
|
+
const domains = Object.entries(app.domains).map(([name, domain]) => `<tr><th scope="row">${escapeHtml(name)}</th><td class="${domain.status}">${escapeHtml(domain.status)}</td><td>${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : "No issues"}</td></tr>`).join("");
|
|
1090
|
+
const architecture = app.domains.architecture;
|
|
1091
|
+
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("") ?? "";
|
|
1092
|
+
const findings = 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("");
|
|
1093
|
+
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>` : ""}${findings ? `<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>${findings}</tbody></table></div></details>` : ""}</section>`;
|
|
1094
|
+
}).join("");
|
|
1095
|
+
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>
|
|
1096
|
+
:root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#f6f7f5;color:#1b2925}body{max-width:1100px;margin:auto;padding:2rem 1rem}header,.app,.meta{background:white;border:1px solid #d9e1dc;border-radius:12px;padding:1.4rem;margin:0 0 1.2rem}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}.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:9rem}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}
|
|
1097
|
+
</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)</p></header><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(report.inventory.source)}</code></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>
|
|
1098
|
+
`;
|
|
1099
|
+
}
|
|
1100
|
+
async function assessGovernance(options) {
|
|
1101
|
+
const scanRoot = resolve7(options.scanRoot);
|
|
1102
|
+
const workspaceRoot = resolve7(options.workspaceRoot ?? scanRoot);
|
|
1103
|
+
const scanRelative = relative6(workspaceRoot, scanRoot);
|
|
1104
|
+
if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
|
|
1105
|
+
if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
|
|
1106
|
+
const assessment = parseAssessmentPolicy(options.policy.assessment);
|
|
1107
|
+
const outputDirectory = resolve7(options.outputDirectory ?? join7(scanRoot, ".nklattice-governance"));
|
|
1108
|
+
const inventorySource = resolve7(options.inventory ?? join7(scanRoot, ".nklattice-apps.json"));
|
|
1109
|
+
let inventory;
|
|
1110
|
+
const issues = [];
|
|
1111
|
+
try {
|
|
1112
|
+
inventory = await loadInventory(inventorySource, scanRoot);
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
issues.push(`Inventory: ${error instanceof Error ? error.message : String(error)}`);
|
|
1115
|
+
}
|
|
1116
|
+
let apps = [];
|
|
1117
|
+
try {
|
|
1118
|
+
apps = await discoverApps(scanRoot, workspaceRoot);
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
issues.push(`Discovery: ${error instanceof Error ? error.message : String(error)}`);
|
|
1121
|
+
}
|
|
1122
|
+
if (apps.length === 0) issues.push("No managed applications discovered");
|
|
1123
|
+
if (inventory) issues.push(...compareInventory(inventory, apps, scanRoot));
|
|
1124
|
+
if (options.expectedApps !== void 0 && apps.length !== options.expectedApps) {
|
|
1125
|
+
issues.push(`Expected ${options.expectedApps} managed apps; discovered ${apps.length}`);
|
|
1126
|
+
}
|
|
1127
|
+
const results = [];
|
|
1128
|
+
for (const app of apps) {
|
|
1129
|
+
const appPath = relative6(scanRoot, app.directory).replaceAll("\\", "/") || ".";
|
|
1130
|
+
const inventoryIssues = issues.filter((issue) => issue.includes(appPath) || issue.startsWith("Inventory:") || issue.startsWith("Discovery:"));
|
|
1131
|
+
const inventoryDomain = { status: inventoryIssues.length ? "incomplete" : "pass", issues: inventoryIssues };
|
|
1132
|
+
const dependencyDomain = { status: "pass", issues: [], findings: [] };
|
|
1133
|
+
try {
|
|
1134
|
+
dependencyDomain.findings = await checkApp(app, workspaceRoot, options.policy);
|
|
1135
|
+
if (dependencyDomain.findings.length) {
|
|
1136
|
+
dependencyDomain.status = "fail";
|
|
1137
|
+
dependencyDomain.issues = dependencyDomain.findings.map((item) => `${item.packageName}: ${item.reason}; required ${item.expected}, declared ${item.declared ?? "missing"}, locked ${item.locked ?? "missing"}`);
|
|
1138
|
+
}
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
dependencyDomain.status = "incomplete";
|
|
1141
|
+
dependencyDomain.issues = [error instanceof Error ? error.message : String(error)];
|
|
1142
|
+
}
|
|
1143
|
+
const architectureDomain = {
|
|
1144
|
+
status: "pass",
|
|
1145
|
+
issues: [],
|
|
1146
|
+
filesScanned: 0,
|
|
1147
|
+
localEdges: 0,
|
|
1148
|
+
errors: 0,
|
|
1149
|
+
findings: [],
|
|
1150
|
+
diagnostics: []
|
|
1151
|
+
};
|
|
1152
|
+
try {
|
|
1153
|
+
const scan = await scanProjectDetailed(app.directory, assessment.architecture.analysis.aliases);
|
|
1154
|
+
architectureDomain.filesScanned = scan.modules.length;
|
|
1155
|
+
architectureDomain.diagnostics = scan.diagnostics;
|
|
1156
|
+
const graph = buildGraph(scan.modules);
|
|
1157
|
+
architectureDomain.localEdges = graph.edges.length;
|
|
1158
|
+
architectureDomain.findings = evaluateRules(graph, assessment.architecture.analysis);
|
|
1159
|
+
const score = scoreFindings(architectureDomain.findings, assessment.architecture.analysis);
|
|
1160
|
+
architectureDomain.score = score.score;
|
|
1161
|
+
architectureDomain.grade = score.grade;
|
|
1162
|
+
architectureDomain.scoreBreakdown = score;
|
|
1163
|
+
architectureDomain.errors = architectureDomain.findings.filter((item) => item.severity === "error").length;
|
|
1164
|
+
if (scan.modules.length === 0) architectureDomain.diagnostics.push("No supported source files found");
|
|
1165
|
+
if (architectureDomain.diagnostics.length) {
|
|
1166
|
+
architectureDomain.status = "incomplete";
|
|
1167
|
+
architectureDomain.issues.push(...architectureDomain.diagnostics);
|
|
1168
|
+
}
|
|
1169
|
+
if (score.score < assessment.architecture.minimumScore) {
|
|
1170
|
+
architectureDomain.issues.push(`Score ${score.score} is below minimum ${assessment.architecture.minimumScore}`);
|
|
1171
|
+
}
|
|
1172
|
+
if (architectureDomain.errors > assessment.architecture.maximumErrors) {
|
|
1173
|
+
architectureDomain.issues.push(`${architectureDomain.errors} error findings exceed maximum ${assessment.architecture.maximumErrors}`);
|
|
1174
|
+
}
|
|
1175
|
+
const blocked = architectureDomain.findings.filter((item) => assessment.architecture.blockedRuleIds.includes(item.ruleId));
|
|
1176
|
+
for (const finding of blocked) architectureDomain.issues.push(`Blocked ${finding.ruleId} at ${finding.file}:${finding.line}: ${finding.evidence}`);
|
|
1177
|
+
if (architectureDomain.status !== "incomplete" && architectureDomain.issues.length) architectureDomain.status = "fail";
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
architectureDomain.status = "incomplete";
|
|
1180
|
+
architectureDomain.issues.push(error instanceof Error ? error.message : String(error));
|
|
1181
|
+
}
|
|
1182
|
+
const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain };
|
|
1183
|
+
results.push({ path: appPath, profile: app.profile, status: statusOf(Object.values(domains).map((domain) => domain.status)), domains });
|
|
1184
|
+
}
|
|
1185
|
+
const report = {
|
|
1186
|
+
schema: "urn:nklattice:governance-assessment:1",
|
|
1187
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1188
|
+
status: issues.length ? "incomplete" : statusOf(results.map((app) => app.status)),
|
|
1189
|
+
policy: { version: options.policy.policyVersion, source: options.policy.source, sha256: options.policy.sha256 },
|
|
1190
|
+
inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
|
|
1191
|
+
scope: { domains: ["explicit app inventory", "dependency minimums", "source architecture"], notEvaluated },
|
|
1192
|
+
issues,
|
|
1193
|
+
apps: results
|
|
299
1194
|
};
|
|
1195
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
1196
|
+
await writeFile(join7(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
|
|
1197
|
+
`);
|
|
1198
|
+
await writeFile(join7(outputDirectory, "report.html"), renderAssessmentHtml(report));
|
|
1199
|
+
return report;
|
|
300
1200
|
}
|
|
301
1201
|
export {
|
|
1202
|
+
assessGovernance,
|
|
302
1203
|
checkGovernance,
|
|
303
|
-
loadPolicy
|
|
1204
|
+
loadPolicy,
|
|
1205
|
+
renderAssessmentHtml
|
|
304
1206
|
};
|