@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/cli.js
CHANGED
|
@@ -1,12 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { writeFile as
|
|
4
|
+
import { writeFile as writeFile4 } from "fs/promises";
|
|
5
5
|
import { realpathSync } from "fs";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
|
-
import { dirname as
|
|
7
|
+
import { dirname as dirname5, join as join12, resolve as resolve11 } from "path";
|
|
8
8
|
import { Command, CommanderError } from "commander";
|
|
9
9
|
|
|
10
|
+
// package.json
|
|
11
|
+
var package_default = {
|
|
12
|
+
name: "@anhcodo13/lattice",
|
|
13
|
+
version: "0.4.0",
|
|
14
|
+
private: false,
|
|
15
|
+
packageManager: "pnpm@10.30.3",
|
|
16
|
+
type: "module",
|
|
17
|
+
description: "Project governance assessment and architecture analysis for React teams",
|
|
18
|
+
author: "KhucHieuNghi",
|
|
19
|
+
license: "MIT",
|
|
20
|
+
repository: { type: "git", url: "git+https://github.com/KhucHieuNghi/nklattice.git" },
|
|
21
|
+
homepage: "https://khuchieunghi.github.io/nklattice/",
|
|
22
|
+
bugs: { url: "https://github.com/KhucHieuNghi/nklattice/issues" },
|
|
23
|
+
publishConfig: { access: "public" },
|
|
24
|
+
bin: { lattice: "dist/cli.js" },
|
|
25
|
+
exports: { ".": { types: "./dist/index.d.ts", import: "./dist/index.js" } },
|
|
26
|
+
files: ["dist", "governance/policy.json", "LICENSE"],
|
|
27
|
+
engines: { node: ">=24" },
|
|
28
|
+
scripts: {
|
|
29
|
+
test: "vitest run",
|
|
30
|
+
typecheck: "tsc --noEmit",
|
|
31
|
+
build: "tsup",
|
|
32
|
+
verify: "pnpm typecheck && pnpm test && pnpm build && node scripts/smoke-built-cli.mjs",
|
|
33
|
+
"verify:package": "node scripts/smoke-tarball.mjs"
|
|
34
|
+
},
|
|
35
|
+
dependencies: {
|
|
36
|
+
commander: "^14.0.0",
|
|
37
|
+
"fast-glob": "^3.3.3",
|
|
38
|
+
semver: "7.8.5",
|
|
39
|
+
typescript: "5.9.3",
|
|
40
|
+
yaml: "^2.9.1"
|
|
41
|
+
},
|
|
42
|
+
devDependencies: {
|
|
43
|
+
"@types/node": "^24.0.0",
|
|
44
|
+
"@types/semver": "^7.8.0",
|
|
45
|
+
tsup: "^8.5.0",
|
|
46
|
+
vitest: "^3.2.0"
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
10
50
|
// src/analysis/analyze.ts
|
|
11
51
|
import { mkdir as mkdir2, readFile as readFile4, writeFile } from "fs/promises";
|
|
12
52
|
import { basename, join as join5, resolve as resolve3 } from "path";
|
|
@@ -37,22 +77,8 @@ function patterns(value, label) {
|
|
|
37
77
|
}
|
|
38
78
|
return value;
|
|
39
79
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
let text;
|
|
43
|
-
try {
|
|
44
|
-
text = await readFile(path, "utf8");
|
|
45
|
-
} catch (error) {
|
|
46
|
-
if (record(error) && error.code === "ENOENT") return { thresholds: { ...defaultThresholds }, weights: { ...defaultWeights }, policies: [] };
|
|
47
|
-
throw error;
|
|
48
|
-
}
|
|
49
|
-
let value;
|
|
50
|
-
try {
|
|
51
|
-
value = JSON.parse(text);
|
|
52
|
-
} catch {
|
|
53
|
-
throw new Error("Malformed .nklattice.json");
|
|
54
|
-
}
|
|
55
|
-
if (!record(value)) throw new Error(".nklattice.json must be an object");
|
|
80
|
+
function parseAnalysisConfig(value) {
|
|
81
|
+
if (!record(value)) throw new Error("Analysis configuration must be an object");
|
|
56
82
|
const analysis = value.analysis === void 0 ? {} : value.analysis;
|
|
57
83
|
if (!record(analysis)) throw new Error("analysis must be an object");
|
|
58
84
|
const overrides = analysis.thresholds === void 0 ? {} : analysis.thresholds;
|
|
@@ -89,6 +115,7 @@ async function loadAnalysisConfig(root) {
|
|
|
89
115
|
if (ids.has(raw.id)) throw new Error(`Duplicate policy id ${raw.id}`);
|
|
90
116
|
ids.add(raw.id);
|
|
91
117
|
if (raw.enabled === false) continue;
|
|
118
|
+
if (raw.enabled !== void 0 && raw.enabled !== true) throw new Error(`Invalid enabled flag in ${raw.id}`);
|
|
92
119
|
const from = patterns(raw.from, `${raw.id}.from`);
|
|
93
120
|
const hasAllow = raw.allow !== void 0;
|
|
94
121
|
const hasDisallow = raw.disallow !== void 0;
|
|
@@ -106,7 +133,33 @@ async function loadAnalysisConfig(root) {
|
|
|
106
133
|
recommendation: raw.recommendation
|
|
107
134
|
});
|
|
108
135
|
}
|
|
109
|
-
|
|
136
|
+
const rawAliases = analysis.aliases ?? {};
|
|
137
|
+
if (!record(rawAliases)) throw new Error("analysis.aliases must be an object");
|
|
138
|
+
const aliases = {};
|
|
139
|
+
for (const [prefix, target] of Object.entries(rawAliases)) {
|
|
140
|
+
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 === "..")) {
|
|
141
|
+
throw new Error(`Invalid analysis alias ${prefix}`);
|
|
142
|
+
}
|
|
143
|
+
aliases[prefix] = target;
|
|
144
|
+
}
|
|
145
|
+
return { thresholds, weights, policies, aliases };
|
|
146
|
+
}
|
|
147
|
+
async function loadAnalysisConfig(root) {
|
|
148
|
+
const path = join(root, ".nklattice.json");
|
|
149
|
+
let text;
|
|
150
|
+
try {
|
|
151
|
+
text = await readFile(path, "utf8");
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (record(error) && error.code === "ENOENT") return parseAnalysisConfig({});
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
let value;
|
|
157
|
+
try {
|
|
158
|
+
value = JSON.parse(text);
|
|
159
|
+
} catch {
|
|
160
|
+
throw new Error("Malformed .nklattice.json");
|
|
161
|
+
}
|
|
162
|
+
return parseAnalysisConfig(value);
|
|
110
163
|
}
|
|
111
164
|
|
|
112
165
|
// src/analysis/graph.ts
|
|
@@ -170,28 +223,28 @@ async function exists(path) {
|
|
|
170
223
|
}
|
|
171
224
|
}
|
|
172
225
|
async function archivePrevious(outputDirectory) {
|
|
173
|
-
const
|
|
226
|
+
const json2 = join2(outputDirectory, "analysis.json");
|
|
174
227
|
const html = join2(outputDirectory, "report.html");
|
|
175
|
-
if (!await exists(
|
|
228
|
+
if (!await exists(json2) && !await exists(html)) return;
|
|
176
229
|
const name = `${(/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
|
177
230
|
const destination = join2(outputDirectory, "history", name);
|
|
178
231
|
await mkdir(destination, { recursive: true });
|
|
179
|
-
if (await exists(
|
|
232
|
+
if (await exists(json2)) await rename(json2, join2(destination, "analysis.json"));
|
|
180
233
|
if (await exists(html)) await rename(html, join2(destination, "report.html"));
|
|
181
234
|
}
|
|
182
235
|
|
|
183
236
|
// src/analysis/recommend.ts
|
|
184
|
-
function recommendActions(
|
|
237
|
+
function recommendActions(findings2) {
|
|
185
238
|
const points = { error: 5, warning: 2, info: 1 };
|
|
186
239
|
const grouped = /* @__PURE__ */ new Map();
|
|
187
|
-
for (const finding of
|
|
240
|
+
for (const finding of findings2) grouped.set(finding.file, [...grouped.get(finding.file) ?? [], finding]);
|
|
188
241
|
const ranked = [...grouped.entries()].map(([file, items]) => ({ file, items, priority: items.reduce((sum, item) => sum + points[item.severity], 0) })).sort((a, b) => b.priority - a.priority || a.file.localeCompare(b.file));
|
|
189
242
|
const hotspots = ranked.slice(0, 8).map(({ file, items, priority }) => ({
|
|
190
243
|
file,
|
|
191
244
|
findingCount: items.length,
|
|
192
245
|
estimatedRecovery: `Estimate: up to ${Math.min(15, priority)} points if these findings are resolved`
|
|
193
246
|
}));
|
|
194
|
-
const quickWins =
|
|
247
|
+
const quickWins = findings2.filter((finding) => finding.severity === "info" || finding.ruleId === "duplicate-import").slice(0, 8).map((finding) => ({ file: finding.file, ruleId: finding.ruleId, recommendation: finding.recommendation }));
|
|
195
248
|
const roadmap = ranked.slice(0, 8).map(({ file, items, priority }) => ({
|
|
196
249
|
file,
|
|
197
250
|
priority,
|
|
@@ -204,79 +257,365 @@ function recommendActions(findings) {
|
|
|
204
257
|
function escapeHtml(value) {
|
|
205
258
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
206
259
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
:
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
260
|
+
var styles = String.raw`
|
|
261
|
+
:root {
|
|
262
|
+
color-scheme: light;
|
|
263
|
+
--paper: #f7f8f3;
|
|
264
|
+
--surface: #fff;
|
|
265
|
+
--ink: #182019;
|
|
266
|
+
--muted: #626d63;
|
|
267
|
+
--line: #dce4d8;
|
|
268
|
+
--lime: #d8f85d;
|
|
269
|
+
--forest: #172119;
|
|
270
|
+
--risk: #ef9a79;
|
|
271
|
+
--warning: #9d5a22;
|
|
272
|
+
--error: #a63d35;
|
|
273
|
+
--info: #346f69;
|
|
274
|
+
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
275
|
+
}
|
|
276
|
+
* { box-sizing: border-box; }
|
|
277
|
+
html { scroll-behavior: smooth; }
|
|
278
|
+
body { margin: 0; background: var(--paper); color: var(--ink); font-size: 14px; line-height: 1.55; }
|
|
279
|
+
button, input, select { font: inherit; }
|
|
280
|
+
button, select, input { color: var(--ink); }
|
|
281
|
+
a { color: inherit; }
|
|
282
|
+
:focus-visible { outline: 3px solid #668619; outline-offset: 3px; }
|
|
283
|
+
.skip-link { position: fixed; z-index: 10; top: -70px; left: 18px; padding: 10px 15px; background: var(--lime); font-weight: 700; }
|
|
284
|
+
.skip-link:focus { top: 12px; }
|
|
285
|
+
.layout { display: grid; grid-template-columns: 224px minmax(0, 1fr); min-height: 100vh; }
|
|
286
|
+
.rail { position: sticky; top: 0; display: flex; flex-direction: column; height: 100vh; padding: 31px 19px 22px; border-right: 1px solid var(--line); background: #f1f4eb; }
|
|
287
|
+
.rail-brand { padding: 0 10px 31px; border-bottom: 1px solid #d5dfd0; }
|
|
288
|
+
.brand { display: block; font-size: 19px; font-weight: 780; letter-spacing: -.055em; }
|
|
289
|
+
.rail-brand small, .rail-foot { display: block; margin-top: 2px; color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .11em; text-transform: uppercase; }
|
|
290
|
+
.rail-links { display: grid; gap: 3px; padding-top: 26px; }
|
|
291
|
+
.rail-links a { display: flex; align-items: center; gap: 12px; min-height: 41px; padding: 7px 10px; border-radius: 7px; color: #465248; font-weight: 570; text-decoration: none; }
|
|
292
|
+
.rail-links a:hover, .rail-links a:focus-visible { background: #e4eccf; color: var(--ink); }
|
|
293
|
+
.rail-links span { color: #83917d; font: 600 10px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
294
|
+
.rail-foot { margin-top: auto; padding: 20px 10px 0; border-top: 1px solid #d5dfd0; line-height: 1.65; letter-spacing: .06em; }
|
|
295
|
+
main { width: min(100%, 1460px); margin: 0 auto; padding: 0 clamp(28px, 5vw, 82px) 82px; }
|
|
296
|
+
.report-header { padding: 38px 0 30px; }
|
|
297
|
+
.topline { display: flex; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--line); color: var(--muted); font: 650 10px ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .12em; text-transform: uppercase; }
|
|
298
|
+
.report-header h1 { margin: 25px 0 7px; font-size: clamp(29px, 3.6vw, 47px); line-height: 1.12; letter-spacing: -.055em; overflow-wrap: anywhere; }
|
|
299
|
+
.report-header p { margin: 0; color: var(--muted); max-width: 690px; }
|
|
300
|
+
.overview-panel { display: grid; grid-template-columns: minmax(250px, .83fr) minmax(0, 1fr); gap: 30px; padding: clamp(26px, 3vw, 42px); border-radius: 13px; background: var(--forest); color: #f5f8f1; }
|
|
301
|
+
.overview-panel.score-risk { --hero-accent: var(--risk); }
|
|
302
|
+
.overview-panel:not(.score-risk) { --hero-accent: var(--lime); }
|
|
303
|
+
.hero-label { display: block; color: #b9c8b6; font-size: 12px; font-weight: 650; letter-spacing: .11em; text-transform: uppercase; }
|
|
304
|
+
.hero-number { display: flex; align-items: baseline; gap: 8px; margin-top: 12px; }
|
|
305
|
+
.hero-number strong { color: var(--hero-accent); font-size: clamp(66px, 8vw, 112px); font-weight: 620; line-height: 1; letter-spacing: -.095em; }
|
|
306
|
+
.hero-number span { color: #a5b3a2; font: 500 18px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
307
|
+
.hero-grade { display: flex; align-items: center; gap: 11px; margin-top: 14px; color: #bdc9b8; }
|
|
308
|
+
.hero-grade strong { min-width: 32px; padding: 2px 7px; border: 1px solid var(--hero-accent); border-radius: 4px; color: var(--hero-accent); text-align: center; }
|
|
309
|
+
.hero-context { align-self: end; }
|
|
310
|
+
.hero-context p { max-width: 430px; margin: 0 0 22px; color: #b9c8b6; font-size: 14px; }
|
|
311
|
+
.hero-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 11px; padding-top: 19px; border-top: 1px solid #3b4b3d; }
|
|
312
|
+
.hero-stats div { min-width: 0; }
|
|
313
|
+
.hero-stats strong { display: block; font-size: clamp(21px, 2vw, 30px); font-weight: 600; letter-spacing: -.045em; }
|
|
314
|
+
.hero-stats span { color: #a5b3a2; font-size: 11px; }
|
|
315
|
+
section { padding-top: 62px; scroll-margin-top: 26px; }
|
|
316
|
+
.section-head { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: 24px; margin-bottom: 23px; }
|
|
317
|
+
.section-index { padding-top: 8px; color: #65735e; font: 650 11px ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .05em; text-transform: uppercase; }
|
|
318
|
+
h2 { margin: 0; font-size: clamp(25px, 2.7vw, 35px); line-height: 1.17; letter-spacing: -.045em; }
|
|
319
|
+
.section-head p { max-width: 680px; margin: 8px 0 0; color: var(--muted); }
|
|
320
|
+
.two-column { display: grid; grid-template-columns: minmax(0, 1.16fr) minmax(290px, .84fr); gap: 15px; }
|
|
321
|
+
.panel { min-width: 0; padding: 25px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
|
|
322
|
+
.panel h3 { margin: 0 0 14px; font-size: 17px; letter-spacing: -.025em; }
|
|
323
|
+
.panel p { margin: 0 0 13px; color: var(--muted); }
|
|
324
|
+
.category-row + .category-row { margin-top: 20px; }
|
|
325
|
+
.category-head { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
|
|
326
|
+
.category-head strong { font-size: 13px; text-transform: capitalize; }
|
|
327
|
+
.category-meta { color: var(--muted); font-size: 12px; }
|
|
328
|
+
.category-meta b { color: var(--ink); font-size: 14px; }
|
|
329
|
+
.bar { height: 7px; overflow: hidden; border-radius: 8px; background: #e9ede5; }
|
|
330
|
+
.bar i { display: block; height: 100%; border-radius: inherit; background: #7d9f46; }
|
|
331
|
+
.formula { margin: 18px 0; padding: 16px; border-radius: 6px; background: #f1f4ec; color: #3d4f39; font: 12px/1.7 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
|
|
332
|
+
.note { padding-top: 13px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
|
|
333
|
+
.filterbar { display: grid; grid-template-columns: 160px 175px minmax(180px, 1fr); gap: 12px; align-items: end; margin-bottom: 14px; }
|
|
334
|
+
.filterbar label { display: grid; gap: 6px; min-width: 0; color: var(--muted); font-size: 11px; font-weight: 680; letter-spacing: .05em; text-transform: uppercase; }
|
|
335
|
+
.filterbar select, .filterbar input, .graph-toolbar select { width: 100%; height: 42px; padding: 8px 11px; border: 1px solid #cbd6c8; border-radius: 6px; background: #fff; color: var(--ink); font-size: 13px; letter-spacing: 0; text-transform: none; }
|
|
336
|
+
.results-line { display: flex; justify-content: space-between; gap: 10px; margin: 0 0 11px; color: var(--muted); font-size: 12px; }
|
|
337
|
+
.findings { border-top: 1px solid var(--line); }
|
|
338
|
+
.finding { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 18px; padding: 20px 4px; border-bottom: 1px solid var(--line); }
|
|
339
|
+
.finding-severity { align-self: start; width: fit-content; padding: 3px 8px; border-radius: 3px; background: #e4f0ee; color: var(--info); font: 700 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
|
|
340
|
+
.finding-severity.warning { background: #f7ead8; color: var(--warning); }
|
|
341
|
+
.finding-severity.error { background: #f8e7e5; color: var(--error); }
|
|
342
|
+
.finding-heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: 7px 15px; }
|
|
343
|
+
.finding-heading strong { font-size: 16px; letter-spacing: -.02em; }
|
|
344
|
+
.path { color: #536457; font: 12px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
|
|
345
|
+
.finding-evidence { margin: 8px 0 12px; color: #39453c; }
|
|
346
|
+
.finding-action { display: flex; gap: 11px; padding-left: 11px; border-left: 2px solid #b3ce5c; color: #465447; font-size: 12px; }
|
|
347
|
+
.finding-action b { flex: 0 0 auto; color: #466223; }
|
|
348
|
+
.more-button { margin-top: 17px; padding: 11px 18px; border: 1px solid #a9b69e; border-radius: 6px; background: #fff; font-weight: 650; cursor: pointer; }
|
|
349
|
+
.more-button:hover { background: #ebf4d9; }
|
|
350
|
+
.list { margin: 0; padding: 0; list-style: none; }
|
|
351
|
+
.list li { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 12px; padding: 13px 0; border-top: 1px solid var(--line); }
|
|
352
|
+
.list-index { padding-top: 2px; color: #6b8361; font: 600 11px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
353
|
+
.list strong { display: block; font-size: 13px; font-weight: 650; overflow-wrap: anywhere; }
|
|
354
|
+
.list small { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
|
355
|
+
.empty { padding: 16px 0; color: var(--muted); }
|
|
356
|
+
.graph-toolbar { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
|
357
|
+
.graph-toolbar label { display: grid; gap: 6px; width: min(480px, 100%); color: var(--muted); font-size: 11px; font-weight: 680; letter-spacing: .05em; text-transform: uppercase; }
|
|
358
|
+
.graph-key { display: flex; gap: 17px; color: var(--muted); font-size: 12px; }
|
|
359
|
+
.graph-key span::before { display: inline-block; width: 14px; height: 2px; margin-right: 7px; vertical-align: middle; background: #91a93b; content: ''; }
|
|
360
|
+
.graph-key span:last-child::before { background: #709a9b; }
|
|
361
|
+
.graph-grid { display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(240px, .7fr); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; background: #fff; }
|
|
362
|
+
.graph-wrap { min-width: 0; overflow: auto; background: #fbfcf8; }
|
|
363
|
+
.graph { display: block; width: 100%; min-width: 600px; height: auto; }
|
|
364
|
+
.graph .link-out { stroke: #91a93b; stroke-width: 2; }
|
|
365
|
+
.graph .link-in { stroke: #709a9b; stroke-width: 2; }
|
|
366
|
+
.graph .node-circle { fill: #fff; stroke: #718b69; stroke-width: 2; }
|
|
367
|
+
.graph .node-root .node-circle { fill: var(--lime); stroke: #2d4321; stroke-width: 3; }
|
|
368
|
+
.graph .node:hover .node-circle, .graph .node:focus-visible .node-circle { fill: #e2f3ac; stroke: #2d4321; }
|
|
369
|
+
.graph .node { cursor: pointer; }
|
|
370
|
+
.graph .node-label { fill: #405446; font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
371
|
+
.graph-detail { min-width: 0; padding: 25px 20px; border-left: 1px solid var(--line); }
|
|
372
|
+
.graph-detail h3 { margin: 0 0 10px; font-size: 13px; overflow-wrap: anywhere; }
|
|
373
|
+
.graph-detail p { margin: 0 0 17px; color: var(--muted); font-size: 12px; }
|
|
374
|
+
.graph-detail ul { margin: 0; padding: 0; list-style: none; }
|
|
375
|
+
.graph-detail li { padding: 7px 0; border-top: 1px solid var(--line); color: #3d4c3f; font: 11px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
|
|
376
|
+
.graph-detail li span { display: inline-block; min-width: 26px; color: #6a8725; font-weight: 700; }
|
|
377
|
+
.footer { margin-top: 66px; padding-top: 22px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }
|
|
378
|
+
@media (max-width: 1100px) { .section-head { grid-template-columns: 130px minmax(0, 1fr); } .graph-grid { grid-template-columns: 1fr; } .graph-detail { border-left: 0; border-top: 1px solid var(--line); } }
|
|
379
|
+
@media (max-width: 850px) { .layout { display: block; } .rail { position: static; height: auto; padding: 15px 20px; border-right: 0; border-bottom: 1px solid var(--line); } .rail-brand { padding: 0 0 10px; border-bottom: 0; } .rail-links { display: flex; gap: 2px; overflow-x: auto; padding-top: 0; } .rail-links a { flex: 0 0 auto; white-space: nowrap; font-size: 12px; } .rail-foot { display: none; } .two-column { grid-template-columns: 1fr; } }
|
|
380
|
+
@media (max-width: 620px) { main { padding: 0 18px 55px; } .topline { display: block; } .topline span { display: block; margin-top: 6px; } .overview-panel { grid-template-columns: 1fr; gap: 25px; } .hero-context p { margin-bottom: 15px; } .section-head { display: block; } .section-index { display: block; margin-bottom: 9px; } .filterbar { grid-template-columns: 1fr 1fr; } .filterbar label:last-child { grid-column: 1 / -1; } .finding { grid-template-columns: 1fr; gap: 8px; } .graph-key { width: 100%; } }
|
|
381
|
+
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
|
|
382
|
+
`;
|
|
383
|
+
var script = String.raw`
|
|
232
384
|
(() => {
|
|
233
385
|
const report = JSON.parse(document.getElementById('report-data').textContent);
|
|
234
386
|
const byId = id => document.getElementById(id);
|
|
235
387
|
const text = (id, value) => { byId(id).textContent = String(value); };
|
|
236
|
-
const add = (parent, tag, value, className) => {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
388
|
+
const add = (parent, tag, value, className) => {
|
|
389
|
+
const node = document.createElement(tag);
|
|
390
|
+
if (className) node.className = className;
|
|
391
|
+
node.textContent = String(value);
|
|
392
|
+
parent.append(node);
|
|
393
|
+
return node;
|
|
394
|
+
};
|
|
395
|
+
const count = value => new Intl.NumberFormat().format(value);
|
|
396
|
+
const severityCount = severity => report.findings.filter(item => item.severity === severity).length;
|
|
397
|
+
|
|
398
|
+
text('generated-at', new Date(report.generatedAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }));
|
|
399
|
+
text('score-value', report.score.score.toFixed(2));
|
|
400
|
+
text('grade-value', report.score.grade);
|
|
401
|
+
text('module-count', count(report.graph.modules.length));
|
|
402
|
+
text('link-count', count(report.graph.edges.length));
|
|
403
|
+
text('finding-count', count(report.findings.length));
|
|
404
|
+
text('findings-summary', count(severityCount('error')) + ' error · ' + count(severityCount('warning')) + ' warning · ' + count(severityCount('info')) + ' info');
|
|
405
|
+
text('score-formula', '100 − ' + report.score.weightedDeduction + ' weighted − ' + report.score.maintenanceAdjustment + ' maintenance − ' + report.score.criticalAdjustment + ' critical; bounded to 0–100 = ' + report.score.score);
|
|
406
|
+
if (report.score.score < 55) byId('overview-panel').classList.add('score-risk');
|
|
407
|
+
|
|
408
|
+
for (const category of report.score.categories) {
|
|
409
|
+
const row = add(byId('category-bars'), 'div', '', 'category-row');
|
|
410
|
+
const head = add(row, 'div', '', 'category-head');
|
|
411
|
+
const name = add(head, 'div', '', 'category-name');
|
|
412
|
+
add(name, 'strong', category.id);
|
|
413
|
+
add(name, 'span', ' · ' + category.weight + '% weight · ' + category.findingCount + ' findings', 'category-meta');
|
|
414
|
+
const meta = add(head, 'span', '', 'category-meta');
|
|
415
|
+
add(meta, 'b', category.score.toFixed(2));
|
|
416
|
+
add(meta, 'span', ' / 100');
|
|
417
|
+
const bar = add(row, 'div', '', 'bar');
|
|
418
|
+
bar.setAttribute('role', 'meter');
|
|
419
|
+
bar.setAttribute('aria-label', category.id + ' score');
|
|
420
|
+
bar.setAttribute('aria-valuemin', '0');
|
|
421
|
+
bar.setAttribute('aria-valuemax', '100');
|
|
422
|
+
bar.setAttribute('aria-valuenow', String(category.score));
|
|
423
|
+
const fill = add(bar, 'i', '');
|
|
424
|
+
fill.style.width = Math.max(0, Math.min(100, category.score)) + '%';
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const categories = [...new Set(report.findings.map(item => item.category))].sort();
|
|
428
|
+
for (const category of categories) {
|
|
429
|
+
const option = add(byId('category-filter'), 'option', category);
|
|
430
|
+
option.value = category;
|
|
431
|
+
}
|
|
432
|
+
let visibleLimit = 25;
|
|
433
|
+
const renderFindings = () => {
|
|
434
|
+
const list = byId('findings-list');
|
|
435
|
+
list.replaceChildren();
|
|
436
|
+
const severity = byId('findings-filter').value;
|
|
437
|
+
const category = byId('category-filter').value;
|
|
438
|
+
const query = byId('findings-search').value.trim().toLowerCase();
|
|
439
|
+
const visible = report.findings.filter(item =>
|
|
440
|
+
(severity === 'all' || item.severity === severity) &&
|
|
441
|
+
(category === 'all' || item.category === category) &&
|
|
442
|
+
[item.file, item.ruleId, item.evidence, item.recommendation].join(' ').toLowerCase().includes(query));
|
|
443
|
+
const shown = visible.slice(0, visibleLimit);
|
|
444
|
+
text('results-count', 'Showing ' + count(shown.length) + ' of ' + count(visible.length) + ' matching findings');
|
|
445
|
+
if (!shown.length) add(list, 'div', 'No findings match these filters.', 'empty');
|
|
446
|
+
for (const item of shown) {
|
|
447
|
+
const card = add(list, 'article', '', 'finding');
|
|
448
|
+
add(card, 'span', item.severity, 'finding-severity ' + item.severity);
|
|
449
|
+
const content = add(card, 'div', '', 'finding-content');
|
|
450
|
+
const heading = add(content, 'div', '', 'finding-heading');
|
|
451
|
+
add(heading, 'strong', item.ruleId);
|
|
452
|
+
add(heading, 'span', item.file + ':' + item.line, 'path');
|
|
453
|
+
add(content, 'p', item.evidence, 'finding-evidence');
|
|
454
|
+
const action = add(content, 'div', '', 'finding-action');
|
|
455
|
+
add(action, 'b', 'Next step');
|
|
456
|
+
add(action, 'span', item.recommendation);
|
|
457
|
+
}
|
|
458
|
+
byId('load-more').hidden = visible.length <= visibleLimit;
|
|
254
459
|
};
|
|
255
|
-
for(const id of ['findings-filter','category-filter','findings-search'])
|
|
460
|
+
for (const id of ['findings-filter', 'category-filter', 'findings-search']) {
|
|
461
|
+
byId(id).addEventListener('input', () => { visibleLimit = 25; renderFindings(); });
|
|
462
|
+
}
|
|
463
|
+
byId('load-more').addEventListener('click', () => { visibleLimit += 25; renderFindings(); });
|
|
256
464
|
renderFindings();
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
465
|
+
|
|
466
|
+
const renderList = (id, values, render) => {
|
|
467
|
+
const list = byId(id);
|
|
468
|
+
if (!values.length) { add(list, 'li', 'Nothing to review in this section.', 'empty'); return; }
|
|
469
|
+
values.forEach((value, index) => {
|
|
470
|
+
const row = add(list, 'li', '');
|
|
471
|
+
add(row, 'span', String(index + 1).padStart(2, '0'), 'list-index');
|
|
472
|
+
const content = add(row, 'div', '');
|
|
473
|
+
render(content, value);
|
|
474
|
+
});
|
|
475
|
+
};
|
|
476
|
+
renderList('hotspot-list', report.actions.hotspots, (content, item) => {
|
|
477
|
+
add(content, 'strong', item.file);
|
|
478
|
+
add(content, 'small', item.findingCount + ' findings · ' + item.estimatedRecovery);
|
|
479
|
+
});
|
|
480
|
+
renderList('quickwin-list', report.actions.quickWins, (content, item) => {
|
|
481
|
+
add(content, 'strong', item.file + ' · ' + item.ruleId);
|
|
482
|
+
add(content, 'small', item.recommendation);
|
|
483
|
+
});
|
|
484
|
+
renderList('roadmap-list', report.actions.roadmap, (content, item) => {
|
|
485
|
+
add(content, 'strong', item.file);
|
|
486
|
+
add(content, 'small', item.summary);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const svg = byId('module-graph');
|
|
490
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
491
|
+
const degrees = new Map(report.graph.modules.map(module => [module.path, 0]));
|
|
492
|
+
for (const edge of report.graph.edges) {
|
|
493
|
+
degrees.set(edge.from, (degrees.get(edge.from) || 0) + 1);
|
|
494
|
+
degrees.set(edge.to, (degrees.get(edge.to) || 0) + 1);
|
|
495
|
+
}
|
|
496
|
+
const modules = [...report.graph.modules].sort((a, b) =>
|
|
497
|
+
(degrees.get(b.path) || 0) - (degrees.get(a.path) || 0) || a.path.localeCompare(b.path));
|
|
498
|
+
for (const module of modules) {
|
|
499
|
+
const option = add(byId('graph-module'), 'option', module.path);
|
|
500
|
+
option.value = module.path;
|
|
501
|
+
}
|
|
502
|
+
const svgEl = (tag, attrs) => {
|
|
503
|
+
const node = document.createElementNS(ns, tag);
|
|
504
|
+
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value));
|
|
505
|
+
svg.append(node);
|
|
506
|
+
return node;
|
|
507
|
+
};
|
|
508
|
+
const renderGraph = () => {
|
|
509
|
+
svg.replaceChildren();
|
|
510
|
+
const selected = byId('graph-module').value;
|
|
511
|
+
const module = report.graph.modules.find(item => item.path === selected);
|
|
512
|
+
if (!module) { text('graph-detail-title', 'No modules found'); return; }
|
|
513
|
+
const incoming = report.graph.edges.filter(edge => edge.to === selected);
|
|
514
|
+
const outgoing = report.graph.edges.filter(edge => edge.from === selected);
|
|
515
|
+
const related = new Map();
|
|
516
|
+
for (const edge of outgoing) related.set(edge.to, { path: edge.to, outgoing: true, incoming: related.get(edge.to)?.incoming || false });
|
|
517
|
+
for (const edge of incoming) related.set(edge.from, { path: edge.from, incoming: true, outgoing: related.get(edge.from)?.outgoing || false });
|
|
518
|
+
const neighbors = [...related.values()].sort((a, b) =>
|
|
519
|
+
(degrees.get(b.path) || 0) - (degrees.get(a.path) || 0) || a.path.localeCompare(b.path));
|
|
520
|
+
const shown = neighbors.slice(0, 12);
|
|
521
|
+
const cx = 450, cy = 260;
|
|
522
|
+
shown.forEach((neighbor, index) => {
|
|
523
|
+
const angle = 2 * Math.PI * index / Math.max(1, shown.length) - Math.PI / 2;
|
|
524
|
+
const x = cx + 275 * Math.cos(angle), y = cy + 185 * Math.sin(angle);
|
|
525
|
+
if (neighbor.outgoing) svgEl('line', { x1: cx, y1: cy, x2: x, y2: y, class: 'link-out' });
|
|
526
|
+
if (neighbor.incoming) svgEl('line', { x1: x, y1: y, x2: cx, y2: cy, class: 'link-in' });
|
|
527
|
+
const group = svgEl('g', { class: 'node', tabindex: '0', role: 'button', 'aria-label': 'Focus ' + neighbor.path });
|
|
528
|
+
const circle = document.createElementNS(ns, 'circle');
|
|
529
|
+
for (const [key, value] of Object.entries({ cx: x, cy: y, r: 10, class: 'node-circle' })) circle.setAttribute(key, String(value));
|
|
530
|
+
group.append(circle);
|
|
531
|
+
const label = document.createElementNS(ns, 'text');
|
|
532
|
+
label.setAttribute('x', String(x + (x < cx ? -18 : 18)));
|
|
533
|
+
label.setAttribute('y', String(y + 4));
|
|
534
|
+
label.setAttribute('text-anchor', x < cx ? 'end' : 'start');
|
|
535
|
+
label.setAttribute('class', 'node-label');
|
|
536
|
+
const basename = neighbor.path.split('/').pop() || neighbor.path;
|
|
537
|
+
label.textContent = basename.length > 17 ? basename.slice(0, 15) + '…' : basename;
|
|
538
|
+
group.append(label);
|
|
539
|
+
const focus = () => { byId('graph-module').value = neighbor.path; renderGraph(); };
|
|
540
|
+
group.addEventListener('click', focus);
|
|
541
|
+
group.addEventListener('keydown', event => {
|
|
542
|
+
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); focus(); byId('graph-module').focus(); }
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
const root = svgEl('g', { class: 'node node-root', 'aria-label': 'Focused module ' + selected });
|
|
546
|
+
const center = document.createElementNS(ns, 'circle');
|
|
547
|
+
for (const [key, value] of Object.entries({ cx, cy, r: 22, class: 'node-circle' })) center.setAttribute(key, String(value));
|
|
548
|
+
root.append(center);
|
|
549
|
+
const label = document.createElementNS(ns, 'text');
|
|
550
|
+
label.setAttribute('x', String(cx));
|
|
551
|
+
label.setAttribute('y', String(cy + 42));
|
|
552
|
+
label.setAttribute('text-anchor', 'middle');
|
|
553
|
+
label.setAttribute('class', 'node-label');
|
|
554
|
+
label.textContent = (selected.split('/').pop() || selected).slice(0, 24);
|
|
555
|
+
root.append(label);
|
|
556
|
+
text('graph-detail-title', selected);
|
|
557
|
+
text('graph-detail-meta', incoming.length + ' incoming · ' + outgoing.length + ' outgoing · ' + module.lines + ' lines');
|
|
558
|
+
text('graph-detail-limit', neighbors.length > 12 ? 'Showing 12 of ' + neighbors.length + ' connected modules. Choose another focus to continue.' : 'Showing all ' + neighbors.length + ' directly connected modules.');
|
|
559
|
+
const list = byId('graph-neighbors');
|
|
560
|
+
list.replaceChildren();
|
|
561
|
+
for (const neighbor of shown) {
|
|
562
|
+
const item = add(list, 'li', '');
|
|
563
|
+
add(item, 'span', neighbor.incoming && neighbor.outgoing ? '↔' : neighbor.outgoing ? '→' : '←');
|
|
564
|
+
add(item, 'span', neighbor.path);
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
byId('graph-module').addEventListener('change', renderGraph);
|
|
568
|
+
renderGraph();
|
|
267
569
|
})();
|
|
268
|
-
|
|
570
|
+
`;
|
|
571
|
+
function renderHtml(report) {
|
|
572
|
+
const data = JSON.stringify(report).replaceAll("<", "\\u003c");
|
|
573
|
+
const title = escapeHtml(report.project.name);
|
|
574
|
+
return `<!doctype html>
|
|
575
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
576
|
+
<title>${title} \xB7 NKLattice architecture report</title><style>${styles}</style></head><body>
|
|
577
|
+
<a class="skip-link" href="#report-main">Skip to report</a>
|
|
578
|
+
<div class="layout"><nav class="rail" aria-label="Report sections">
|
|
579
|
+
<div class="rail-brand"><span class="brand">NKLattice</span><small>Architecture report</small></div>
|
|
580
|
+
<div class="rail-links"><a href="#overview"><span>01</span>Overview</a><a href="#score"><span>02</span>Score</a><a href="#findings"><span>03</span>Findings</a><a href="#hotspots"><span>04</span>Priorities</a><a href="#graph"><span>05</span>Graph</a><a href="#roadmap"><span>06</span>Roadmap</a></div>
|
|
581
|
+
<div class="rail-foot">Local report<br>No source upload</div>
|
|
582
|
+
</nav>
|
|
583
|
+
<main id="report-main"><header class="report-header" id="overview">
|
|
584
|
+
<div class="topline"><span>Project / Architecture analysis</span><span>Generated <time id="generated-at"></time></span></div>
|
|
585
|
+
<h1>${title}</h1><p>A review of the source files found in this scan. Use the findings below to decide what to inspect first.</p>
|
|
586
|
+
</header>
|
|
587
|
+
<div class="overview-panel" id="overview-panel" aria-label="Analysis summary">
|
|
588
|
+
<div><span class="hero-label">Architecture score</span><div class="hero-number"><strong id="score-value"></strong><span>/ 100</span></div><div class="hero-grade"><strong id="grade-value"></strong><span>Based on this scan and its current rules</span></div></div>
|
|
589
|
+
<div class="hero-context"><p>The score describes static source patterns. It does not measure runtime performance, security, or product quality.</p><div class="hero-stats"><div><strong id="module-count"></strong><span>Modules</span></div><div><strong id="link-count"></strong><span>Local links</span></div><div><strong id="finding-count"></strong><span>Findings</span></div></div></div>
|
|
590
|
+
</div>
|
|
591
|
+
<section id="score"><div class="section-head"><span class="section-index">02 / Score</span><div><h2>Where the score comes from</h2><p>Category scores use the weights configured for this project. The final score reflects weighted deductions and bounded adjustments.</p></div></div>
|
|
592
|
+
<div class="two-column"><div class="panel"><h3>Category breakdown</h3><div id="category-bars"></div></div><div class="panel"><h3>Calculation</h3><p>Start at 100. Weighted category deductions and bounded adjustments produce the final score.</p><div class="formula" id="score-formula"></div><div class="note">Thresholds and weights can be changed in the project configuration. Compare runs only when their scan scope and settings match.</div></div></div>
|
|
593
|
+
</section>
|
|
594
|
+
<section id="findings"><div class="section-head"><span class="section-index">03 / Evidence</span><div><h2>Findings to review</h2><p id="findings-summary"></p></div></div>
|
|
595
|
+
<div class="filterbar"><label>Severity<select id="findings-filter"><option value="all">All severities</option><option value="error">Error</option><option value="warning">Warning</option><option value="info">Info</option></select></label><label>Category<select id="category-filter"><option value="all">All categories</option></select></label><label>Search findings<input id="findings-search" type="search" placeholder="File, rule, evidence, or next step"></label></div>
|
|
596
|
+
<div class="results-line"><span id="results-count" role="status" aria-live="polite"></span><span>Sorted by the analysis engine</span></div><div id="findings-list" class="findings"></div><button type="button" id="load-more" class="more-button" hidden>Show 25 more findings</button>
|
|
597
|
+
</section>
|
|
598
|
+
<section id="hotspots"><div class="section-head"><span class="section-index">04 / Priorities</span><div><h2>Where to begin</h2><p>Hotspots rank files by finding severity. Quick wins are selected lower-severity issues; neither list guarantees a score gain.</p></div></div>
|
|
599
|
+
<div class="two-column"><div class="panel"><h3>Hotspots</h3><ol id="hotspot-list" class="list"></ol></div><div class="panel"><h3>Quick wins</h3><ol id="quickwin-list" class="list"></ol></div></div>
|
|
600
|
+
</section>
|
|
601
|
+
<section id="graph"><div class="section-head"><span class="section-index">05 / Connections</span><div><h2>Explore local dependencies</h2><p>Focus on a module to see direct incoming and outgoing links. The complete graph remains in analysis.json.</p></div></div>
|
|
602
|
+
<div class="graph-toolbar"><label>Focus module<select id="graph-module" aria-label="Focus module in dependency graph"></select></label><div class="graph-key"><span>Outgoing import</span><span>Incoming import</span></div></div>
|
|
603
|
+
<div class="graph-grid"><div class="graph-wrap"><svg id="module-graph" class="graph" viewBox="0 0 900 520" role="group" aria-label="Direct dependencies of the selected module"></svg></div><aside class="graph-detail" aria-live="polite"><h3 id="graph-detail-title"></h3><p id="graph-detail-meta"></p><p id="graph-detail-limit"></p><ul id="graph-neighbors"></ul></aside></div>
|
|
604
|
+
</section>
|
|
605
|
+
<section id="roadmap"><div class="section-head"><span class="section-index">06 / Next steps</span><div><h2>Refactoring shortlist</h2><p>A review order generated from the findings. Confirm each suggestion against the code and its external consumers.</p></div></div><div class="panel"><ol id="roadmap-list" class="list"></ol></div></section>
|
|
606
|
+
<footer class="footer">NKLattice \xB7 Self-contained offline report \xB7 No remote scripts or source upload</footer></main></div>
|
|
607
|
+
<script type="application/json" id="report-data">${data}</script><script>${script}</script></body></html>`;
|
|
269
608
|
}
|
|
270
609
|
|
|
271
610
|
// src/analysis/rules.ts
|
|
272
611
|
import { matchesGlob } from "path";
|
|
273
612
|
function evaluateRules(graph, config) {
|
|
274
|
-
const
|
|
613
|
+
const findings2 = [];
|
|
275
614
|
const outgoing = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly)]));
|
|
276
615
|
const incoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path && !edge.isTypeOnly)]));
|
|
277
616
|
const allIncoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path)]));
|
|
278
617
|
for (const cycle of findCycles(graph)) {
|
|
279
|
-
|
|
618
|
+
findings2.push({
|
|
280
619
|
ruleId: "module-cycle",
|
|
281
620
|
category: "architecture",
|
|
282
621
|
severity: "error",
|
|
@@ -293,7 +632,7 @@ function evaluateRules(graph, config) {
|
|
|
293
632
|
for (const imported of module.imports) {
|
|
294
633
|
if (imported.isTypeOnly) continue;
|
|
295
634
|
if (seen.has(imported.specifier)) {
|
|
296
|
-
|
|
635
|
+
findings2.push({
|
|
297
636
|
ruleId: "duplicate-import",
|
|
298
637
|
category: "imports",
|
|
299
638
|
severity: "warning",
|
|
@@ -306,7 +645,7 @@ function evaluateRules(graph, config) {
|
|
|
306
645
|
seen.add(imported.specifier);
|
|
307
646
|
}
|
|
308
647
|
if (out.length > config.thresholds.maxFanOut) {
|
|
309
|
-
|
|
648
|
+
findings2.push({
|
|
310
649
|
ruleId: "high-fan-out",
|
|
311
650
|
category: "architecture",
|
|
312
651
|
severity: "warning",
|
|
@@ -317,7 +656,7 @@ function evaluateRules(graph, config) {
|
|
|
317
656
|
});
|
|
318
657
|
}
|
|
319
658
|
if (into.length > config.thresholds.maxFanIn) {
|
|
320
|
-
|
|
659
|
+
findings2.push({
|
|
321
660
|
ruleId: "high-fan-in",
|
|
322
661
|
category: "architecture",
|
|
323
662
|
severity: "warning",
|
|
@@ -328,7 +667,7 @@ function evaluateRules(graph, config) {
|
|
|
328
667
|
});
|
|
329
668
|
}
|
|
330
669
|
if (into.length === 0 && out.length === 0 && graph.modules.length > 1 && !module.isRoute) {
|
|
331
|
-
|
|
670
|
+
findings2.push({
|
|
332
671
|
ruleId: "orphan-module",
|
|
333
672
|
category: "architecture",
|
|
334
673
|
severity: "info",
|
|
@@ -344,7 +683,7 @@ function evaluateRules(graph, config) {
|
|
|
344
683
|
for (const name of module.exports) {
|
|
345
684
|
if (name === "*" || module.typeExports.includes(name)) continue;
|
|
346
685
|
if (!usedNames.has(name)) {
|
|
347
|
-
|
|
686
|
+
findings2.push({
|
|
348
687
|
ruleId: "unused-export",
|
|
349
688
|
category: "imports",
|
|
350
689
|
severity: "info",
|
|
@@ -358,7 +697,7 @@ function evaluateRules(graph, config) {
|
|
|
358
697
|
}
|
|
359
698
|
}
|
|
360
699
|
if (module.componentCount > 0 && module.lines > config.thresholds.maxComponentLines) {
|
|
361
|
-
|
|
700
|
+
findings2.push({
|
|
362
701
|
ruleId: "large-component",
|
|
363
702
|
category: "components",
|
|
364
703
|
severity: "warning",
|
|
@@ -369,7 +708,7 @@ function evaluateRules(graph, config) {
|
|
|
369
708
|
});
|
|
370
709
|
}
|
|
371
710
|
if (module.jsxDepth > config.thresholds.maxJsxDepth) {
|
|
372
|
-
|
|
711
|
+
findings2.push({
|
|
373
712
|
ruleId: "deep-jsx",
|
|
374
713
|
category: "components",
|
|
375
714
|
severity: "warning",
|
|
@@ -380,7 +719,7 @@ function evaluateRules(graph, config) {
|
|
|
380
719
|
});
|
|
381
720
|
}
|
|
382
721
|
if (module.complexity > config.thresholds.maxComplexity) {
|
|
383
|
-
|
|
722
|
+
findings2.push({
|
|
384
723
|
ruleId: "high-complexity",
|
|
385
724
|
category: "complexity",
|
|
386
725
|
severity: "warning",
|
|
@@ -396,7 +735,7 @@ function evaluateRules(graph, config) {
|
|
|
396
735
|
const allowed = policy.allow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
397
736
|
const disallowed = policy.disallow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
398
737
|
if (policy.allow && !allowed || policy.disallow && disallowed) {
|
|
399
|
-
|
|
738
|
+
findings2.push({
|
|
400
739
|
ruleId: policy.id,
|
|
401
740
|
category: "architecture",
|
|
402
741
|
severity: policy.severity,
|
|
@@ -423,7 +762,7 @@ function evaluateRules(graph, config) {
|
|
|
423
762
|
return false;
|
|
424
763
|
};
|
|
425
764
|
if (visit(module.path, /* @__PURE__ */ new Set([module.path]), 0)) {
|
|
426
|
-
|
|
765
|
+
findings2.push({
|
|
427
766
|
ruleId: "deep-dependency-chain",
|
|
428
767
|
category: "architecture",
|
|
429
768
|
severity: "warning",
|
|
@@ -434,7 +773,7 @@ function evaluateRules(graph, config) {
|
|
|
434
773
|
});
|
|
435
774
|
}
|
|
436
775
|
}
|
|
437
|
-
return
|
|
776
|
+
return findings2.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
|
|
438
777
|
}
|
|
439
778
|
|
|
440
779
|
// src/analysis/scan.ts
|
|
@@ -522,6 +861,17 @@ function parseModule(path, contents) {
|
|
|
522
861
|
}
|
|
523
862
|
}
|
|
524
863
|
function visit(node, depth) {
|
|
864
|
+
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
|
|
865
|
+
const argument = node.arguments[0];
|
|
866
|
+
if (argument && ts.isStringLiteralLike(argument)) {
|
|
867
|
+
imports.push({
|
|
868
|
+
specifier: argument.text,
|
|
869
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
870
|
+
importedNames: ["*"],
|
|
871
|
+
isTypeOnly: false
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
}
|
|
525
875
|
const isJsx = ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
|
|
526
876
|
const nextDepth = depth + (isJsx ? 1 : 0);
|
|
527
877
|
jsxDepth = Math.max(jsxDepth, nextDepth);
|
|
@@ -544,56 +894,203 @@ function parseModule(path, contents) {
|
|
|
544
894
|
isRoute: /(^|\/)(app|pages)\/.+(page|layout|route|index)\.[cm]?[jt]sx?$/.test(path) || /(^|\/)app\/(page|layout|route)\.[cm]?[jt]sx?$/.test(path)
|
|
545
895
|
};
|
|
546
896
|
}
|
|
897
|
+
function moduleParseDiagnostics(path, contents) {
|
|
898
|
+
const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
|
|
899
|
+
const diagnostics = source.parseDiagnostics ?? [];
|
|
900
|
+
const messages = diagnostics.map((diagnostic) => {
|
|
901
|
+
const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1;
|
|
902
|
+
return `${path}:${line}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`;
|
|
903
|
+
});
|
|
904
|
+
function visit(node) {
|
|
905
|
+
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
|
|
906
|
+
const argument = node.arguments[0];
|
|
907
|
+
if (!argument || !ts.isStringLiteralLike(argument)) {
|
|
908
|
+
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
909
|
+
messages.push(`${path}:${line}: nonliteral dynamic import or require cannot be analyzed`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
ts.forEachChild(node, visit);
|
|
913
|
+
}
|
|
914
|
+
visit(source);
|
|
915
|
+
return messages;
|
|
916
|
+
}
|
|
547
917
|
|
|
548
918
|
// src/analysis/resolve.ts
|
|
549
|
-
import { readFile as readFile2 } from "fs/promises";
|
|
550
|
-
import { join as join3, relative, resolve, sep } from "path";
|
|
919
|
+
import { readFile as readFile2, stat } from "fs/promises";
|
|
920
|
+
import { dirname, extname, join as join3, relative, resolve, sep } from "path";
|
|
551
921
|
import ts2 from "typescript";
|
|
552
922
|
async function compilerOptions(root) {
|
|
553
923
|
const path = join3(root, "tsconfig.json");
|
|
554
924
|
let content;
|
|
555
925
|
try {
|
|
556
926
|
content = await readFile2(path, "utf8");
|
|
557
|
-
} catch {
|
|
558
|
-
return {};
|
|
927
|
+
} catch (error) {
|
|
928
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return { options: {}, diagnostics: [] };
|
|
929
|
+
throw error;
|
|
930
|
+
}
|
|
931
|
+
try {
|
|
932
|
+
const json2 = ts2.parseConfigFileTextToJson(path, content);
|
|
933
|
+
if (json2.error) return { options: {}, diagnostics: [`tsconfig.json: ${ts2.flattenDiagnosticMessageText(json2.error.messageText, " ")}`] };
|
|
934
|
+
const parsed = ts2.parseJsonConfigFileContent(json2.config, ts2.sys, root);
|
|
935
|
+
return { options: parsed.options, diagnostics: parsed.errors.map((error) => `tsconfig.json: ${ts2.flattenDiagnosticMessageText(error.messageText, " ")}`) };
|
|
936
|
+
} catch (error) {
|
|
937
|
+
return { options: {}, diagnostics: [`tsconfig.json: ${error instanceof Error ? error.message : String(error)}`] };
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
function propertyName(name) {
|
|
941
|
+
if (ts2.isIdentifier(name) || ts2.isStringLiteral(name)) return name.text;
|
|
942
|
+
return void 0;
|
|
943
|
+
}
|
|
944
|
+
function staticAliasTarget(node) {
|
|
945
|
+
if (ts2.isStringLiteralLike(node)) return node.text;
|
|
946
|
+
if (ts2.isCallExpression(node) || ts2.isNewExpression(node)) {
|
|
947
|
+
for (const argument of node.arguments ?? []) {
|
|
948
|
+
const found = staticAliasTarget(argument);
|
|
949
|
+
if (found?.startsWith("./")) return found;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return void 0;
|
|
953
|
+
}
|
|
954
|
+
async function viteAliasDiagnostics(root, options, aliases) {
|
|
955
|
+
const diagnostics = [];
|
|
956
|
+
for (const file of ["vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs", "vite.config.cts", "vite.config.cjs"]) {
|
|
957
|
+
let visit2 = function(node) {
|
|
958
|
+
if (ts2.isPropertyAssignment(node) && propertyName(node.name) === "resolve") {
|
|
959
|
+
if (!ts2.isObjectLiteralExpression(node.initializer)) {
|
|
960
|
+
diagnostics.push(`${file}: dynamic Vite resolve configuration cannot be verified`);
|
|
961
|
+
ts2.forEachChild(node, visit2);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
for (const property of node.initializer.properties) {
|
|
965
|
+
if (!ts2.isPropertyAssignment(property) || !propertyName(property.name)) {
|
|
966
|
+
diagnostics.push(`${file}: dynamic Vite resolve entry cannot be verified`);
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
if (propertyName(property.name) !== "alias") continue;
|
|
970
|
+
if (!ts2.isObjectLiteralExpression(property.initializer)) {
|
|
971
|
+
diagnostics.push(`${file}: dynamic Vite alias configuration cannot be verified`);
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
for (const alias of property.initializer.properties) {
|
|
975
|
+
if (!ts2.isPropertyAssignment(alias) || !propertyName(alias.name)) {
|
|
976
|
+
diagnostics.push(`${file}: dynamic Vite alias entry cannot be verified`);
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
const key = propertyName(alias.name);
|
|
980
|
+
const prefix = key.endsWith("/") ? key : `${key}/`;
|
|
981
|
+
const centralTarget = aliases[prefix];
|
|
982
|
+
const tsTarget = options.paths?.[`${key}/*`]?.[0];
|
|
983
|
+
const mapped = centralTarget ?? (tsTarget?.endsWith("/*") ? tsTarget.slice(0, -1).replace(/^\.\//, "") : void 0);
|
|
984
|
+
if (!mapped) {
|
|
985
|
+
diagnostics.push(`${file}: Vite alias ${key} needs a central analysis.aliases or matching tsconfig paths mapping`);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
const target = staticAliasTarget(alias.initializer);
|
|
989
|
+
if (!target?.startsWith("./")) {
|
|
990
|
+
diagnostics.push(`${file}: Vite alias ${key} target cannot be verified statically`);
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
const expected = `${target.slice(2).replace(/\/$/, "")}/`;
|
|
994
|
+
if (mapped !== expected) diagnostics.push(`${file}: Vite alias ${key} maps to ${expected}, but assessment uses ${mapped}`);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
ts2.forEachChild(node, visit2);
|
|
999
|
+
};
|
|
1000
|
+
var visit = visit2;
|
|
1001
|
+
let sourceText;
|
|
1002
|
+
try {
|
|
1003
|
+
sourceText = await readFile2(join3(root, file), "utf8");
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue;
|
|
1006
|
+
throw error;
|
|
1007
|
+
}
|
|
1008
|
+
const source = ts2.createSourceFile(file, sourceText, ts2.ScriptTarget.Latest, true);
|
|
1009
|
+
const defaultExport = source.statements.find((statement) => ts2.isExportAssignment(statement) && !statement.isExportEquals);
|
|
1010
|
+
if (!defaultExport || !ts2.isExportAssignment(defaultExport)) {
|
|
1011
|
+
diagnostics.push(`${file}: Vite configuration export cannot be verified statically`);
|
|
1012
|
+
} else {
|
|
1013
|
+
const expression = defaultExport.expression;
|
|
1014
|
+
const config = ts2.isCallExpression(expression) && expression.expression.getText(source) === "defineConfig" ? expression.arguments[0] : expression;
|
|
1015
|
+
if (!config || !ts2.isObjectLiteralExpression(config)) {
|
|
1016
|
+
diagnostics.push(`${file}: Vite configuration export cannot be verified statically`);
|
|
1017
|
+
} else if (config.properties.some((property) => !ts2.isPropertyAssignment(property) || !propertyName(property.name))) {
|
|
1018
|
+
diagnostics.push(`${file}: dynamic Vite configuration entry cannot be verified`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
visit2(source);
|
|
559
1022
|
}
|
|
560
|
-
|
|
561
|
-
if (json.error) return {};
|
|
562
|
-
const parsed = ts2.parseJsonConfigFileContent(json.config, ts2.sys, root);
|
|
563
|
-
return parsed.options;
|
|
1023
|
+
return diagnostics;
|
|
564
1024
|
}
|
|
565
|
-
async function
|
|
566
|
-
|
|
1025
|
+
async function existingNonSourceAsset(path) {
|
|
1026
|
+
if (/\.[cm]?[jt]sx?$/.test(extname(path)) || path.endsWith(".d.ts")) return false;
|
|
1027
|
+
return (await stat(path).catch(() => null))?.isFile() === true;
|
|
1028
|
+
}
|
|
1029
|
+
async function resolveImportsDetailed(root, modules, aliases = {}) {
|
|
1030
|
+
const { options, diagnostics } = await compilerOptions(root);
|
|
1031
|
+
diagnostics.push(...await viteAliasDiagnostics(root, options, aliases));
|
|
567
1032
|
const files = new Set(modules.map((module) => module.path));
|
|
568
1033
|
for (const module of modules) {
|
|
569
1034
|
const from = join3(root, module.path);
|
|
570
1035
|
for (const imported of module.imports) {
|
|
571
|
-
const
|
|
572
|
-
|
|
1036
|
+
const specifier = imported.specifier.split("?")[0];
|
|
1037
|
+
const matchingAlias = Object.keys(aliases).sort((a, b) => b.length - a.length).find((prefix) => specifier.startsWith(prefix));
|
|
1038
|
+
const aliasTarget = matchingAlias ? resolve(root, aliases[matchingAlias], specifier.slice(matchingAlias.length)) : void 0;
|
|
1039
|
+
const candidate = aliasTarget ? relative(dirname(from), aliasTarget).replaceAll("\\", "/") : specifier;
|
|
1040
|
+
const lookup = aliasTarget && !candidate.startsWith(".") ? `./${candidate}` : candidate;
|
|
1041
|
+
const result = ts2.resolveModuleName(lookup, from, options, ts2.sys).resolvedModule;
|
|
1042
|
+
const localAlias = Object.keys(options.paths ?? {}).some((pattern) => {
|
|
1043
|
+
const [prefix, suffix = ""] = pattern.split("*");
|
|
1044
|
+
return specifier.startsWith(prefix) && specifier.endsWith(suffix);
|
|
1045
|
+
});
|
|
1046
|
+
const isLocal = specifier.startsWith(".") || localAlias || aliasTarget !== void 0 || /^[~@#]\//.test(specifier);
|
|
1047
|
+
if (!result) {
|
|
1048
|
+
const assetPath = aliasTarget ?? (specifier.startsWith(".") ? resolve(dirname(from), specifier) : void 0);
|
|
1049
|
+
const assetRelative = assetPath ? relative(root, assetPath) : void 0;
|
|
1050
|
+
if (assetRelative && (assetRelative === ".." || assetRelative.startsWith(`..${sep}`))) {
|
|
1051
|
+
diagnostics.push(`${module.path}:${imported.line}: local import escapes app root ${imported.specifier}`);
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
if (assetPath && await existingNonSourceAsset(assetPath)) continue;
|
|
1055
|
+
if (isLocal) diagnostics.push(`${module.path}:${imported.line}: unresolved local import ${imported.specifier}`);
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
573
1058
|
const path = relative(root, resolve(result.resolvedFileName));
|
|
574
|
-
if (path === ".." || path.startsWith(`..${sep}`))
|
|
1059
|
+
if (path === ".." || path.startsWith(`..${sep}`)) {
|
|
1060
|
+
if (isLocal) diagnostics.push(`${module.path}:${imported.line}: local import escapes app root ${imported.specifier}`);
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
575
1063
|
const normalized = path.replaceAll("\\", "/");
|
|
576
1064
|
if (files.has(normalized)) imported.resolved = normalized;
|
|
1065
|
+
else if (isLocal && !normalized.endsWith(".d.ts")) diagnostics.push(`${module.path}:${imported.line}: local import outside analyzed source ${imported.specifier}`);
|
|
577
1066
|
}
|
|
578
1067
|
}
|
|
1068
|
+
return diagnostics;
|
|
579
1069
|
}
|
|
580
1070
|
|
|
581
1071
|
// src/analysis/scan.ts
|
|
582
|
-
async function scanProject(projectRoot) {
|
|
1072
|
+
async function scanProject(projectRoot, aliases = {}) {
|
|
1073
|
+
return (await scanProjectDetailed(projectRoot, aliases)).modules;
|
|
1074
|
+
}
|
|
1075
|
+
async function scanProjectDetailed(projectRoot, aliases = {}) {
|
|
583
1076
|
const root = resolve2(projectRoot);
|
|
584
1077
|
const files = await fastGlob("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
|
|
585
1078
|
cwd: root,
|
|
586
1079
|
onlyFiles: true,
|
|
587
1080
|
unique: true,
|
|
1081
|
+
dot: true,
|
|
588
1082
|
followSymbolicLinks: false,
|
|
589
|
-
ignore: ["**/node_modules/**", "**/.git/**", "**/.next/**", "**/dist/**", "**/coverage/**", "**/*.d.ts"]
|
|
1083
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/.next/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**", "**/*.d.ts"]
|
|
590
1084
|
});
|
|
591
|
-
const
|
|
592
|
-
path.replaceAll("\\", "/")
|
|
593
|
-
await readFile3(join4(root, path), "utf8")
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
1085
|
+
const parsed = await Promise.all(files.sort().map(async (path) => {
|
|
1086
|
+
const normalized = path.replaceAll("\\", "/");
|
|
1087
|
+
const contents = await readFile3(join4(root, path), "utf8");
|
|
1088
|
+
return { module: parseModule(normalized, contents), diagnostics: moduleParseDiagnostics(normalized, contents) };
|
|
1089
|
+
}));
|
|
1090
|
+
const modules = parsed.map((item) => item.module);
|
|
1091
|
+
const diagnostics = parsed.flatMap((item) => item.diagnostics);
|
|
1092
|
+
diagnostics.push(...await resolveImportsDetailed(root, modules, aliases));
|
|
1093
|
+
return { modules, diagnostics };
|
|
597
1094
|
}
|
|
598
1095
|
|
|
599
1096
|
// src/analysis/score.ts
|
|
@@ -608,10 +1105,10 @@ function grade(score) {
|
|
|
608
1105
|
if (score >= 55) return "D";
|
|
609
1106
|
return "F";
|
|
610
1107
|
}
|
|
611
|
-
function scoreFindings(
|
|
1108
|
+
function scoreFindings(findings2, config) {
|
|
612
1109
|
const penalties = { error: 18, warning: 7, info: 2 };
|
|
613
1110
|
const categories = Object.entries(config.weights).map(([id, weight]) => {
|
|
614
|
-
const categoryFindings =
|
|
1111
|
+
const categoryFindings = findings2.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
|
|
615
1112
|
const occurrences = /* @__PURE__ */ new Map();
|
|
616
1113
|
let deduction = 0;
|
|
617
1114
|
for (const finding of categoryFindings) {
|
|
@@ -623,8 +1120,8 @@ function scoreFindings(findings, config) {
|
|
|
623
1120
|
return { id, weight, score: round(100 - deduction), deduction: round(deduction), findingCount: categoryFindings.length };
|
|
624
1121
|
});
|
|
625
1122
|
const weightedDeduction = round(categories.reduce((sum, category) => sum + category.deduction * category.weight / 100, 0));
|
|
626
|
-
const maintenanceAdjustment = round(Math.min(5, Math.max(0,
|
|
627
|
-
const criticalAdjustment = round(Math.min(10,
|
|
1123
|
+
const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings2.length - 10) * 0.15));
|
|
1124
|
+
const criticalAdjustment = round(Math.min(10, findings2.filter((finding) => finding.severity === "error").length * 1.5));
|
|
628
1125
|
const score = round(Math.max(0, Math.min(100, 100 - weightedDeduction - maintenanceAdjustment - criticalAdjustment)));
|
|
629
1126
|
return { score, grade: grade(score), weightedDeduction, maintenanceAdjustment, criticalAdjustment, categories };
|
|
630
1127
|
}
|
|
@@ -633,10 +1130,10 @@ function scoreFindings(findings, config) {
|
|
|
633
1130
|
async function analyzeProject(projectRoot, options = {}) {
|
|
634
1131
|
const root = resolve3(projectRoot);
|
|
635
1132
|
const config = await loadAnalysisConfig(root);
|
|
636
|
-
const modules = await scanProject(root);
|
|
1133
|
+
const modules = await scanProject(root, config.aliases);
|
|
637
1134
|
if (modules.length === 0) throw new Error("No supported source files found");
|
|
638
1135
|
const graph = buildGraph(modules);
|
|
639
|
-
const
|
|
1136
|
+
const findings2 = evaluateRules(graph, config);
|
|
640
1137
|
let name = basename(root);
|
|
641
1138
|
try {
|
|
642
1139
|
const manifest = JSON.parse(await readFile4(join5(root, "package.json"), "utf8"));
|
|
@@ -648,9 +1145,9 @@ async function analyzeProject(projectRoot, options = {}) {
|
|
|
648
1145
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
649
1146
|
project: { name },
|
|
650
1147
|
graph,
|
|
651
|
-
findings,
|
|
652
|
-
score: scoreFindings(
|
|
653
|
-
actions: recommendActions(
|
|
1148
|
+
findings: findings2,
|
|
1149
|
+
score: scoreFindings(findings2, config),
|
|
1150
|
+
actions: recommendActions(findings2)
|
|
654
1151
|
};
|
|
655
1152
|
const outputDirectory = resolve3(options.outputDirectory ?? join5(root, ".nklattice-report"));
|
|
656
1153
|
await mkdir2(outputDirectory, { recursive: true });
|
|
@@ -679,12 +1176,12 @@ function dependencyMap(manifest) {
|
|
|
679
1176
|
const entries = manifest[section2];
|
|
680
1177
|
if (entries === void 0) continue;
|
|
681
1178
|
if (!isRecord(entries)) throw new Error(`Invalid ${section2} in package.json`);
|
|
682
|
-
for (const [name,
|
|
683
|
-
if (typeof
|
|
684
|
-
if (result[name] !== void 0 && result[name] !==
|
|
1179
|
+
for (const [name, version2] of Object.entries(entries)) {
|
|
1180
|
+
if (typeof version2 !== "string") throw new Error(`Invalid version for ${name}`);
|
|
1181
|
+
if (result[name] !== void 0 && result[name] !== version2) {
|
|
685
1182
|
throw new Error(`Conflicting declarations for ${name}`);
|
|
686
1183
|
}
|
|
687
|
-
result[name] =
|
|
1184
|
+
result[name] = version2;
|
|
688
1185
|
}
|
|
689
1186
|
}
|
|
690
1187
|
return result;
|
|
@@ -707,8 +1204,9 @@ async function discoverApps(scanRoot, workspaceRoot) {
|
|
|
707
1204
|
cwd: scanRoot,
|
|
708
1205
|
onlyFiles: true,
|
|
709
1206
|
unique: true,
|
|
1207
|
+
dot: true,
|
|
710
1208
|
followSymbolicLinks: false,
|
|
711
|
-
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/.next/**", "**/coverage/**"]
|
|
1209
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/out/**", "**/.next/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**"]
|
|
712
1210
|
});
|
|
713
1211
|
const apps = [];
|
|
714
1212
|
for (const path of manifests.sort()) {
|
|
@@ -735,7 +1233,7 @@ async function discoverApps(scanRoot, workspaceRoot) {
|
|
|
735
1233
|
|
|
736
1234
|
// src/governance/lockfiles.ts
|
|
737
1235
|
import { access as access2, readFile as readFile6 } from "fs/promises";
|
|
738
|
-
import { dirname, join as join7, relative as relative3, resolve as resolve4, sep as sep2 } from "path";
|
|
1236
|
+
import { dirname as dirname2, join as join7, relative as relative3, resolve as resolve4, sep as sep2 } from "path";
|
|
739
1237
|
import YAML from "yaml";
|
|
740
1238
|
function record2(value) {
|
|
741
1239
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -764,7 +1262,7 @@ async function findLockfile(appDirectory, workspaceRoot) {
|
|
|
764
1262
|
if (hasNpm) return { path: npm, kind: "npm", root: directory };
|
|
765
1263
|
if (hasPnpm) return { path: pnpm, kind: "pnpm", root: directory };
|
|
766
1264
|
if (directory === boundary) break;
|
|
767
|
-
directory =
|
|
1265
|
+
directory = dirname2(directory);
|
|
768
1266
|
}
|
|
769
1267
|
throw new Error(`No lockfile found for ${appDirectory}`);
|
|
770
1268
|
}
|
|
@@ -826,17 +1324,17 @@ function readPnpm(raw, root, app, name) {
|
|
|
826
1324
|
if (!record2(entry) || typeof entry.specifier !== "string" || typeof entry.version !== "string") {
|
|
827
1325
|
throw new Error(`Malformed pnpm lockfile dependency for ${name}`);
|
|
828
1326
|
}
|
|
829
|
-
const
|
|
830
|
-
if (!
|
|
831
|
-
const packageKeys = [`${name}@${
|
|
1327
|
+
const version2 = entry.version.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1];
|
|
1328
|
+
if (!version2) throw new Error(`Unsupported pnpm resolution for ${name}`);
|
|
1329
|
+
const packageKeys = [`${name}@${version2}`, `/${name}@${version2}`];
|
|
832
1330
|
if (!packageKeys.some((key) => record2(lock.packages) && Object.hasOwn(lock.packages, key))) {
|
|
833
|
-
throw new Error(`Missing pnpm package entry for ${name}@${
|
|
1331
|
+
throw new Error(`Missing pnpm package entry for ${name}@${version2}`);
|
|
834
1332
|
}
|
|
835
1333
|
const snapshotKeys = [`${name}@${entry.version}`, `/${name}@${entry.version}`, ...packageKeys];
|
|
836
1334
|
if (!snapshotKeys.some((key) => Object.hasOwn(lock.snapshots, key))) {
|
|
837
1335
|
throw new Error(`Missing pnpm snapshot for ${name}@${entry.version}`);
|
|
838
1336
|
}
|
|
839
|
-
return { specifier: entry.specifier, version };
|
|
1337
|
+
return { specifier: entry.specifier, version: version2 };
|
|
840
1338
|
}
|
|
841
1339
|
async function readResolution(app, workspaceRoot, name) {
|
|
842
1340
|
const lockfile = await findLockfile(app.directory, workspaceRoot);
|
|
@@ -845,6 +1343,39 @@ async function readResolution(app, workspaceRoot, name) {
|
|
|
845
1343
|
}
|
|
846
1344
|
|
|
847
1345
|
// src/governance/check.ts
|
|
1346
|
+
async function checkApp(app, workspaceRoot, policy) {
|
|
1347
|
+
const requirements = policy.profiles[app.profile];
|
|
1348
|
+
if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
|
|
1349
|
+
const findings2 = [];
|
|
1350
|
+
for (const [packageName, expected] of Object.entries(requirements)) {
|
|
1351
|
+
const declared = app.dependencies[packageName];
|
|
1352
|
+
const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
|
|
1353
|
+
const reasons = [];
|
|
1354
|
+
const declaredRange = declared === void 0 ? null : validRange(declared);
|
|
1355
|
+
if (declared === void 0) reasons.push("required manifest declaration missing");
|
|
1356
|
+
else if (declaredRange === null) reasons.push("unsupported manifest range");
|
|
1357
|
+
else if (!subset(declaredRange, expected)) reasons.push("manifest range allows versions below minimum");
|
|
1358
|
+
if (resolution.specifier !== declared) reasons.push("lockfile specifier differs");
|
|
1359
|
+
if (resolution.version === void 0) reasons.push("lockfile resolution missing");
|
|
1360
|
+
else if (!valid(resolution.version)) reasons.push("invalid lockfile resolution");
|
|
1361
|
+
else {
|
|
1362
|
+
if (!satisfies(resolution.version, expected)) reasons.push("lockfile resolution does not satisfy minimum");
|
|
1363
|
+
if (declaredRange !== null && !satisfies(resolution.version, declaredRange)) {
|
|
1364
|
+
reasons.push("lockfile resolution outside manifest range");
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
if (reasons.length) findings2.push({
|
|
1368
|
+
app: app.relativePath,
|
|
1369
|
+
packageName,
|
|
1370
|
+
expected,
|
|
1371
|
+
declared,
|
|
1372
|
+
lockfileSpecifier: resolution.specifier,
|
|
1373
|
+
locked: resolution.version,
|
|
1374
|
+
reason: reasons.join("; ")
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
return findings2;
|
|
1378
|
+
}
|
|
848
1379
|
async function checkGovernance(options) {
|
|
849
1380
|
const scanRoot = resolve5(options.scanRoot);
|
|
850
1381
|
const workspaceRoot = resolve5(options.workspaceRoot ?? scanRoot);
|
|
@@ -854,59 +1385,446 @@ async function checkGovernance(options) {
|
|
|
854
1385
|
}
|
|
855
1386
|
const apps = await discoverApps(scanRoot, workspaceRoot);
|
|
856
1387
|
if (apps.length === 0) throw new Error("No managed applications found");
|
|
857
|
-
const
|
|
858
|
-
for (const app of apps) {
|
|
859
|
-
const requirements = options.policy.profiles[app.profile];
|
|
860
|
-
if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
|
|
861
|
-
for (const [packageName, expected] of Object.entries(requirements)) {
|
|
862
|
-
const declared = app.dependencies[packageName];
|
|
863
|
-
const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
|
|
864
|
-
const reasons = [];
|
|
865
|
-
const declaredRange = declared === void 0 ? null : validRange(declared);
|
|
866
|
-
if (declared === void 0) reasons.push("required manifest declaration missing");
|
|
867
|
-
else if (declaredRange === null) reasons.push("unsupported manifest range");
|
|
868
|
-
else if (!subset(declaredRange, expected)) reasons.push("manifest range allows versions below minimum");
|
|
869
|
-
if (resolution.specifier !== declared) reasons.push("lockfile specifier differs");
|
|
870
|
-
if (resolution.version === void 0) reasons.push("lockfile resolution missing");
|
|
871
|
-
else if (!valid(resolution.version)) reasons.push("invalid lockfile resolution");
|
|
872
|
-
else {
|
|
873
|
-
if (!satisfies(resolution.version, expected)) reasons.push("lockfile resolution does not satisfy minimum");
|
|
874
|
-
if (declaredRange !== null && !satisfies(resolution.version, declaredRange)) {
|
|
875
|
-
reasons.push("lockfile resolution outside manifest range");
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
if (reasons.length) {
|
|
879
|
-
findings.push({
|
|
880
|
-
app: app.relativePath,
|
|
881
|
-
packageName,
|
|
882
|
-
expected,
|
|
883
|
-
declared,
|
|
884
|
-
lockfileSpecifier: resolution.specifier,
|
|
885
|
-
locked: resolution.version,
|
|
886
|
-
reason: reasons.join("; ")
|
|
887
|
-
});
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
}
|
|
1388
|
+
const findings2 = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
|
|
891
1389
|
return {
|
|
892
1390
|
appsChecked: apps.length,
|
|
893
|
-
findings,
|
|
1391
|
+
findings: findings2,
|
|
894
1392
|
policyVersion: options.policy.policyVersion,
|
|
895
1393
|
policySource: options.policy.source,
|
|
896
1394
|
policySha256: options.policy.sha256
|
|
897
1395
|
};
|
|
898
1396
|
}
|
|
899
1397
|
|
|
900
|
-
// src/governance/
|
|
1398
|
+
// src/governance/assess.ts
|
|
1399
|
+
import { mkdir as mkdir3, writeFile as writeFile2 } from "fs/promises";
|
|
1400
|
+
import { basename as basename2, join as join10, relative as relative7, resolve as resolve9, sep as sep6 } from "path";
|
|
1401
|
+
|
|
1402
|
+
// src/governance/inventory.ts
|
|
901
1403
|
import { createHash } from "crypto";
|
|
902
|
-
import { readFile as readFile7 } from "fs/promises";
|
|
903
|
-
import { resolve as resolve6 } from "path";
|
|
1404
|
+
import { readFile as readFile7, realpath } from "fs/promises";
|
|
1405
|
+
import { isAbsolute, join as join8, relative as relative5, resolve as resolve6, sep as sep4 } from "path";
|
|
1406
|
+
var profiles2 = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
|
|
1407
|
+
async function loadInventory(source, scanRoot) {
|
|
1408
|
+
const absolute = resolve6(source);
|
|
1409
|
+
const root = await realpath(scanRoot);
|
|
1410
|
+
const inventoryPath = await realpath(absolute);
|
|
1411
|
+
const inventoryRelative = relative5(root, inventoryPath);
|
|
1412
|
+
if (inventoryRelative === ".." || inventoryRelative.startsWith(`..${sep4}`)) throw new Error("Inventory must be inside scan root");
|
|
1413
|
+
const raw = await readFile7(inventoryPath, "utf8");
|
|
1414
|
+
let value;
|
|
1415
|
+
try {
|
|
1416
|
+
value = JSON.parse(raw);
|
|
1417
|
+
} catch {
|
|
1418
|
+
throw new Error("Invalid app inventory JSON");
|
|
1419
|
+
}
|
|
1420
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Invalid app inventory object");
|
|
1421
|
+
const parsed = value;
|
|
1422
|
+
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.apps) || parsed.apps.length === 0) {
|
|
1423
|
+
throw new Error("Inventory schemaVersion 1 and nonempty apps required");
|
|
1424
|
+
}
|
|
1425
|
+
const apps = [];
|
|
1426
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1427
|
+
for (const entry of parsed.apps) {
|
|
1428
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error("Invalid app inventory entry");
|
|
1429
|
+
const app = entry;
|
|
1430
|
+
const path = app.path;
|
|
1431
|
+
if (typeof path !== "string" || path !== "." && (path === "" || path.startsWith("./") || path.includes("\\") || path.split("/").some((segment) => !segment || segment === "." || segment === "..")) || isAbsolute(path)) {
|
|
1432
|
+
throw new Error(`Invalid app inventory path ${String(path)}`);
|
|
1433
|
+
}
|
|
1434
|
+
if (typeof app.profile !== "string" || !profiles2.has(app.profile)) throw new Error(`Invalid profile for ${path}`);
|
|
1435
|
+
if (seen.has(path)) throw new Error(`Duplicate inventory app ${path}`);
|
|
1436
|
+
seen.add(path);
|
|
1437
|
+
try {
|
|
1438
|
+
const actual = await realpath(join8(root, path));
|
|
1439
|
+
const rel = relative5(root, actual);
|
|
1440
|
+
if (rel === ".." || rel.startsWith(`..${sep4}`)) throw new Error(`App path escapes scan root: ${path}`);
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
if (error instanceof Error && error.message.includes("escapes scan root")) throw error;
|
|
1443
|
+
}
|
|
1444
|
+
apps.push({ path, profile: app.profile });
|
|
1445
|
+
}
|
|
1446
|
+
return { source: absolute, sha256: createHash("sha256").update(raw).digest("hex"), apps };
|
|
1447
|
+
}
|
|
1448
|
+
function compareInventory(inventory, discovered, scanRoot) {
|
|
1449
|
+
const expected = new Map(inventory.apps.map((app) => [app.path, app.profile]));
|
|
1450
|
+
const actual = new Map(discovered.map((app) => [relative5(scanRoot, app.directory).replaceAll("\\", "/") || ".", app.profile]));
|
|
1451
|
+
const issues = [];
|
|
1452
|
+
for (const [path, profile] of expected) {
|
|
1453
|
+
if (!actual.has(path)) issues.push(`Expected app ${path} was not discovered`);
|
|
1454
|
+
else if (actual.get(path) !== profile) issues.push(`App ${path} has profile ${actual.get(path)}; inventory requires ${profile}`);
|
|
1455
|
+
}
|
|
1456
|
+
for (const path of actual.keys()) if (!expected.has(path)) issues.push(`Discovered app ${path} is absent from inventory`);
|
|
1457
|
+
return issues;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// src/governance/evidence.ts
|
|
1461
|
+
import { createHash as createHash2 } from "crypto";
|
|
1462
|
+
import { execFile } from "child_process";
|
|
1463
|
+
import { createReadStream } from "fs";
|
|
1464
|
+
import { lstat, readFile as readFile8, realpath as realpath2 } from "fs/promises";
|
|
1465
|
+
import { dirname as dirname3, join as join9, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
|
|
1466
|
+
import { promisify } from "util";
|
|
1467
|
+
import fg from "fast-glob";
|
|
1468
|
+
var execFileAsync = promisify(execFile);
|
|
1469
|
+
function object(value) {
|
|
1470
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1471
|
+
}
|
|
1472
|
+
function string(value) {
|
|
1473
|
+
return typeof value === "string" && value.length > 0;
|
|
1474
|
+
}
|
|
1475
|
+
function nonnegative(value) {
|
|
1476
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
1477
|
+
}
|
|
1478
|
+
function errors(value) {
|
|
1479
|
+
return Array.isArray(value) && value.every(string);
|
|
1480
|
+
}
|
|
1481
|
+
function findings(value) {
|
|
1482
|
+
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)));
|
|
1483
|
+
}
|
|
1484
|
+
function base(value) {
|
|
1485
|
+
return object(value) && string(value.tool) && string(value.version) && typeof value.complete === "boolean" && errors(value.errors);
|
|
1486
|
+
}
|
|
1487
|
+
function validateApp(value) {
|
|
1488
|
+
if (!object(value) || !string(value.path) || !string(value.profile)) return false;
|
|
1489
|
+
const dependency = value.dependencySecurity;
|
|
1490
|
+
const quality = value.codeQuality;
|
|
1491
|
+
const security = value.codeSecurity;
|
|
1492
|
+
const compatibility2 = value.compatibility;
|
|
1493
|
+
if (!base(dependency) || !nonnegative(dependency.packagesScanned) || !findings(dependency.advisories)) return false;
|
|
1494
|
+
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;
|
|
1495
|
+
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;
|
|
1496
|
+
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;
|
|
1497
|
+
if (!base(compatibility2) || !Array.isArray(compatibility2.projects) || !compatibility2.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;
|
|
1498
|
+
return true;
|
|
1499
|
+
}
|
|
1500
|
+
async function hashFile(hash, path) {
|
|
1501
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
1502
|
+
}
|
|
1503
|
+
async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
|
|
1504
|
+
const root = resolve7(scanRoot);
|
|
1505
|
+
const workspace = resolve7(workspaceRoot);
|
|
1506
|
+
const scanRelative = relative6(workspace, root);
|
|
1507
|
+
if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
|
|
1508
|
+
const paths = await fg("**/*", {
|
|
1509
|
+
cwd: root,
|
|
1510
|
+
dot: true,
|
|
1511
|
+
onlyFiles: true,
|
|
1512
|
+
followSymbolicLinks: false,
|
|
1513
|
+
ignore: [
|
|
1514
|
+
"**/.git/**",
|
|
1515
|
+
"**/node_modules/**",
|
|
1516
|
+
"**/dist/**",
|
|
1517
|
+
"**/build/**",
|
|
1518
|
+
"**/.next/**",
|
|
1519
|
+
"**/coverage/**",
|
|
1520
|
+
"**/playwright-report/**",
|
|
1521
|
+
"**/test-results/**",
|
|
1522
|
+
"**/.nklattice-governance/**",
|
|
1523
|
+
"**/.nklattice-report/**",
|
|
1524
|
+
"**/AGENTS.md",
|
|
1525
|
+
"**/*.tsbuildinfo",
|
|
1526
|
+
"**/next-env.d.ts"
|
|
1527
|
+
]
|
|
1528
|
+
});
|
|
1529
|
+
paths.sort();
|
|
1530
|
+
const hash = createHash2("sha256");
|
|
1531
|
+
for (const path of paths) {
|
|
1532
|
+
const absolute = resolve7(root, path);
|
|
1533
|
+
const normalized = relative6(root, absolute);
|
|
1534
|
+
if (normalized === ".." || normalized.startsWith(`..${sep5}`)) throw new Error("Input path escapes scan root");
|
|
1535
|
+
if (!(await lstat(absolute)).isFile()) continue;
|
|
1536
|
+
hash.update(path.replaceAll("\\", "/"));
|
|
1537
|
+
hash.update("\0");
|
|
1538
|
+
await hashFile(hash, absolute);
|
|
1539
|
+
hash.update("\0");
|
|
1540
|
+
}
|
|
1541
|
+
let ancestor = root;
|
|
1542
|
+
while (ancestor === workspace || ancestor !== dirname3(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
|
|
1543
|
+
for (const name of [
|
|
1544
|
+
"package.json",
|
|
1545
|
+
"package-lock.json",
|
|
1546
|
+
"pnpm-lock.yaml",
|
|
1547
|
+
"pnpm-workspace.yaml",
|
|
1548
|
+
"tsconfig.json",
|
|
1549
|
+
"eslint.config.js",
|
|
1550
|
+
"eslint.config.mjs",
|
|
1551
|
+
"eslint.config.cjs",
|
|
1552
|
+
"eslint.config.ts",
|
|
1553
|
+
"eslint.config.mts",
|
|
1554
|
+
"vitest.config.js",
|
|
1555
|
+
"vitest.config.mjs",
|
|
1556
|
+
"vitest.config.ts",
|
|
1557
|
+
"vitest.config.mts",
|
|
1558
|
+
"playwright.config.js",
|
|
1559
|
+
"playwright.config.mjs",
|
|
1560
|
+
"playwright.config.ts",
|
|
1561
|
+
"playwright.config.mts",
|
|
1562
|
+
".semgrep.yml",
|
|
1563
|
+
".semgrep.yaml",
|
|
1564
|
+
"semgrep.yml",
|
|
1565
|
+
"semgrep.yaml"
|
|
1566
|
+
]) {
|
|
1567
|
+
const path = join9(ancestor, name);
|
|
1568
|
+
try {
|
|
1569
|
+
const stat2 = await lstat(path);
|
|
1570
|
+
if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
|
|
1571
|
+
if (!stat2.isFile()) continue;
|
|
1572
|
+
hash.update(relative6(workspace, path).replaceAll("\\", "/"));
|
|
1573
|
+
hash.update("\0");
|
|
1574
|
+
await hashFile(hash, path);
|
|
1575
|
+
hash.update("\0");
|
|
1576
|
+
} catch (error) {
|
|
1577
|
+
if (error.code !== "ENOENT") throw error;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
if (ancestor === workspace) break;
|
|
1581
|
+
ancestor = dirname3(ancestor);
|
|
1582
|
+
}
|
|
1583
|
+
return hash.digest("hex");
|
|
1584
|
+
}
|
|
1585
|
+
async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
|
|
1586
|
+
const source = resolve7(path);
|
|
1587
|
+
const stat2 = await lstat(source);
|
|
1588
|
+
if (!stat2.isFile() || stat2.isSymbolicLink() || stat2.size > 1e7) throw new Error("Evidence must be a regular JSON file under 10 MB");
|
|
1589
|
+
const root = resolve7(scanRoot);
|
|
1590
|
+
const relativePath = relative6(root, source);
|
|
1591
|
+
if (relativePath !== ".." && !relativePath.startsWith(`..${sep5}`)) throw new Error("Evidence file must be outside the scan root");
|
|
1592
|
+
const actualRoot = await realpath2(root);
|
|
1593
|
+
const actualSource = await realpath2(source);
|
|
1594
|
+
const actualRelative = relative6(actualRoot, actualSource);
|
|
1595
|
+
if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
|
|
1596
|
+
let parsed;
|
|
1597
|
+
try {
|
|
1598
|
+
parsed = JSON.parse(await readFile8(source, "utf8"));
|
|
1599
|
+
} catch {
|
|
1600
|
+
throw new Error("Evidence is not valid JSON");
|
|
1601
|
+
}
|
|
1602
|
+
return validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours);
|
|
1603
|
+
}
|
|
1604
|
+
async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
|
|
1605
|
+
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");
|
|
1606
|
+
if (parsed.policySha256 !== policySha256) throw new Error("Evidence was generated for another policy");
|
|
1607
|
+
const generatedAt = Date.parse(parsed.generatedAt);
|
|
1608
|
+
if (!Number.isFinite(generatedAt) || generatedAt > Date.now() + 6e4 || Date.now() - generatedAt > maximumAgeHours * 36e5) {
|
|
1609
|
+
throw new Error("Evidence is stale or has an invalid timestamp");
|
|
1610
|
+
}
|
|
1611
|
+
if (parsed.inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) throw new Error("Evidence inputs changed after scanning");
|
|
1612
|
+
const current = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot })).stdout.trim();
|
|
1613
|
+
if (!/^[0-9a-f]{40}$/.test(current) || parsed.commit !== current) throw new Error("Evidence commit differs from the checked-out commit");
|
|
1614
|
+
const appPaths = parsed.apps.map((app) => app.path);
|
|
1615
|
+
if (new Set(appPaths).size !== appPaths.length) throw new Error("Evidence contains duplicate app paths");
|
|
1616
|
+
return parsed;
|
|
1617
|
+
}
|
|
1618
|
+
var incomplete = (issues, tool, version2, scanned) => ({ status: "incomplete", issues, tool, version: version2, scanned });
|
|
1619
|
+
function evaluateAppEvidence(app, policy) {
|
|
1620
|
+
if (!app) {
|
|
1621
|
+
const missing = incomplete(["No fresh scan evidence for this app"]);
|
|
1622
|
+
return { dependencySecurity: { ...missing }, codeQuality: { ...missing }, codeSecurity: { ...missing }, compatibility: { ...missing } };
|
|
1623
|
+
}
|
|
1624
|
+
const dependency = app.dependencySecurity;
|
|
1625
|
+
let dependencySecurity;
|
|
1626
|
+
if (!dependency.complete || dependency.packagesScanned === 0 || dependency.errors.length) {
|
|
1627
|
+
dependencySecurity = { ...incomplete(["Dependency advisory scan did not cover the lockfile", ...dependency.errors], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories };
|
|
1628
|
+
} else {
|
|
1629
|
+
const high2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "high").length;
|
|
1630
|
+
const critical2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "critical").length;
|
|
1631
|
+
const unknown = dependency.advisories.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
|
|
1632
|
+
dependencySecurity = unknown.length ? { ...incomplete([`${unknown.length} advisories have unknown severity`], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories } : {
|
|
1633
|
+
status: high2 > policy.dependencySecurity.maximumHigh || critical2 > policy.dependencySecurity.maximumCritical ? "fail" : "pass",
|
|
1634
|
+
issues: [
|
|
1635
|
+
...high2 > policy.dependencySecurity.maximumHigh ? [`${high2} high advisories exceed ${policy.dependencySecurity.maximumHigh}`] : [],
|
|
1636
|
+
...critical2 > policy.dependencySecurity.maximumCritical ? [`${critical2} critical advisories exceed ${policy.dependencySecurity.maximumCritical}`] : []
|
|
1637
|
+
],
|
|
1638
|
+
tool: dependency.tool,
|
|
1639
|
+
version: dependency.version,
|
|
1640
|
+
scanned: dependency.packagesScanned,
|
|
1641
|
+
findings: dependency.advisories
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
const quality = app.codeQuality;
|
|
1645
|
+
const qualityMissing = [
|
|
1646
|
+
...!quality.lint.complete || quality.lint.filesScanned === 0 ? ["ESLint did not scan source files", ...quality.lint.errorsDetail] : [],
|
|
1647
|
+
...policy.codeQuality.requireTypecheck && !quality.typecheck.complete ? ["TypeScript typecheck did not complete", ...quality.typecheck.errors] : [],
|
|
1648
|
+
...policy.codeQuality.requireTests && (!quality.tests.complete || quality.tests.passed + quality.tests.failed === 0) ? ["Tests did not run", ...quality.tests.errors] : [],
|
|
1649
|
+
...!quality.coverage.complete ? ["Line coverage is unavailable", ...quality.coverage.errors] : []
|
|
1650
|
+
];
|
|
1651
|
+
const qualityFailures = [
|
|
1652
|
+
...quality.lint.errorCount > policy.codeQuality.maximumLintErrors ? [`${quality.lint.errorCount} ESLint errors exceed ${policy.codeQuality.maximumLintErrors}`] : [],
|
|
1653
|
+
...policy.codeQuality.requireTypecheck && !quality.typecheck.passed ? ["TypeScript typecheck failed"] : [],
|
|
1654
|
+
...quality.tests.failed > 0 ? [`${quality.tests.failed} tests failed`] : [],
|
|
1655
|
+
...quality.coverage.linesPercent < policy.codeQuality.minimumLineCoverage ? [`Line coverage ${quality.coverage.linesPercent}% is below ${policy.codeQuality.minimumLineCoverage}%`] : []
|
|
1656
|
+
];
|
|
1657
|
+
const qualityVersion = `ESLint ${quality.lint.version} \xB7 TypeScript ${quality.typecheck.version} \xB7 Vitest ${quality.tests.version}`;
|
|
1658
|
+
const qualityMetrics = {
|
|
1659
|
+
lintErrors: quality.lint.errorCount,
|
|
1660
|
+
lintWarnings: quality.lint.warnings,
|
|
1661
|
+
typecheck: quality.typecheck.applicable === false ? "not applicable" : quality.typecheck.passed ? "pass" : "fail",
|
|
1662
|
+
testsPassed: quality.tests.passed,
|
|
1663
|
+
testsFailed: quality.tests.failed,
|
|
1664
|
+
lineCoveragePercent: quality.coverage.linesPercent
|
|
1665
|
+
};
|
|
1666
|
+
const codeQuality = qualityMissing.length ? { ...incomplete([...qualityMissing, ...qualityFailures], "ESLint / TypeScript / tests", qualityVersion, quality.lint.filesScanned), metrics: qualityMetrics } : {
|
|
1667
|
+
status: qualityFailures.length ? "fail" : "pass",
|
|
1668
|
+
issues: qualityFailures,
|
|
1669
|
+
tool: "ESLint / TypeScript / tests",
|
|
1670
|
+
version: qualityVersion,
|
|
1671
|
+
scanned: quality.lint.filesScanned,
|
|
1672
|
+
metrics: qualityMetrics
|
|
1673
|
+
};
|
|
1674
|
+
const security = app.codeSecurity;
|
|
1675
|
+
const securityMissing = [
|
|
1676
|
+
...!security.sast.complete || security.sast.filesScanned === 0 ? ["SAST did not scan source files", ...security.sast.errors] : [],
|
|
1677
|
+
...!security.secrets.complete || security.secrets.filesScanned === 0 ? ["Secret scanner did not scan source files", ...security.secrets.errors] : []
|
|
1678
|
+
];
|
|
1679
|
+
const high = security.sast.findings.filter((item) => item.severity.toLowerCase() === "high").length;
|
|
1680
|
+
const critical = security.sast.findings.filter((item) => item.severity.toLowerCase() === "critical").length;
|
|
1681
|
+
const unknownSecurity = security.sast.findings.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
|
|
1682
|
+
if (unknownSecurity.length) securityMissing.push(`${unknownSecurity.length} SAST findings have unknown severity`);
|
|
1683
|
+
const securityFailures = [
|
|
1684
|
+
...high > policy.codeSecurity.maximumHigh ? [`${high} high SAST findings exceed ${policy.codeSecurity.maximumHigh}`] : [],
|
|
1685
|
+
...critical > policy.codeSecurity.maximumCritical ? [`${critical} critical SAST findings exceed ${policy.codeSecurity.maximumCritical}`] : [],
|
|
1686
|
+
...security.secrets.findings.length > policy.codeSecurity.maximumSecrets ? [`${security.secrets.findings.length} detected secrets exceed ${policy.codeSecurity.maximumSecrets}`] : []
|
|
1687
|
+
];
|
|
1688
|
+
const securityVersion = `Semgrep ${security.sast.version} \xB7 Gitleaks ${security.secrets.version}`;
|
|
1689
|
+
const codeSecurity = securityMissing.length ? { ...incomplete([...securityMissing, ...securityFailures], "Semgrep / Gitleaks", securityVersion, security.sast.filesScanned), findings: [...security.sast.findings, ...security.secrets.findings] } : {
|
|
1690
|
+
status: securityFailures.length ? "fail" : "pass",
|
|
1691
|
+
issues: securityFailures,
|
|
1692
|
+
tool: "Semgrep / Gitleaks",
|
|
1693
|
+
version: securityVersion,
|
|
1694
|
+
scanned: security.sast.filesScanned,
|
|
1695
|
+
findings: [...security.sast.findings, ...security.secrets.findings]
|
|
1696
|
+
};
|
|
1697
|
+
const compatibility2 = app.compatibility;
|
|
1698
|
+
const projectNames = new Set(compatibility2.projects.map((project) => project.name));
|
|
1699
|
+
const missingProjects = policy.compatibility.requiredProjects.filter((name) => !projectNames.has(name));
|
|
1700
|
+
const incompleteProjects = compatibility2.projects.filter((project) => project.passed + project.failed < policy.compatibility.minimumTestsPerProject);
|
|
1701
|
+
const missingBrowserVersions = compatibility2.projects.filter((project) => !project.browserVersion);
|
|
1702
|
+
const expectedEngines = {
|
|
1703
|
+
"chromium-desktop": "chromium",
|
|
1704
|
+
"firefox-desktop": "firefox",
|
|
1705
|
+
"webkit-desktop": "webkit",
|
|
1706
|
+
"android-chrome": "chromium",
|
|
1707
|
+
"iphone-safari": "webkit"
|
|
1708
|
+
};
|
|
1709
|
+
const invalidEngines = compatibility2.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
|
|
1710
|
+
const invalidDevices = compatibility2.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
|
|
1711
|
+
const missingViewports = compatibility2.projects.filter((project) => new Set(project.viewportWidths ?? []).size < 2);
|
|
1712
|
+
const compatibilityMissing = [
|
|
1713
|
+
...!compatibility2.complete ? ["Compatibility test run did not complete", ...compatibility2.errors] : [],
|
|
1714
|
+
...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
|
|
1715
|
+
...incompleteProjects.map((project) => `${project.name} ran fewer than ${policy.compatibility.minimumTestsPerProject} tests`),
|
|
1716
|
+
...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
|
|
1717
|
+
...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
|
|
1718
|
+
...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
|
|
1719
|
+
...missingViewports.map((project) => `${project.name} did not test two viewport widths`)
|
|
1720
|
+
];
|
|
1721
|
+
const failedProjects = compatibility2.projects.filter((project) => project.failed > 0);
|
|
1722
|
+
const compatibilityDomain = compatibilityMissing.length ? { ...incomplete([...compatibilityMissing, ...failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`)], compatibility2.tool, compatibility2.version), matrix: compatibility2.projects } : {
|
|
1723
|
+
status: failedProjects.length ? "fail" : "pass",
|
|
1724
|
+
issues: failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`),
|
|
1725
|
+
tool: compatibility2.tool,
|
|
1726
|
+
version: compatibility2.version,
|
|
1727
|
+
scanned: compatibility2.projects.reduce((count, project) => count + project.passed + project.failed, 0),
|
|
1728
|
+
matrix: compatibility2.projects
|
|
1729
|
+
};
|
|
1730
|
+
return { dependencySecurity, codeQuality, codeSecurity, compatibility: compatibilityDomain };
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// src/governance/policy.ts
|
|
1734
|
+
import { createHash as createHash3 } from "crypto";
|
|
1735
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
1736
|
+
import { resolve as resolve8 } from "path";
|
|
904
1737
|
import { valid as valid2 } from "semver";
|
|
905
1738
|
var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
906
1739
|
var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
907
1740
|
function record3(value) {
|
|
908
1741
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
909
1742
|
}
|
|
1743
|
+
var builtInRuleIds = /* @__PURE__ */ new Set([
|
|
1744
|
+
"module-cycle",
|
|
1745
|
+
"duplicate-import",
|
|
1746
|
+
"high-fan-out",
|
|
1747
|
+
"high-fan-in",
|
|
1748
|
+
"orphan-module",
|
|
1749
|
+
"unused-export",
|
|
1750
|
+
"large-component",
|
|
1751
|
+
"deep-jsx",
|
|
1752
|
+
"high-complexity",
|
|
1753
|
+
"deep-dependency-chain"
|
|
1754
|
+
]);
|
|
1755
|
+
function parseAssessmentPolicy(value) {
|
|
1756
|
+
if (!record3(value) || !record3(value.architecture)) throw new Error("assessment.architecture is required");
|
|
1757
|
+
const architecture = value.architecture;
|
|
1758
|
+
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");
|
|
1759
|
+
if (!Number.isSafeInteger(architecture.maximumErrors) || architecture.maximumErrors < 0) {
|
|
1760
|
+
throw new Error("assessment.architecture.maximumErrors must be a nonnegative integer");
|
|
1761
|
+
}
|
|
1762
|
+
if (!Array.isArray(architecture.blockedRuleIds) || !architecture.blockedRuleIds.every((item) => typeof item === "string") || new Set(architecture.blockedRuleIds).size !== architecture.blockedRuleIds.length) {
|
|
1763
|
+
throw new Error("assessment.architecture.blockedRuleIds must be a unique string array");
|
|
1764
|
+
}
|
|
1765
|
+
const rawAnalysis = architecture.analysis ?? {};
|
|
1766
|
+
if (!record3(rawAnalysis)) throw new Error("assessment.architecture.analysis must be an object");
|
|
1767
|
+
if (Array.isArray(rawAnalysis.policies) && rawAnalysis.policies.some((item) => record3(item) && item.enabled === false)) {
|
|
1768
|
+
throw new Error("Central architecture policies cannot be disabled");
|
|
1769
|
+
}
|
|
1770
|
+
const analysis = parseAnalysisConfig({ analysis: rawAnalysis, policies: rawAnalysis.policies });
|
|
1771
|
+
for (const policy of analysis.policies) {
|
|
1772
|
+
if (builtInRuleIds.has(policy.id)) throw new Error(`Architecture boundary ID ${policy.id} conflicts with a built-in rule`);
|
|
1773
|
+
}
|
|
1774
|
+
const known = /* @__PURE__ */ new Set([...builtInRuleIds, ...analysis.policies.map((item) => item.id)]);
|
|
1775
|
+
for (const id of architecture.blockedRuleIds) {
|
|
1776
|
+
if (!known.has(id)) throw new Error(`Unknown blocked rule ID ${id}`);
|
|
1777
|
+
}
|
|
1778
|
+
let evidence;
|
|
1779
|
+
if (value.evidence !== void 0) {
|
|
1780
|
+
if (!record3(value.evidence)) throw new Error("assessment.evidence must be an object");
|
|
1781
|
+
const gates = value.evidence;
|
|
1782
|
+
const nonnegative2 = (source, key, label) => {
|
|
1783
|
+
const result = source[key];
|
|
1784
|
+
if (!Number.isSafeInteger(result) || result < 0) throw new Error(`${label}.${key} must be a nonnegative integer`);
|
|
1785
|
+
return result;
|
|
1786
|
+
};
|
|
1787
|
+
if (!Number.isFinite(gates.maximumAgeHours) || gates.maximumAgeHours <= 0 || gates.maximumAgeHours > 168) {
|
|
1788
|
+
throw new Error("assessment.evidence.maximumAgeHours must be greater than 0 and at most 168");
|
|
1789
|
+
}
|
|
1790
|
+
for (const domain of ["dependencySecurity", "codeQuality", "codeSecurity", "compatibility"]) {
|
|
1791
|
+
if (!record3(gates[domain])) throw new Error(`assessment.evidence.${domain} is required`);
|
|
1792
|
+
}
|
|
1793
|
+
const dependencySecurity = gates.dependencySecurity;
|
|
1794
|
+
const codeQuality = gates.codeQuality;
|
|
1795
|
+
const codeSecurity = gates.codeSecurity;
|
|
1796
|
+
const compatibility2 = gates.compatibility;
|
|
1797
|
+
const minimumLineCoverage = codeQuality.minimumLineCoverage;
|
|
1798
|
+
if (typeof minimumLineCoverage !== "number" || !Number.isFinite(minimumLineCoverage) || minimumLineCoverage < 0 || minimumLineCoverage > 100) {
|
|
1799
|
+
throw new Error("assessment.evidence.codeQuality.minimumLineCoverage must be 0\u2013100");
|
|
1800
|
+
}
|
|
1801
|
+
if (typeof codeQuality.requireTypecheck !== "boolean" || typeof codeQuality.requireTests !== "boolean") {
|
|
1802
|
+
throw new Error("assessment.evidence.codeQuality requires boolean requireTypecheck and requireTests");
|
|
1803
|
+
}
|
|
1804
|
+
const projects = compatibility2.requiredProjects;
|
|
1805
|
+
if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
|
|
1806
|
+
throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
|
|
1807
|
+
}
|
|
1808
|
+
evidence = {
|
|
1809
|
+
maximumAgeHours: gates.maximumAgeHours,
|
|
1810
|
+
dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
|
|
1811
|
+
codeQuality: {
|
|
1812
|
+
maximumLintErrors: nonnegative2(codeQuality, "maximumLintErrors", "assessment.evidence.codeQuality"),
|
|
1813
|
+
minimumLineCoverage,
|
|
1814
|
+
requireTypecheck: codeQuality.requireTypecheck,
|
|
1815
|
+
requireTests: codeQuality.requireTests
|
|
1816
|
+
},
|
|
1817
|
+
codeSecurity: { maximumHigh: nonnegative2(codeSecurity, "maximumHigh", "assessment.evidence.codeSecurity"), maximumCritical: nonnegative2(codeSecurity, "maximumCritical", "assessment.evidence.codeSecurity"), maximumSecrets: nonnegative2(codeSecurity, "maximumSecrets", "assessment.evidence.codeSecurity") },
|
|
1818
|
+
compatibility: { requiredProjects: projects, minimumTestsPerProject: nonnegative2(compatibility2, "minimumTestsPerProject", "assessment.evidence.compatibility") }
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
return { architecture: {
|
|
1822
|
+
minimumScore: architecture.minimumScore,
|
|
1823
|
+
maximumErrors: architecture.maximumErrors,
|
|
1824
|
+
blockedRuleIds: architecture.blockedRuleIds,
|
|
1825
|
+
analysis
|
|
1826
|
+
}, ...evidence ? { evidence } : {} };
|
|
1827
|
+
}
|
|
910
1828
|
function parsePolicy(raw, source) {
|
|
911
1829
|
let parsed;
|
|
912
1830
|
try {
|
|
@@ -923,26 +1841,27 @@ function parsePolicy(raw, source) {
|
|
|
923
1841
|
if (!record3(parsed.profiles) || Object.keys(parsed.profiles).length === 0) {
|
|
924
1842
|
throw new Error("Policy profiles must be a nonempty object");
|
|
925
1843
|
}
|
|
926
|
-
const
|
|
1844
|
+
const profiles3 = {};
|
|
927
1845
|
for (const [name, rules] of Object.entries(parsed.profiles)) {
|
|
928
1846
|
if (!record3(rules) || Object.keys(rules).length === 0) {
|
|
929
1847
|
throw new Error(`Policy profile ${name} must list packages`);
|
|
930
1848
|
}
|
|
931
1849
|
const versions = {};
|
|
932
|
-
for (const [packageName,
|
|
933
|
-
if (typeof
|
|
1850
|
+
for (const [packageName, version2] of Object.entries(rules)) {
|
|
1851
|
+
if (typeof version2 !== "string" || !minimumVersion.test(version2) || valid2(version2.slice(2)) === null) {
|
|
934
1852
|
throw new Error(`Policy minimum version (>=X.Y.Z) required for ${packageName} in ${name}`);
|
|
935
1853
|
}
|
|
936
|
-
versions[packageName] =
|
|
1854
|
+
versions[packageName] = version2;
|
|
937
1855
|
}
|
|
938
|
-
|
|
1856
|
+
profiles3[name] = versions;
|
|
939
1857
|
}
|
|
940
1858
|
return {
|
|
941
1859
|
schemaVersion: 2,
|
|
942
1860
|
policyVersion: parsed.policyVersion,
|
|
943
|
-
profiles:
|
|
1861
|
+
profiles: profiles3,
|
|
1862
|
+
...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
|
|
944
1863
|
source,
|
|
945
|
-
sha256:
|
|
1864
|
+
sha256: createHash3("sha256").update(raw).digest("hex")
|
|
946
1865
|
};
|
|
947
1866
|
}
|
|
948
1867
|
async function loadPolicy(source) {
|
|
@@ -959,12 +1878,693 @@ async function loadPolicy(source) {
|
|
|
959
1878
|
}
|
|
960
1879
|
return parsePolicy(await response.text(), source);
|
|
961
1880
|
}
|
|
962
|
-
return parsePolicy(await
|
|
1881
|
+
return parsePolicy(await readFile9(resolve8(source), "utf8"), source);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// src/governance/assess.ts
|
|
1885
|
+
var notEvaluated = [
|
|
1886
|
+
"vulnerability databases",
|
|
1887
|
+
"licenses",
|
|
1888
|
+
"secret detection",
|
|
1889
|
+
"test execution",
|
|
1890
|
+
"runtime behavior",
|
|
1891
|
+
"branch protection",
|
|
1892
|
+
"repository access controls",
|
|
1893
|
+
"cross-repository rollout status"
|
|
1894
|
+
];
|
|
1895
|
+
function statusOf(statuses) {
|
|
1896
|
+
if (statuses.includes("incomplete")) return "incomplete";
|
|
1897
|
+
if (statuses.includes("fail")) return "fail";
|
|
1898
|
+
return "pass";
|
|
1899
|
+
}
|
|
1900
|
+
function escapeHtml2(value) {
|
|
1901
|
+
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
1902
|
+
}
|
|
1903
|
+
function renderAssessmentHtml(report) {
|
|
1904
|
+
const labels = {
|
|
1905
|
+
inventory: "App inventory",
|
|
1906
|
+
dependencies: "Dependency policy",
|
|
1907
|
+
architecture: "Architecture",
|
|
1908
|
+
dependencySecurity: "Dependency security",
|
|
1909
|
+
codeQuality: "Code quality",
|
|
1910
|
+
codeSecurity: "Code security",
|
|
1911
|
+
compatibility: "Browser compatibility"
|
|
1912
|
+
};
|
|
1913
|
+
const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
|
|
1914
|
+
const overview = domainNames.map((name) => {
|
|
1915
|
+
const statuses = report.apps.map((app) => app.domains[name]?.status ?? "incomplete");
|
|
1916
|
+
const status = statusOf(statuses);
|
|
1917
|
+
const passed = statuses.filter((item) => item === "pass").length;
|
|
1918
|
+
return `<li><span>${escapeHtml2(labels[name] ?? name)}</span><strong class="${status}">${escapeHtml2(status)}</strong><small>${passed}/${report.apps.length} apps passed</small></li>`;
|
|
1919
|
+
}).join("");
|
|
1920
|
+
const apps = report.apps.map((app) => {
|
|
1921
|
+
const domains = Object.entries(app.domains).map(([name, domain]) => {
|
|
1922
|
+
const evidence = domain;
|
|
1923
|
+
const identity = evidence.tool ? `<p><small>${escapeHtml2(evidence.tool)} ${escapeHtml2(evidence.version ?? "")} \xB7 scanned ${escapeHtml2(evidence.scanned ?? "unknown")}</small></p>` : "";
|
|
1924
|
+
const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml2(key)}: ${escapeHtml2(value)}`).join(" \xB7 ")}</small></p>` : "";
|
|
1925
|
+
const findings3 = evidence.findings?.length ? `<details><summary>${evidence.findings.length} finding(s)</summary><ul>${evidence.findings.map((item) => `<li><strong>${escapeHtml2(item.severity)}</strong> ${escapeHtml2(item.id)} \xB7 <code>${escapeHtml2(item.subject)}</code> \u2014 ${escapeHtml2(item.message)}${item.remediation ? ` \xB7 ${escapeHtml2(item.remediation)}` : ""}</li>`).join("")}</ul></details>` : "";
|
|
1926
|
+
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">${escapeHtml2(item.name)}</th><td>${item.passed}</td><td>${item.failed}</td><td>${item.skipped}</td><td>${escapeHtml2([item.browserVersion, item.device].filter(Boolean).join(" \xB7 ") || "Not recorded")}</td><td>${escapeHtml2(item.viewportWidths?.join(", ") ?? "Not recorded")}</td></tr>`).join("")}</tbody></table></div>` : "";
|
|
1927
|
+
return `<tr><th scope="row">${escapeHtml2(labels[name] ?? name)}</th><td class="${domain.status}">${escapeHtml2(domain.status)}</td><td>${identity}${metrics}${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : "No issues"}${findings3}${matrix}</td></tr>`;
|
|
1928
|
+
}).join("");
|
|
1929
|
+
const architecture = app.domains.architecture;
|
|
1930
|
+
const categoryRows = architecture.scoreBreakdown?.categories.map((category) => `<tr><th scope="row">${escapeHtml2(category.id)}</th><td>${escapeHtml2(category.weight)}%</td><td>${escapeHtml2(category.score)}</td><td>${escapeHtml2(category.findingCount)}</td></tr>`).join("") ?? "";
|
|
1931
|
+
const findings2 = architecture.findings.map((finding) => `<tr><td>${escapeHtml2(finding.severity)}</td><td>${escapeHtml2(finding.ruleId)}</td><td><code>${escapeHtml2(finding.file)}:${finding.line}</code></td><td>${escapeHtml2(finding.evidence)}</td></tr>`).join("");
|
|
1932
|
+
return `<section class="app"><h2>${escapeHtml2(app.path)} <span class="${app.status}">${escapeHtml2(app.status)}</span></h2><p>Profile: ${escapeHtml2(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>`;
|
|
1933
|
+
}).join("");
|
|
1934
|
+
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>
|
|
1935
|
+
: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}
|
|
1936
|
+
@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}">${escapeHtml2(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s) \xB7 policy ${escapeHtml2(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 ${escapeHtml2(report.policy.version)} \xB7 <code>${escapeHtml2(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml2(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml2(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml2(basename2(report.inventory.source))}</code></p>${report.evidence ? `<p>Scanners: ${escapeHtml2(report.evidence.generatedAt)} \xB7 commit <code>${escapeHtml2(report.evidence.commit)}</code> \xB7 input <code>${escapeHtml2(report.evidence.inputSha256)}</code></p>` : "<p>Scanner evidence unavailable.</p>"}${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml2).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml2).join(", ")}.</p></section></body></html>
|
|
1937
|
+
`;
|
|
1938
|
+
}
|
|
1939
|
+
async function assessGovernance(options) {
|
|
1940
|
+
const scanRoot = resolve9(options.scanRoot);
|
|
1941
|
+
const workspaceRoot = resolve9(options.workspaceRoot ?? scanRoot);
|
|
1942
|
+
const scanRelative = relative7(workspaceRoot, scanRoot);
|
|
1943
|
+
if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
|
|
1944
|
+
if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
|
|
1945
|
+
const assessment = parseAssessmentPolicy(options.policy.assessment);
|
|
1946
|
+
const outputDirectory = resolve9(options.outputDirectory ?? join10(scanRoot, ".nklattice-governance"));
|
|
1947
|
+
const inventorySource = resolve9(options.inventory ?? join10(scanRoot, ".nklattice-apps.json"));
|
|
1948
|
+
let inventory;
|
|
1949
|
+
const issues = [];
|
|
1950
|
+
try {
|
|
1951
|
+
inventory = await loadInventory(inventorySource, scanRoot);
|
|
1952
|
+
} catch (error) {
|
|
1953
|
+
issues.push(`Inventory: ${error instanceof Error ? error.message : String(error)}`);
|
|
1954
|
+
}
|
|
1955
|
+
let apps = [];
|
|
1956
|
+
try {
|
|
1957
|
+
apps = await discoverApps(scanRoot, workspaceRoot);
|
|
1958
|
+
} catch (error) {
|
|
1959
|
+
issues.push(`Discovery: ${error instanceof Error ? error.message : String(error)}`);
|
|
1960
|
+
}
|
|
1961
|
+
if (apps.length === 0) issues.push("No managed applications discovered");
|
|
1962
|
+
if (inventory) issues.push(...compareInventory(inventory, apps, scanRoot));
|
|
1963
|
+
if (options.expectedApps !== void 0 && apps.length !== options.expectedApps) {
|
|
1964
|
+
issues.push(`Expected ${options.expectedApps} managed apps; discovered ${apps.length}`);
|
|
1965
|
+
}
|
|
1966
|
+
let evidence;
|
|
1967
|
+
if (assessment.evidence) {
|
|
1968
|
+
if (!options.evidence && !options.trustedEvidence) issues.push("Scan evidence is required by the policy; run governance collect and pass --evidence");
|
|
1969
|
+
else try {
|
|
1970
|
+
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);
|
|
1971
|
+
} catch (error) {
|
|
1972
|
+
issues.push(`Scan evidence: ${error instanceof Error ? error.message : String(error)}`);
|
|
1973
|
+
}
|
|
1974
|
+
if (evidence) {
|
|
1975
|
+
const discovered = new Set(apps.map((app) => relative7(scanRoot, app.directory).replaceAll("\\", "/") || "."));
|
|
1976
|
+
for (const item of evidence.apps) if (!discovered.has(item.path)) issues.push(`Scan evidence contains unrecognized app ${item.path}`);
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
const results = [];
|
|
1980
|
+
for (const app of apps) {
|
|
1981
|
+
const appPath = relative7(scanRoot, app.directory).replaceAll("\\", "/") || ".";
|
|
1982
|
+
const inventoryIssues = issues.filter((issue) => issue.includes(appPath) || issue.startsWith("Inventory:") || issue.startsWith("Discovery:"));
|
|
1983
|
+
const inventoryDomain = { status: inventoryIssues.length ? "incomplete" : "pass", issues: inventoryIssues };
|
|
1984
|
+
const dependencyDomain = { status: "pass", issues: [], findings: [] };
|
|
1985
|
+
try {
|
|
1986
|
+
dependencyDomain.findings = await checkApp(app, workspaceRoot, options.policy);
|
|
1987
|
+
if (dependencyDomain.findings.length) {
|
|
1988
|
+
dependencyDomain.status = "fail";
|
|
1989
|
+
dependencyDomain.issues = dependencyDomain.findings.map((item) => `${item.packageName}: ${item.reason}; required ${item.expected}, declared ${item.declared ?? "missing"}, locked ${item.locked ?? "missing"}`);
|
|
1990
|
+
}
|
|
1991
|
+
} catch (error) {
|
|
1992
|
+
dependencyDomain.status = "incomplete";
|
|
1993
|
+
dependencyDomain.issues = [error instanceof Error ? error.message : String(error)];
|
|
1994
|
+
}
|
|
1995
|
+
const architectureDomain = {
|
|
1996
|
+
status: "pass",
|
|
1997
|
+
issues: [],
|
|
1998
|
+
filesScanned: 0,
|
|
1999
|
+
localEdges: 0,
|
|
2000
|
+
errors: 0,
|
|
2001
|
+
findings: [],
|
|
2002
|
+
diagnostics: []
|
|
2003
|
+
};
|
|
2004
|
+
try {
|
|
2005
|
+
const scan = await scanProjectDetailed(app.directory, assessment.architecture.analysis.aliases);
|
|
2006
|
+
architectureDomain.filesScanned = scan.modules.length;
|
|
2007
|
+
architectureDomain.diagnostics = scan.diagnostics;
|
|
2008
|
+
const graph = buildGraph(scan.modules);
|
|
2009
|
+
architectureDomain.localEdges = graph.edges.length;
|
|
2010
|
+
architectureDomain.findings = evaluateRules(graph, assessment.architecture.analysis);
|
|
2011
|
+
const score = scoreFindings(architectureDomain.findings, assessment.architecture.analysis);
|
|
2012
|
+
architectureDomain.score = score.score;
|
|
2013
|
+
architectureDomain.grade = score.grade;
|
|
2014
|
+
architectureDomain.scoreBreakdown = score;
|
|
2015
|
+
architectureDomain.errors = architectureDomain.findings.filter((item) => item.severity === "error").length;
|
|
2016
|
+
if (scan.modules.length === 0) architectureDomain.diagnostics.push("No supported source files found");
|
|
2017
|
+
if (architectureDomain.diagnostics.length) {
|
|
2018
|
+
architectureDomain.status = "incomplete";
|
|
2019
|
+
architectureDomain.issues.push(...architectureDomain.diagnostics);
|
|
2020
|
+
}
|
|
2021
|
+
if (score.score < assessment.architecture.minimumScore) {
|
|
2022
|
+
architectureDomain.issues.push(`Score ${score.score} is below minimum ${assessment.architecture.minimumScore}`);
|
|
2023
|
+
}
|
|
2024
|
+
if (architectureDomain.errors > assessment.architecture.maximumErrors) {
|
|
2025
|
+
architectureDomain.issues.push(`${architectureDomain.errors} error findings exceed maximum ${assessment.architecture.maximumErrors}`);
|
|
2026
|
+
}
|
|
2027
|
+
const blocked = architectureDomain.findings.filter((item) => assessment.architecture.blockedRuleIds.includes(item.ruleId));
|
|
2028
|
+
for (const finding of blocked) architectureDomain.issues.push(`Blocked ${finding.ruleId} at ${finding.file}:${finding.line}: ${finding.evidence}`);
|
|
2029
|
+
if (architectureDomain.status !== "incomplete" && architectureDomain.issues.length) architectureDomain.status = "fail";
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
architectureDomain.status = "incomplete";
|
|
2032
|
+
architectureDomain.issues.push(error instanceof Error ? error.message : String(error));
|
|
2033
|
+
}
|
|
2034
|
+
const extra = assessment.evidence ? evaluateAppEvidence(evidence?.apps.find((item) => item.path === appPath && item.profile === app.profile), assessment.evidence) : {};
|
|
2035
|
+
const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain, ...extra };
|
|
2036
|
+
results.push({ path: appPath, profile: app.profile, status: statusOf(Object.values(domains).map((domain) => domain.status)), domains });
|
|
2037
|
+
}
|
|
2038
|
+
const report = {
|
|
2039
|
+
schema: "urn:nklattice:governance-assessment:1",
|
|
2040
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2041
|
+
status: issues.length ? "incomplete" : statusOf(results.map((app) => app.status)),
|
|
2042
|
+
policy: { version: options.policy.policyVersion, source: options.policy.source, sha256: options.policy.sha256 },
|
|
2043
|
+
...evidence ? { evidence: { source: options.evidence ? resolve9(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
|
|
2044
|
+
inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
|
|
2045
|
+
scope: {
|
|
2046
|
+
domains: ["explicit app inventory", "dependency minimums", "source architecture", ...assessment.evidence ? ["known dependency advisories", "source quality", "source security", "browser and mobile emulation"] : []],
|
|
2047
|
+
notEvaluated: assessment.evidence ? ["licenses", "physical Android/iOS devices", "runtime performance", "cryptographic evidence attestation", "branch protection", "repository access controls", "cross-repository rollout status"] : notEvaluated
|
|
2048
|
+
},
|
|
2049
|
+
issues,
|
|
2050
|
+
apps: results
|
|
2051
|
+
};
|
|
2052
|
+
await mkdir3(outputDirectory, { recursive: true });
|
|
2053
|
+
await writeFile2(join10(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
|
|
2054
|
+
`);
|
|
2055
|
+
await writeFile2(join10(outputDirectory, "report.html"), renderAssessmentHtml(report));
|
|
2056
|
+
return report;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// src/governance/collect.ts
|
|
2060
|
+
import { execFile as execFile2 } from "child_process";
|
|
2061
|
+
import { createHash as createHash4 } from "crypto";
|
|
2062
|
+
import { mkdtemp, mkdir as mkdir4, readFile as readFile10, rm, writeFile as writeFile3 } from "fs/promises";
|
|
2063
|
+
import { tmpdir } from "os";
|
|
2064
|
+
import { dirname as dirname4, join as join11, relative as relative8, resolve as resolve10, sep as sep7 } from "path";
|
|
2065
|
+
import { promisify as promisify2 } from "util";
|
|
2066
|
+
import fg2 from "fast-glob";
|
|
2067
|
+
import YAML2 from "yaml";
|
|
2068
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
2069
|
+
var cap = 16 * 1024 * 1024;
|
|
2070
|
+
async function run(file, args, cwd, env) {
|
|
2071
|
+
try {
|
|
2072
|
+
const result = await execFileAsync2(file, args, { cwd, env, timeout: 3e5, maxBuffer: cap });
|
|
2073
|
+
return { code: 0, stdout: result.stdout, stderr: result.stderr };
|
|
2074
|
+
} catch (error) {
|
|
2075
|
+
const failure = error;
|
|
2076
|
+
return {
|
|
2077
|
+
code: typeof failure.code === "number" ? failure.code : -1,
|
|
2078
|
+
stdout: failure.stdout ?? "",
|
|
2079
|
+
stderr: failure.stderr ?? "",
|
|
2080
|
+
...typeof failure.code === "number" ? {} : { error: failure.killed ? "Scanner timed out" : failure.message }
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
function inside(parent, child) {
|
|
2085
|
+
const path = relative8(parent, child);
|
|
2086
|
+
return path === "" || path !== ".." && !path.startsWith(`..${sep7}`);
|
|
2087
|
+
}
|
|
2088
|
+
async function binary(name, app, workspaceRoot) {
|
|
2089
|
+
let directory = app;
|
|
2090
|
+
while (inside(workspaceRoot, directory)) {
|
|
2091
|
+
const candidate = join11(directory, "node_modules", ".bin", name);
|
|
2092
|
+
try {
|
|
2093
|
+
await readFile10(candidate);
|
|
2094
|
+
return candidate;
|
|
2095
|
+
} catch {
|
|
2096
|
+
}
|
|
2097
|
+
if (directory === workspaceRoot) break;
|
|
2098
|
+
directory = dirname4(directory);
|
|
2099
|
+
}
|
|
2100
|
+
return name;
|
|
2101
|
+
}
|
|
2102
|
+
async function version(file, cwd) {
|
|
2103
|
+
const result = await run(file, ["--version"], cwd);
|
|
2104
|
+
if (result.code !== 0) return "unavailable";
|
|
2105
|
+
const reported = result.stdout.trim().slice(0, 120) || "unknown";
|
|
2106
|
+
if (file === "gitleaks" && reported.includes("version is set by build process")) {
|
|
2107
|
+
const located = await run("which", [file], cwd);
|
|
2108
|
+
if (located.code === 0) {
|
|
2109
|
+
const build = await run("go", ["version", "-m", located.stdout.trim()], cwd);
|
|
2110
|
+
const match = build.stdout.match(/^\s*mod\s+\S+\/gitleaks\/v8\s+(v\d+\.\d+\.\d+)/m);
|
|
2111
|
+
if (match) return match[1];
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
return reported;
|
|
2115
|
+
}
|
|
2116
|
+
function json(text) {
|
|
2117
|
+
try {
|
|
2118
|
+
return JSON.parse(text);
|
|
2119
|
+
} catch {
|
|
2120
|
+
return void 0;
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
function obj(value) {
|
|
2124
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2125
|
+
}
|
|
2126
|
+
function str(value) {
|
|
2127
|
+
return typeof value === "string" ? value : "";
|
|
2128
|
+
}
|
|
2129
|
+
function num(value) {
|
|
2130
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
2131
|
+
}
|
|
2132
|
+
async function sourcePaths(app) {
|
|
2133
|
+
return fg2("**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}", {
|
|
2134
|
+
cwd: app,
|
|
2135
|
+
dot: true,
|
|
2136
|
+
onlyFiles: true,
|
|
2137
|
+
followSymbolicLinks: false,
|
|
2138
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/build/**", "**/coverage/**", "**/*.d.ts"]
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
async function sourceCount(app) {
|
|
2142
|
+
return (await sourcePaths(app)).length;
|
|
2143
|
+
}
|
|
2144
|
+
async function productionSources(app) {
|
|
2145
|
+
return (await sourcePaths(app)).filter((path) => !/(^|\/)(e2e|tests?|__tests__|fixtures)\//.test(path) && !/\.(test|spec|config)\.[cm]?[jt]sx?$/.test(path));
|
|
2146
|
+
}
|
|
2147
|
+
async function dependencyAudit(app, workspaceRoot, cache) {
|
|
2148
|
+
const failure = (errors2, tool = "npm/pnpm audit") => ({ tool, version: "unavailable", complete: false, packagesScanned: 0, advisories: [], errors: errors2 });
|
|
2149
|
+
let lock;
|
|
2150
|
+
try {
|
|
2151
|
+
lock = await findLockfile(app.directory, workspaceRoot);
|
|
2152
|
+
} catch (error) {
|
|
2153
|
+
return failure([error instanceof Error ? error.message : String(error)]);
|
|
2154
|
+
}
|
|
2155
|
+
const cached = cache.get(lock.path);
|
|
2156
|
+
if (cached) return cached;
|
|
2157
|
+
const raw = await readFile10(lock.path, "utf8");
|
|
2158
|
+
const parsedLock = lock.kind === "npm" ? json(raw) : YAML2.parse(raw);
|
|
2159
|
+
const packages = obj(parsedLock) && obj(parsedLock.packages) ? Object.keys(parsedLock.packages).filter((key) => key !== "").length : 0;
|
|
2160
|
+
const executable = lock.kind === "npm" ? "npm" : "pnpm";
|
|
2161
|
+
const toolVersion = await version(executable, lock.root);
|
|
2162
|
+
const result = await run(executable, ["audit", "--json"], lock.root);
|
|
2163
|
+
const report = json(result.stdout);
|
|
2164
|
+
if (!obj(report)) {
|
|
2165
|
+
const evidence2 = { ...failure([result.error ?? "Audit JSON output is unavailable"], `${executable} audit`), version: toolVersion, packagesScanned: packages };
|
|
2166
|
+
cache.set(lock.path, evidence2);
|
|
2167
|
+
return evidence2;
|
|
2168
|
+
}
|
|
2169
|
+
const auditMetadata = obj(report.metadata) ? report.metadata : void 0;
|
|
2170
|
+
const auditedPackages = lock.kind === "npm" && auditMetadata && obj(auditMetadata.dependencies) ? num(auditMetadata.dependencies.total) : lock.kind === "pnpm" && auditMetadata ? num(auditMetadata.totalDependencies) : 0;
|
|
2171
|
+
const supportedShape = lock.kind === "npm" ? report.auditReportVersion === 2 && obj(report.vulnerabilities) : obj(report.advisories);
|
|
2172
|
+
if (!supportedShape || packages === 0 || auditedPackages < packages || result.error || result.code > 1) {
|
|
2173
|
+
const evidence2 = { ...failure([result.error ?? "Audit output was unavailable or lockfile package inventory was empty"], `${executable} audit`), version: toolVersion, packagesScanned: packages };
|
|
2174
|
+
evidence2.errors = [result.error ?? `Audit schema or package coverage is incomplete (${auditedPackages}/${packages})`];
|
|
2175
|
+
cache.set(lock.path, evidence2);
|
|
2176
|
+
return evidence2;
|
|
2177
|
+
}
|
|
2178
|
+
const advisories = [];
|
|
2179
|
+
if (lock.kind === "npm" && obj(report.vulnerabilities)) {
|
|
2180
|
+
for (const [name, value] of Object.entries(report.vulnerabilities)) {
|
|
2181
|
+
if (!obj(value)) continue;
|
|
2182
|
+
const via = Array.isArray(value.via) ? value.via.find(obj) : void 0;
|
|
2183
|
+
advisories.push({
|
|
2184
|
+
id: obj(via) ? str(via.url) || str(via.title) || `npm:${name}` : `npm:${name}`,
|
|
2185
|
+
severity: str(value.severity) || "unknown",
|
|
2186
|
+
subject: name,
|
|
2187
|
+
message: "Known advisory affects a dependency",
|
|
2188
|
+
remediation: "Upgrade the affected package or apply a reviewed exception"
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
} else if (lock.kind === "pnpm" && obj(report.advisories)) {
|
|
2192
|
+
for (const [key, value] of Object.entries(report.advisories)) {
|
|
2193
|
+
if (!obj(value)) continue;
|
|
2194
|
+
advisories.push({
|
|
2195
|
+
id: str(value.url) || str(value.id) || key,
|
|
2196
|
+
severity: str(value.severity) || "unknown",
|
|
2197
|
+
subject: str(value.module_name) || "workspace dependency",
|
|
2198
|
+
message: "Known advisory affects a workspace dependency",
|
|
2199
|
+
remediation: "Upgrade the affected package or apply a reviewed exception"
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
} else if (result.code !== 0) {
|
|
2203
|
+
const evidence2 = { tool: `${executable} audit`, version: toolVersion, complete: false, packagesScanned: packages, advisories: [], errors: ["Audit returned an unsupported JSON shape"] };
|
|
2204
|
+
cache.set(lock.path, evidence2);
|
|
2205
|
+
return evidence2;
|
|
2206
|
+
}
|
|
2207
|
+
const registryErrors = [report.error, report.errors].filter(Boolean);
|
|
2208
|
+
const evidence = {
|
|
2209
|
+
tool: `${executable} audit${lock.root !== app.directory ? " (workspace-wide)" : ""}`,
|
|
2210
|
+
version: toolVersion,
|
|
2211
|
+
complete: registryErrors.length === 0,
|
|
2212
|
+
packagesScanned: packages,
|
|
2213
|
+
advisories,
|
|
2214
|
+
errors: registryErrors.length ? ["Advisory service returned an error"] : []
|
|
2215
|
+
};
|
|
2216
|
+
cache.set(lock.path, evidence);
|
|
2217
|
+
return evidence;
|
|
2218
|
+
}
|
|
2219
|
+
async function lint(app, workspaceRoot) {
|
|
2220
|
+
const file = await binary("eslint", app.directory, workspaceRoot);
|
|
2221
|
+
const toolVersion = await version(file, app.directory);
|
|
2222
|
+
const result = await run(file, [".", "--format", "json"], app.directory);
|
|
2223
|
+
const parsed = json(result.stdout);
|
|
2224
|
+
if (!Array.isArray(parsed) || result.error || !parsed.every(obj)) {
|
|
2225
|
+
return {
|
|
2226
|
+
tool: "ESLint",
|
|
2227
|
+
version: toolVersion,
|
|
2228
|
+
complete: false,
|
|
2229
|
+
filesScanned: 0,
|
|
2230
|
+
errorCount: 0,
|
|
2231
|
+
warnings: 0,
|
|
2232
|
+
errorsDetail: [result.error ?? "ESLint JSON output is unavailable; install and configure ESLint"]
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
const files = parsed;
|
|
2236
|
+
if (!files.every((item) => str(item.filePath) && Number.isSafeInteger(item.errorCount) && Number.isSafeInteger(item.warningCount))) {
|
|
2237
|
+
return {
|
|
2238
|
+
tool: "ESLint",
|
|
2239
|
+
version: toolVersion,
|
|
2240
|
+
complete: false,
|
|
2241
|
+
filesScanned: 0,
|
|
2242
|
+
errorCount: 0,
|
|
2243
|
+
warnings: 0,
|
|
2244
|
+
errorsDetail: ["ESLint JSON result did not contain reliable file paths or diagnostic counts"]
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
const expected = await sourcePaths(app.directory);
|
|
2248
|
+
const analyzed = new Set(files.map((item) => str(item.filePath).replaceAll("\\", "/")));
|
|
2249
|
+
const missing = expected.filter((path) => ![...analyzed].some((file2) => file2.endsWith(`/${path}`) || file2 === path));
|
|
2250
|
+
return {
|
|
2251
|
+
tool: "ESLint",
|
|
2252
|
+
version: toolVersion,
|
|
2253
|
+
complete: missing.length === 0,
|
|
2254
|
+
filesScanned: expected.length - missing.length,
|
|
2255
|
+
errorCount: files.reduce((sum, file2) => sum + num(file2.errorCount), 0),
|
|
2256
|
+
warnings: files.reduce((sum, file2) => sum + num(file2.warningCount), 0),
|
|
2257
|
+
errorsDetail: missing.length ? [`ESLint skipped ${missing.length} eligible source files`] : []
|
|
2258
|
+
};
|
|
2259
|
+
}
|
|
2260
|
+
async function typecheck(app, workspaceRoot) {
|
|
2261
|
+
const production = await productionSources(app.directory);
|
|
2262
|
+
const applicable = production.some((path) => /\.(ts|tsx|mts|cts)$/.test(path));
|
|
2263
|
+
const file = await binary("tsc", app.directory, workspaceRoot);
|
|
2264
|
+
const toolVersion = await version(file, app.directory);
|
|
2265
|
+
if (!applicable) return { tool: "TypeScript", version: toolVersion, applicable: false, complete: true, passed: true, errors: [] };
|
|
2266
|
+
const config = await readFile10(join11(app.directory, "tsconfig.json"), "utf8").catch(() => "");
|
|
2267
|
+
if (!config) return {
|
|
2268
|
+
tool: "TypeScript",
|
|
2269
|
+
version: toolVersion,
|
|
2270
|
+
applicable: true,
|
|
2271
|
+
complete: false,
|
|
2272
|
+
passed: false,
|
|
2273
|
+
errors: ["TypeScript source has no readable tsconfig.json"]
|
|
2274
|
+
};
|
|
2275
|
+
const result = await run(file, ["--project", app.directory, "--noEmit", "--pretty", "false"], app.directory);
|
|
2276
|
+
return {
|
|
2277
|
+
tool: "TypeScript",
|
|
2278
|
+
version: toolVersion,
|
|
2279
|
+
applicable: true,
|
|
2280
|
+
complete: !result.error && toolVersion !== "unavailable",
|
|
2281
|
+
passed: result.code === 0,
|
|
2282
|
+
errors: result.error ? [result.error] : result.code === 0 ? [] : [result.stdout.trim().slice(0, 500) || "TypeScript typecheck failed"]
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
async function testAndCoverage(app, workspaceRoot, temp) {
|
|
2286
|
+
const file = await binary("vitest", app.directory, workspaceRoot);
|
|
2287
|
+
const toolVersion = await version(file, app.directory);
|
|
2288
|
+
const resultPath = join11(temp, "vitest.json");
|
|
2289
|
+
const coverageDirectory = join11(temp, "coverage");
|
|
2290
|
+
const result = await run(file, [
|
|
2291
|
+
"run",
|
|
2292
|
+
"--reporter=json",
|
|
2293
|
+
"--outputFile",
|
|
2294
|
+
resultPath,
|
|
2295
|
+
"--coverage",
|
|
2296
|
+
"--coverage.reporter=json-summary",
|
|
2297
|
+
"--coverage.reportsDirectory",
|
|
2298
|
+
coverageDirectory
|
|
2299
|
+
], app.directory);
|
|
2300
|
+
const output = await readFile10(resultPath, "utf8").catch(() => "");
|
|
2301
|
+
const parsed = json(output);
|
|
2302
|
+
const summary = json(await readFile10(join11(coverageDirectory, "coverage-summary.json"), "utf8").catch(() => ""));
|
|
2303
|
+
const tests = obj(parsed) && typeof parsed.numPassedTests === "number" && typeof parsed.numFailedTests === "number";
|
|
2304
|
+
const lineCoverage = obj(summary) && obj(summary.total) && obj(summary.total.lines) ? summary.total.lines.pct : void 0;
|
|
2305
|
+
const expectedFiles = await productionSources(app.directory);
|
|
2306
|
+
const coveredFiles = obj(summary) ? Object.keys(summary).filter((key) => key !== "total") : [];
|
|
2307
|
+
const coveredPaths = new Set(coveredFiles.map((file2) => resolve10(app.directory, file2)));
|
|
2308
|
+
const uncovered = expectedFiles.filter((path) => !coveredPaths.has(resolve10(app.directory, path)));
|
|
2309
|
+
const coverage = typeof lineCoverage === "number" && expectedFiles.length > 0 && uncovered.length === 0;
|
|
2310
|
+
const unhandled = obj(parsed) && Array.isArray(parsed.unhandledErrors) && parsed.unhandledErrors.length > 0;
|
|
2311
|
+
const reporterFailedWithoutTests = tests && result.code !== 0 && num(parsed.numFailedTests) === 0;
|
|
2312
|
+
return {
|
|
2313
|
+
tests: {
|
|
2314
|
+
tool: "Vitest",
|
|
2315
|
+
version: toolVersion,
|
|
2316
|
+
complete: tests && !result.error && !unhandled && !reporterFailedWithoutTests,
|
|
2317
|
+
passed: tests ? num(parsed.numPassedTests) : 0,
|
|
2318
|
+
failed: tests ? num(parsed.numFailedTests) : 0,
|
|
2319
|
+
errors: tests && !unhandled && !reporterFailedWithoutTests ? [] : [result.error ?? "Vitest reporter recorded an unsuccessful run or unhandled error"]
|
|
2320
|
+
},
|
|
2321
|
+
coverage: {
|
|
2322
|
+
tool: "Vitest coverage",
|
|
2323
|
+
version: toolVersion,
|
|
2324
|
+
complete: coverage,
|
|
2325
|
+
linesPercent: coverage ? lineCoverage : 0,
|
|
2326
|
+
errors: coverage ? [] : [uncovered.length ? `Coverage omitted ${uncovered.length} eligible source files` : "Vitest line coverage JSON summary is unavailable"]
|
|
2327
|
+
}
|
|
2328
|
+
};
|
|
2329
|
+
}
|
|
2330
|
+
async function sast(app, temp) {
|
|
2331
|
+
const file = "semgrep";
|
|
2332
|
+
const toolVersion = await version(file, app.directory);
|
|
2333
|
+
const outputPath = join11(temp, "semgrep.json");
|
|
2334
|
+
const result = await run(file, [
|
|
2335
|
+
"scan",
|
|
2336
|
+
"--config",
|
|
2337
|
+
"p/ci",
|
|
2338
|
+
"--no-git-ignore",
|
|
2339
|
+
"--exclude",
|
|
2340
|
+
"**/node_modules/**",
|
|
2341
|
+
"--exclude",
|
|
2342
|
+
"**/.next/**",
|
|
2343
|
+
"--exclude",
|
|
2344
|
+
"**/coverage/**",
|
|
2345
|
+
"--exclude",
|
|
2346
|
+
"**/dist/**",
|
|
2347
|
+
"--json",
|
|
2348
|
+
"--output",
|
|
2349
|
+
outputPath,
|
|
2350
|
+
app.directory
|
|
2351
|
+
], app.directory);
|
|
2352
|
+
const parsed = json(await readFile10(outputPath, "utf8").catch(() => result.stdout));
|
|
2353
|
+
const expectedSources = await sourcePaths(app.directory);
|
|
2354
|
+
if (!obj(parsed) || !Array.isArray(parsed.results) || result.error) {
|
|
2355
|
+
return {
|
|
2356
|
+
tool: "Semgrep",
|
|
2357
|
+
version: toolVersion,
|
|
2358
|
+
complete: false,
|
|
2359
|
+
filesScanned: 0,
|
|
2360
|
+
findings: [],
|
|
2361
|
+
errors: [result.error ?? "Semgrep JSON result is unavailable; install Semgrep and enable rule download"]
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
const findings2 = parsed.results.filter(obj).map((item) => {
|
|
2365
|
+
const extra = obj(item.extra) ? item.extra : {};
|
|
2366
|
+
const metadata = obj(extra.metadata) ? extra.metadata : {};
|
|
2367
|
+
const impact = str(metadata.impact).toLowerCase();
|
|
2368
|
+
const severity = impact === "high" || impact === "critical" ? impact : str(extra.severity).toLowerCase() === "error" ? "high" : "moderate";
|
|
2369
|
+
return {
|
|
2370
|
+
id: str(item.check_id) || "semgrep:unknown",
|
|
2371
|
+
severity,
|
|
2372
|
+
subject: `${relative8(app.directory, str(item.path) || app.directory)}:${obj(item.start) ? num(item.start.line) : 0}`,
|
|
2373
|
+
message: "Static security rule matched; inspect the source location",
|
|
2374
|
+
remediation: "Review and fix the matched rule"
|
|
2375
|
+
};
|
|
2376
|
+
});
|
|
2377
|
+
const scannedPaths = obj(parsed.paths) && Array.isArray(parsed.paths.scanned) ? parsed.paths.scanned.filter((item) => typeof item === "string") : [];
|
|
2378
|
+
const missing = expectedSources.filter((path) => !scannedPaths.some((scanned) => scanned.replaceAll("\\", "/").endsWith(`/${path}`) || scanned === path));
|
|
2379
|
+
const scanErrors = [
|
|
2380
|
+
...Array.isArray(parsed.errors) && parsed.errors.length ? ["Semgrep reported scanner errors"] : [],
|
|
2381
|
+
...missing.length ? [`Semgrep skipped ${missing.length} eligible source files`] : []
|
|
2382
|
+
];
|
|
2383
|
+
return { tool: "Semgrep", version: toolVersion, complete: scanErrors.length === 0, filesScanned: expectedSources.length - missing.length, findings: findings2, errors: scanErrors };
|
|
2384
|
+
}
|
|
2385
|
+
async function secrets(app, temp) {
|
|
2386
|
+
const file = "gitleaks";
|
|
2387
|
+
const toolVersion = await version(file, app.directory);
|
|
2388
|
+
const outputPath = join11(temp, "gitleaks.json");
|
|
2389
|
+
const configPath = join11(temp, "gitleaks.toml");
|
|
2390
|
+
await writeFile3(configPath, '[extend]\nuseDefault = true\n\n[allowlist]\npaths = ["(^|/)(node_modules|\\\\.next|dist|build|coverage|test-results|playwright-report)/"]\n');
|
|
2391
|
+
const result = await run(file, [
|
|
2392
|
+
"dir",
|
|
2393
|
+
app.directory,
|
|
2394
|
+
"--config",
|
|
2395
|
+
configPath,
|
|
2396
|
+
"--report-format",
|
|
2397
|
+
"json",
|
|
2398
|
+
"--report-path",
|
|
2399
|
+
outputPath,
|
|
2400
|
+
"--redact",
|
|
2401
|
+
"--no-banner"
|
|
2402
|
+
], app.directory);
|
|
2403
|
+
const parsed = json(await readFile10(outputPath, "utf8").catch(() => ""));
|
|
2404
|
+
const count = await sourceCount(app.directory);
|
|
2405
|
+
if (!Array.isArray(parsed) || result.error) {
|
|
2406
|
+
return {
|
|
2407
|
+
tool: "Gitleaks",
|
|
2408
|
+
version: toolVersion,
|
|
2409
|
+
complete: false,
|
|
2410
|
+
filesScanned: 0,
|
|
2411
|
+
findings: [],
|
|
2412
|
+
errors: [result.error ?? "Gitleaks JSON result is unavailable; install Gitleaks"]
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
const findings2 = parsed.filter(obj).map((item) => ({
|
|
2416
|
+
id: str(item.RuleID) || "gitleaks:secret",
|
|
2417
|
+
severity: "critical",
|
|
2418
|
+
subject: `${str(item.File) ? relative8(app.directory, resolve10(app.directory, str(item.File))).replaceAll("\\", "/") : "source"}:${num(item.StartLine)}`,
|
|
2419
|
+
message: `Potential secret detected (fingerprint ${createHash4("sha256").update(str(item.Fingerprint)).digest("hex").slice(0, 16)})`,
|
|
2420
|
+
remediation: "Revoke the credential and remove it from source/history"
|
|
2421
|
+
}));
|
|
2422
|
+
return {
|
|
2423
|
+
tool: "Gitleaks",
|
|
2424
|
+
version: toolVersion,
|
|
2425
|
+
complete: result.code === 0 || result.code === 1,
|
|
2426
|
+
filesScanned: count,
|
|
2427
|
+
findings: findings2,
|
|
2428
|
+
errors: result.code > 1 ? ["Gitleaks scanner failed"] : []
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
function playwrightProjects(value) {
|
|
2432
|
+
if (!obj(value) || !Array.isArray(value.suites)) return [];
|
|
2433
|
+
const totals = /* @__PURE__ */ new Map();
|
|
2434
|
+
const visit = (suite) => {
|
|
2435
|
+
if (!obj(suite)) return;
|
|
2436
|
+
for (const spec of Array.isArray(suite.specs) ? suite.specs : []) {
|
|
2437
|
+
if (!obj(spec) || !Array.isArray(spec.tests)) continue;
|
|
2438
|
+
for (const test of spec.tests) {
|
|
2439
|
+
if (!obj(test) || !str(test.projectName)) continue;
|
|
2440
|
+
const projectName = str(test.projectName);
|
|
2441
|
+
const project = totals.get(projectName) ?? {
|
|
2442
|
+
name: projectName,
|
|
2443
|
+
passed: 0,
|
|
2444
|
+
failed: 0,
|
|
2445
|
+
skipped: 0,
|
|
2446
|
+
viewportWidths: /* @__PURE__ */ new Set()
|
|
2447
|
+
};
|
|
2448
|
+
for (const annotation of Array.isArray(test.annotations) ? test.annotations : []) {
|
|
2449
|
+
if (!obj(annotation)) continue;
|
|
2450
|
+
if (annotation.type === "browser-version" && str(annotation.description)) project.browserVersion = str(annotation.description);
|
|
2451
|
+
if (annotation.type === "viewport-width" && /^\d+$/.test(str(annotation.description))) project.viewportWidths.add(Number(annotation.description));
|
|
2452
|
+
}
|
|
2453
|
+
const status = str(test.status);
|
|
2454
|
+
if (status === "expected") project.passed++;
|
|
2455
|
+
else if (status === "skipped") project.skipped++;
|
|
2456
|
+
else project.failed++;
|
|
2457
|
+
totals.set(project.name, project);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
for (const child of Array.isArray(suite.suites) ? suite.suites : []) visit(child);
|
|
2461
|
+
};
|
|
2462
|
+
for (const suite of value.suites) visit(suite);
|
|
2463
|
+
return [...totals.values()].map((project) => ({ ...project, viewportWidths: [...project.viewportWidths].sort((a, b) => a - b) }));
|
|
2464
|
+
}
|
|
2465
|
+
async function compatibility(app, workspaceRoot, temp) {
|
|
2466
|
+
const file = await binary("playwright", app.directory, workspaceRoot);
|
|
2467
|
+
const toolVersion = await version(file, app.directory);
|
|
2468
|
+
const outputPath = join11(temp, "playwright.json");
|
|
2469
|
+
const configPath = join11(temp, "playwright-projects.json");
|
|
2470
|
+
const reporterPath = join11(temp, "lattice-playwright-reporter.cjs");
|
|
2471
|
+
await writeFile3(reporterPath, 'module.exports=class { onBegin(config) { require("node:fs").writeFileSync(process.env.LATTICE_PLAYWRIGHT_CONFIG_OUT, JSON.stringify(config.projects.map(p => ({ name: p.name, engine: p.use.browserName || p.use.defaultBrowserType, mobile: p.use.isMobile === true, touch: p.use.hasTouch === true, userAgent: p.use.userAgent || "" })))); } };\n');
|
|
2472
|
+
const result = await run(
|
|
2473
|
+
file,
|
|
2474
|
+
["test", `--reporter=json,${reporterPath}`],
|
|
2475
|
+
app.directory,
|
|
2476
|
+
{ ...process.env, PLAYWRIGHT_JSON_OUTPUT_FILE: outputPath, LATTICE_PLAYWRIGHT_CONFIG_OUT: configPath }
|
|
2477
|
+
);
|
|
2478
|
+
const parsed = json(await readFile10(outputPath, "utf8").catch(() => result.stdout));
|
|
2479
|
+
const projects = playwrightProjects(parsed);
|
|
2480
|
+
const config = json(await readFile10(configPath, "utf8").catch(() => ""));
|
|
2481
|
+
const configurations = Array.isArray(config) && config.every(obj) ? config : [];
|
|
2482
|
+
for (const project of projects) {
|
|
2483
|
+
const configured = configurations.find((item) => item.name === project.name);
|
|
2484
|
+
if (!configured) continue;
|
|
2485
|
+
project.engine = str(configured.engine);
|
|
2486
|
+
project.mobile = configured.mobile === true && configured.touch === true;
|
|
2487
|
+
const agent = str(configured.userAgent);
|
|
2488
|
+
const android = agent.match(/Android\s+([\d.]+)/i);
|
|
2489
|
+
const iphone = agent.match(/iPhone OS\s+([\d_]+)/i);
|
|
2490
|
+
if (project.name === "android-chrome" && android && project.mobile) project.device = `Android ${android[1]} UA emulation`;
|
|
2491
|
+
if (project.name === "iphone-safari" && iphone && project.mobile) project.device = `iOS ${iphone[1].replaceAll("_", ".")} UA emulation`;
|
|
2492
|
+
}
|
|
2493
|
+
const reportedErrors = obj(parsed) && Array.isArray(parsed.errors) ? parsed.errors : [];
|
|
2494
|
+
const infrastructureFailure = reportedErrors.length > 0 || configurations.length === 0 || typeof result.stderr === "string" && /browser executable.*doesn't exist|failed to launch browser|install playwright browsers/i.test(result.stderr);
|
|
2495
|
+
return {
|
|
2496
|
+
tool: "Playwright",
|
|
2497
|
+
version: toolVersion,
|
|
2498
|
+
complete: Boolean(parsed) && !result.error && !infrastructureFailure && projects.length > 0,
|
|
2499
|
+
projects,
|
|
2500
|
+
errors: infrastructureFailure ? ["Playwright browser infrastructure or configuration failed"] : projects.length ? [] : [result.error ?? "Playwright JSON results are unavailable; configure required browser and mobile projects"]
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
async function collectGovernanceEvidence(options) {
|
|
2504
|
+
const scanRoot = resolve10(options.scanRoot);
|
|
2505
|
+
const workspaceRoot = resolve10(options.workspaceRoot ?? scanRoot);
|
|
2506
|
+
const destination = resolve10(options.evidence);
|
|
2507
|
+
if (!inside(workspaceRoot, scanRoot)) throw new Error("Scan root must be inside workspace root");
|
|
2508
|
+
if (inside(scanRoot, destination)) throw new Error("Evidence file must be outside the scan root");
|
|
2509
|
+
if (!options.policy.assessment?.evidence) throw new Error("Policy assessment.evidence is required for governance collect");
|
|
2510
|
+
const inputSha256 = await hashAssessmentInputs(scanRoot, workspaceRoot);
|
|
2511
|
+
const assertStableInputs = async (stage) => {
|
|
2512
|
+
if (inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) {
|
|
2513
|
+
throw new Error(`Assessment inputs changed ${stage}; rerun on a stable checkout`);
|
|
2514
|
+
}
|
|
2515
|
+
};
|
|
2516
|
+
const git = await run("git", ["rev-parse", "HEAD"], workspaceRoot);
|
|
2517
|
+
const commit = git.code === 0 && /^[0-9a-f]{40}$/.test(git.stdout.trim()) ? git.stdout.trim() : "unversioned";
|
|
2518
|
+
const apps = await discoverApps(scanRoot, workspaceRoot);
|
|
2519
|
+
const auditCache = /* @__PURE__ */ new Map();
|
|
2520
|
+
const scans = [];
|
|
2521
|
+
for (const app of apps) {
|
|
2522
|
+
const temp = await mkdtemp(join11(tmpdir(), "lattice-scanners-"));
|
|
2523
|
+
try {
|
|
2524
|
+
const dependencySecurity = await dependencyAudit(app, workspaceRoot, auditCache);
|
|
2525
|
+
await assertStableInputs("after dependency audit");
|
|
2526
|
+
const qualityLint = await lint(app, workspaceRoot);
|
|
2527
|
+
await assertStableInputs("after ESLint");
|
|
2528
|
+
const qualityTypecheck = await typecheck(app, workspaceRoot);
|
|
2529
|
+
await assertStableInputs("after TypeScript");
|
|
2530
|
+
const qualityTests = await testAndCoverage(app, workspaceRoot, temp);
|
|
2531
|
+
await assertStableInputs("after Vitest");
|
|
2532
|
+
const securitySast = await sast(app, temp);
|
|
2533
|
+
await assertStableInputs("after Semgrep");
|
|
2534
|
+
const securitySecrets = await secrets(app, temp);
|
|
2535
|
+
await assertStableInputs("after Gitleaks");
|
|
2536
|
+
const compatibilityResult = await compatibility(app, workspaceRoot, temp);
|
|
2537
|
+
await assertStableInputs("after Playwright");
|
|
2538
|
+
scans.push({
|
|
2539
|
+
path: relative8(scanRoot, app.directory).replaceAll("\\", "/") || ".",
|
|
2540
|
+
profile: app.profile,
|
|
2541
|
+
dependencySecurity,
|
|
2542
|
+
codeQuality: { lint: qualityLint, typecheck: qualityTypecheck, ...qualityTests },
|
|
2543
|
+
codeSecurity: { sast: securitySast, secrets: securitySecrets },
|
|
2544
|
+
compatibility: compatibilityResult
|
|
2545
|
+
});
|
|
2546
|
+
} finally {
|
|
2547
|
+
await rm(temp, { recursive: true, force: true });
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
await assertStableInputs("before evidence write");
|
|
2551
|
+
const report = {
|
|
2552
|
+
schemaVersion: 1,
|
|
2553
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2554
|
+
policySha256: options.policy.sha256,
|
|
2555
|
+
inputSha256,
|
|
2556
|
+
commit,
|
|
2557
|
+
apps: scans
|
|
2558
|
+
};
|
|
2559
|
+
await mkdir4(dirname4(destination), { recursive: true });
|
|
2560
|
+
await writeFile3(destination, `${JSON.stringify(report, null, 2)}
|
|
2561
|
+
`, { flag: "w" });
|
|
2562
|
+
return report;
|
|
963
2563
|
}
|
|
964
2564
|
|
|
965
2565
|
// src/runtime.ts
|
|
966
|
-
function isSupportedNodeVersion(
|
|
967
|
-
const major = Number(
|
|
2566
|
+
function isSupportedNodeVersion(version2) {
|
|
2567
|
+
const major = Number(version2.split(".", 1)[0]);
|
|
968
2568
|
return Number.isInteger(major) && major >= 24;
|
|
969
2569
|
}
|
|
970
2570
|
|
|
@@ -981,16 +2581,16 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
981
2581
|
}
|
|
982
2582
|
let exitCode = 0;
|
|
983
2583
|
const program = new Command();
|
|
984
|
-
program.name("lattice").description("Architecture analysis and dependency governance for React teams").version(
|
|
2584
|
+
program.name("lattice").description("Architecture analysis and dependency governance for React teams").version(package_default.version);
|
|
985
2585
|
program.configureOutput({ writeOut: output.out, writeErr: output.err });
|
|
986
2586
|
program.exitOverride();
|
|
987
2587
|
program.command("analyze [path]").description("Analyze local architecture and write offline reports").option("--output <directory>", "Output directory").action(async (path, flags) => {
|
|
988
|
-
const root =
|
|
989
|
-
const destination =
|
|
2588
|
+
const root = resolve11(path ?? ".");
|
|
2589
|
+
const destination = resolve11(flags.output ?? join12(root, ".nklattice-report"));
|
|
990
2590
|
const report = await analyzeProject(root, { outputDirectory: destination });
|
|
991
2591
|
output.out(`NKLattice analyzed ${report.graph.modules.length} modules. Score ${report.score.score} (${report.score.grade}); ${report.findings.length} findings.
|
|
992
2592
|
`);
|
|
993
|
-
output.out(`Reports: ${
|
|
2593
|
+
output.out(`Reports: ${join12(destination, "report.html")} and ${join12(destination, "analysis.json")}
|
|
994
2594
|
`);
|
|
995
2595
|
});
|
|
996
2596
|
program.command("doctor [path]").description("Check whether a directory contains supported source files").action(async (path) => {
|
|
@@ -1000,19 +2600,20 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1000
2600
|
`);
|
|
1001
2601
|
});
|
|
1002
2602
|
program.command("init [path]").description("Create a project configuration file").action(async (path) => {
|
|
1003
|
-
const destination =
|
|
1004
|
-
await
|
|
2603
|
+
const destination = join12(resolve11(path ?? "."), ".nklattice.json");
|
|
2604
|
+
await writeFile4(destination, '{\n "analysis": {},\n "policies": []\n}\n', { flag: "wx" });
|
|
1005
2605
|
output.out(`Created ${destination}
|
|
1006
2606
|
`);
|
|
1007
2607
|
});
|
|
1008
|
-
program.command("governance").description("
|
|
2608
|
+
const governance = program.command("governance").description("Assess centrally approved project governance rules");
|
|
2609
|
+
governance.command("check [path]").option("--policy <source>", "Local file or HTTPS policy URL").option("--workspace-root <path>", "Repository root containing a shared lockfile").option("--expect-apps <count>", "Required number of managed applications").option("--json", "Machine-readable result").action(async (path, flags) => {
|
|
1009
2610
|
let expectedApps;
|
|
1010
2611
|
if (flags.expectApps !== void 0) {
|
|
1011
2612
|
if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
|
|
1012
2613
|
expectedApps = Number(flags.expectApps);
|
|
1013
2614
|
if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
|
|
1014
2615
|
}
|
|
1015
|
-
const policyPath = flags.policy ??
|
|
2616
|
+
const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
1016
2617
|
const policy = await loadPolicy(policyPath);
|
|
1017
2618
|
const result = await checkGovernance({
|
|
1018
2619
|
scanRoot: path ?? ".",
|
|
@@ -1038,6 +2639,88 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1038
2639
|
}
|
|
1039
2640
|
if (result.findings.length > 0) exitCode = 7;
|
|
1040
2641
|
});
|
|
2642
|
+
governance.command("assess [path]").description("Assess app inventory, dependency, architecture, security, quality, and compatibility policy").option("--policy <source>", "Local file or HTTPS policy URL").option("--inventory <path>", "Reviewed app inventory JSON").option("--workspace-root <path>", "Repository root containing a shared lockfile").option("--expect-apps <count>", "Additional expected managed application count").option("--output <directory>", "Assessment report directory").option("--evidence <file>", "Fresh governance collect JSON outside the scan root").option("--json", "Print machine-readable assessment").action(async (path, flags) => {
|
|
2643
|
+
let expectedApps;
|
|
2644
|
+
if (flags.expectApps !== void 0) {
|
|
2645
|
+
if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
|
|
2646
|
+
expectedApps = Number(flags.expectApps);
|
|
2647
|
+
if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
|
|
2648
|
+
}
|
|
2649
|
+
const scanRoot = resolve11(path ?? ".");
|
|
2650
|
+
const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
2651
|
+
const report = await assessGovernance({
|
|
2652
|
+
scanRoot,
|
|
2653
|
+
workspaceRoot: flags.workspaceRoot,
|
|
2654
|
+
inventory: flags.inventory,
|
|
2655
|
+
expectedApps,
|
|
2656
|
+
outputDirectory: flags.output,
|
|
2657
|
+
evidence: flags.evidence,
|
|
2658
|
+
policy: await loadPolicy(policyPath)
|
|
2659
|
+
});
|
|
2660
|
+
if (flags.json) output.out(`${JSON.stringify(report, null, 2)}
|
|
2661
|
+
`);
|
|
2662
|
+
else {
|
|
2663
|
+
output.out(`Governance ${report.status}: ${report.apps.length} app(s); policy ${report.policy.version} (${report.policy.sha256}).
|
|
2664
|
+
`);
|
|
2665
|
+
for (const issue of report.issues) output.out(`Inventory: ${issue}
|
|
2666
|
+
`);
|
|
2667
|
+
for (const app of report.apps) {
|
|
2668
|
+
output.out(`${app.path} (${app.profile}): ${app.status}; ${Object.entries(app.domains).map(([name, domain]) => `${name} ${domain.status}`).join("; ")}; architecture score ${app.domains.architecture.score ?? "unavailable"}.
|
|
2669
|
+
`);
|
|
2670
|
+
for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
|
|
2671
|
+
`);
|
|
2672
|
+
}
|
|
2673
|
+
output.out(`Reports: ${resolve11(flags.output ?? join12(scanRoot, ".nklattice-governance"))}
|
|
2674
|
+
`);
|
|
2675
|
+
}
|
|
2676
|
+
exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
|
|
2677
|
+
});
|
|
2678
|
+
governance.command("collect [path]").description("Run project scanners and write fresh machine-readable evidence").requiredOption("--evidence <file>", "Evidence JSON outside the scan root").option("--policy <source>", "Local file or HTTPS policy URL").option("--workspace-root <path>", "Repository root containing a shared lockfile").action(async (path, flags) => {
|
|
2679
|
+
const scanRoot = resolve11(path ?? ".");
|
|
2680
|
+
const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
2681
|
+
const result = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy: await loadPolicy(policyPath) });
|
|
2682
|
+
output.out(`Collected scanner evidence for ${result.apps.length} app(s) at ${resolve11(flags.evidence)}.
|
|
2683
|
+
`);
|
|
2684
|
+
});
|
|
2685
|
+
governance.command("run [path]").description("Collect and assess all governance domains in one trusted process").requiredOption("--evidence <file>", "Evidence artifact outside the scan root").option("--policy <source>", "Local file or HTTPS policy URL").option("--inventory <path>", "Reviewed app inventory JSON").option("--workspace-root <path>", "Repository root containing a shared lockfile").option("--expect-apps <count>", "Additional expected managed application count").option("--output <directory>", "Assessment report directory").option("--json", "Print machine-readable assessment").action(async (path, flags) => {
|
|
2686
|
+
let expectedApps;
|
|
2687
|
+
if (flags.expectApps !== void 0) {
|
|
2688
|
+
if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
|
|
2689
|
+
expectedApps = Number(flags.expectApps);
|
|
2690
|
+
if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
|
|
2691
|
+
}
|
|
2692
|
+
const scanRoot = resolve11(path ?? ".");
|
|
2693
|
+
const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
2694
|
+
const policy = await loadPolicy(policyPath);
|
|
2695
|
+
const collected = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy });
|
|
2696
|
+
const report = await assessGovernance({
|
|
2697
|
+
scanRoot,
|
|
2698
|
+
workspaceRoot: flags.workspaceRoot,
|
|
2699
|
+
inventory: flags.inventory,
|
|
2700
|
+
expectedApps,
|
|
2701
|
+
outputDirectory: flags.output,
|
|
2702
|
+
evidence: flags.evidence,
|
|
2703
|
+
trustedEvidence: collected,
|
|
2704
|
+
policy
|
|
2705
|
+
});
|
|
2706
|
+
if (flags.json) output.out(`${JSON.stringify(report, null, 2)}
|
|
2707
|
+
`);
|
|
2708
|
+
else {
|
|
2709
|
+
output.out(`Governance ${report.status}: ${report.apps.length} app(s); policy ${report.policy.version} (${report.policy.sha256}).
|
|
2710
|
+
`);
|
|
2711
|
+
for (const issue of report.issues) output.out(`Assessment: ${issue}
|
|
2712
|
+
`);
|
|
2713
|
+
for (const app of report.apps) {
|
|
2714
|
+
output.out(`${app.path} (${app.profile}): ${app.status}; ${Object.entries(app.domains).map(([name, domain]) => `${name} ${domain.status}`).join("; ")}; architecture score ${app.domains.architecture.score ?? "unavailable"}.
|
|
2715
|
+
`);
|
|
2716
|
+
for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
|
|
2717
|
+
`);
|
|
2718
|
+
}
|
|
2719
|
+
output.out(`Reports: ${resolve11(flags.output ?? join12(scanRoot, ".nklattice-governance"))}
|
|
2720
|
+
`);
|
|
2721
|
+
}
|
|
2722
|
+
exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
|
|
2723
|
+
});
|
|
1041
2724
|
try {
|
|
1042
2725
|
await program.parseAsync(arguments_, { from: "user" });
|
|
1043
2726
|
} catch (error) {
|
|
@@ -1049,7 +2732,7 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1049
2732
|
}
|
|
1050
2733
|
return exitCode;
|
|
1051
2734
|
}
|
|
1052
|
-
if (process.argv[1] && realpathSync(
|
|
2735
|
+
if (process.argv[1] && realpathSync(resolve11(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
1053
2736
|
runCli(process.argv.slice(2)).then((code) => {
|
|
1054
2737
|
process.exitCode = code;
|
|
1055
2738
|
});
|