@anhcodo13/lattice 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -16
- package/dist/cli.js +923 -150
- package/dist/index.d.ts +117 -1
- package/dist/index.js +965 -63
- package/governance/policy.json +14 -1
- package/package.json +2 -2
package/dist/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 writeFile3 } from "fs/promises";
|
|
5
5
|
import { realpathSync } from "fs";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
|
-
import { dirname as
|
|
7
|
+
import { dirname as dirname3, join as join10, resolve as resolve9 } 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.3.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
|
|
@@ -204,68 +257,354 @@ 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
|
|
@@ -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 json = ts2.parseConfigFileTextToJson(path, content);
|
|
933
|
+
if (json.error) return { options: {}, diagnostics: [`tsconfig.json: ${ts2.flattenDiagnosticMessageText(json.error.messageText, " ")}`] };
|
|
934
|
+
const parsed = ts2.parseJsonConfigFileContent(json.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
|
+
}
|
|
559
951
|
}
|
|
560
|
-
|
|
561
|
-
if (json.error) return {};
|
|
562
|
-
const parsed = ts2.parseJsonConfigFileContent(json.config, ts2.sys, root);
|
|
563
|
-
return parsed.options;
|
|
952
|
+
return void 0;
|
|
564
953
|
}
|
|
565
|
-
async function
|
|
566
|
-
const
|
|
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);
|
|
1022
|
+
}
|
|
1023
|
+
return diagnostics;
|
|
1024
|
+
}
|
|
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
|
|
@@ -633,7 +1130,7 @@ 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
1136
|
const findings = evaluateRules(graph, config);
|
|
@@ -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
|
}
|
|
@@ -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 findings = [];
|
|
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) findings.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 findings;
|
|
1378
|
+
}
|
|
848
1379
|
async function checkGovernance(options) {
|
|
849
1380
|
const scanRoot = resolve5(options.scanRoot);
|
|
850
1381
|
const workspaceRoot = resolve5(options.workspaceRoot ?? scanRoot);
|
|
@@ -854,40 +1385,7 @@ 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 findings =
|
|
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 findings = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
|
|
891
1389
|
return {
|
|
892
1390
|
appsChecked: apps.length,
|
|
893
1391
|
findings,
|
|
@@ -897,16 +1395,120 @@ async function checkGovernance(options) {
|
|
|
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 { join as join9, relative as relative6, resolve as resolve8, sep as sep5 } 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/policy.ts
|
|
1461
|
+
import { createHash as createHash2 } from "crypto";
|
|
1462
|
+
import { readFile as readFile8 } from "fs/promises";
|
|
1463
|
+
import { resolve as resolve7 } from "path";
|
|
904
1464
|
import { valid as valid2 } from "semver";
|
|
905
1465
|
var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
906
1466
|
var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
907
1467
|
function record3(value) {
|
|
908
1468
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
909
1469
|
}
|
|
1470
|
+
var builtInRuleIds = /* @__PURE__ */ new Set([
|
|
1471
|
+
"module-cycle",
|
|
1472
|
+
"duplicate-import",
|
|
1473
|
+
"high-fan-out",
|
|
1474
|
+
"high-fan-in",
|
|
1475
|
+
"orphan-module",
|
|
1476
|
+
"unused-export",
|
|
1477
|
+
"large-component",
|
|
1478
|
+
"deep-jsx",
|
|
1479
|
+
"high-complexity",
|
|
1480
|
+
"deep-dependency-chain"
|
|
1481
|
+
]);
|
|
1482
|
+
function parseAssessmentPolicy(value) {
|
|
1483
|
+
if (!record3(value) || !record3(value.architecture)) throw new Error("assessment.architecture is required");
|
|
1484
|
+
const architecture = value.architecture;
|
|
1485
|
+
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");
|
|
1486
|
+
if (!Number.isSafeInteger(architecture.maximumErrors) || architecture.maximumErrors < 0) {
|
|
1487
|
+
throw new Error("assessment.architecture.maximumErrors must be a nonnegative integer");
|
|
1488
|
+
}
|
|
1489
|
+
if (!Array.isArray(architecture.blockedRuleIds) || !architecture.blockedRuleIds.every((item) => typeof item === "string") || new Set(architecture.blockedRuleIds).size !== architecture.blockedRuleIds.length) {
|
|
1490
|
+
throw new Error("assessment.architecture.blockedRuleIds must be a unique string array");
|
|
1491
|
+
}
|
|
1492
|
+
const rawAnalysis = architecture.analysis ?? {};
|
|
1493
|
+
if (!record3(rawAnalysis)) throw new Error("assessment.architecture.analysis must be an object");
|
|
1494
|
+
if (Array.isArray(rawAnalysis.policies) && rawAnalysis.policies.some((item) => record3(item) && item.enabled === false)) {
|
|
1495
|
+
throw new Error("Central architecture policies cannot be disabled");
|
|
1496
|
+
}
|
|
1497
|
+
const analysis = parseAnalysisConfig({ analysis: rawAnalysis, policies: rawAnalysis.policies });
|
|
1498
|
+
for (const policy of analysis.policies) {
|
|
1499
|
+
if (builtInRuleIds.has(policy.id)) throw new Error(`Architecture boundary ID ${policy.id} conflicts with a built-in rule`);
|
|
1500
|
+
}
|
|
1501
|
+
const known = /* @__PURE__ */ new Set([...builtInRuleIds, ...analysis.policies.map((item) => item.id)]);
|
|
1502
|
+
for (const id of architecture.blockedRuleIds) {
|
|
1503
|
+
if (!known.has(id)) throw new Error(`Unknown blocked rule ID ${id}`);
|
|
1504
|
+
}
|
|
1505
|
+
return { architecture: {
|
|
1506
|
+
minimumScore: architecture.minimumScore,
|
|
1507
|
+
maximumErrors: architecture.maximumErrors,
|
|
1508
|
+
blockedRuleIds: architecture.blockedRuleIds,
|
|
1509
|
+
analysis
|
|
1510
|
+
} };
|
|
1511
|
+
}
|
|
910
1512
|
function parsePolicy(raw, source) {
|
|
911
1513
|
let parsed;
|
|
912
1514
|
try {
|
|
@@ -923,7 +1525,7 @@ function parsePolicy(raw, source) {
|
|
|
923
1525
|
if (!record3(parsed.profiles) || Object.keys(parsed.profiles).length === 0) {
|
|
924
1526
|
throw new Error("Policy profiles must be a nonempty object");
|
|
925
1527
|
}
|
|
926
|
-
const
|
|
1528
|
+
const profiles3 = {};
|
|
927
1529
|
for (const [name, rules] of Object.entries(parsed.profiles)) {
|
|
928
1530
|
if (!record3(rules) || Object.keys(rules).length === 0) {
|
|
929
1531
|
throw new Error(`Policy profile ${name} must list packages`);
|
|
@@ -935,14 +1537,15 @@ function parsePolicy(raw, source) {
|
|
|
935
1537
|
}
|
|
936
1538
|
versions[packageName] = version;
|
|
937
1539
|
}
|
|
938
|
-
|
|
1540
|
+
profiles3[name] = versions;
|
|
939
1541
|
}
|
|
940
1542
|
return {
|
|
941
1543
|
schemaVersion: 2,
|
|
942
1544
|
policyVersion: parsed.policyVersion,
|
|
943
|
-
profiles:
|
|
1545
|
+
profiles: profiles3,
|
|
1546
|
+
...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
|
|
944
1547
|
source,
|
|
945
|
-
sha256:
|
|
1548
|
+
sha256: createHash2("sha256").update(raw).digest("hex")
|
|
946
1549
|
};
|
|
947
1550
|
}
|
|
948
1551
|
async function loadPolicy(source) {
|
|
@@ -959,7 +1562,141 @@ async function loadPolicy(source) {
|
|
|
959
1562
|
}
|
|
960
1563
|
return parsePolicy(await response.text(), source);
|
|
961
1564
|
}
|
|
962
|
-
return parsePolicy(await
|
|
1565
|
+
return parsePolicy(await readFile8(resolve7(source), "utf8"), source);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// src/governance/assess.ts
|
|
1569
|
+
var notEvaluated = [
|
|
1570
|
+
"vulnerability databases",
|
|
1571
|
+
"licenses",
|
|
1572
|
+
"secret detection",
|
|
1573
|
+
"test execution",
|
|
1574
|
+
"runtime behavior",
|
|
1575
|
+
"branch protection",
|
|
1576
|
+
"repository access controls",
|
|
1577
|
+
"cross-repository rollout status"
|
|
1578
|
+
];
|
|
1579
|
+
function statusOf(statuses) {
|
|
1580
|
+
if (statuses.includes("incomplete")) return "incomplete";
|
|
1581
|
+
if (statuses.includes("fail")) return "fail";
|
|
1582
|
+
return "pass";
|
|
1583
|
+
}
|
|
1584
|
+
function escapeHtml2(value) {
|
|
1585
|
+
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
1586
|
+
}
|
|
1587
|
+
function renderAssessmentHtml(report) {
|
|
1588
|
+
const apps = report.apps.map((app) => {
|
|
1589
|
+
const domains = Object.entries(app.domains).map(([name, domain]) => `<tr><th scope="row">${escapeHtml2(name)}</th><td class="${domain.status}">${escapeHtml2(domain.status)}</td><td>${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : "No issues"}</td></tr>`).join("");
|
|
1590
|
+
const architecture = app.domains.architecture;
|
|
1591
|
+
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("") ?? "";
|
|
1592
|
+
const findings = 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("");
|
|
1593
|
+
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>` : ""}${findings ? `<details><summary>Architecture findings (${architecture.findings.length})</summary><div class="scroll"><table><thead><tr><th>Severity</th><th>Rule</th><th>Location</th><th>Evidence</th></tr></thead><tbody>${findings}</tbody></table></div></details>` : ""}</section>`;
|
|
1594
|
+
}).join("");
|
|
1595
|
+
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>
|
|
1596
|
+
:root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#f6f7f5;color:#1b2925}body{max-width:1100px;margin:auto;padding:2rem 1rem}header,.app,.meta{background:white;border:1px solid #d9e1dc;border-radius:12px;padding:1.4rem;margin:0 0 1.2rem}h1{font-size:clamp(2rem,5vw,3.5rem);line-height:1.08;margin:.3rem 0 1rem}h2{font-size:1.35rem;margin:0 0 .75rem}p{margin:.5rem 0}code{overflow-wrap:anywhere}.pass{color:#146c43;font-weight:700}.fail{color:#b3242b;font-weight:700}.incomplete{color:#8a5500;font-weight:700}.scroll{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d9e1dc;padding:.8rem}th:first-child{width:9rem}ul{padding-left:1.2rem;margin:.1rem 0}li+li{margin-top:.3rem}small{color:#50645c}details{border-top:1px solid #d9e1dc;margin-top:1rem;padding-top:1rem}summary{cursor:pointer;font-weight:650}details[open] summary{margin-bottom:.6rem}
|
|
1597
|
+
</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)</p></header><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(report.inventory.source)}</code></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>
|
|
1598
|
+
`;
|
|
1599
|
+
}
|
|
1600
|
+
async function assessGovernance(options) {
|
|
1601
|
+
const scanRoot = resolve8(options.scanRoot);
|
|
1602
|
+
const workspaceRoot = resolve8(options.workspaceRoot ?? scanRoot);
|
|
1603
|
+
const scanRelative = relative6(workspaceRoot, scanRoot);
|
|
1604
|
+
if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
|
|
1605
|
+
if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
|
|
1606
|
+
const assessment = parseAssessmentPolicy(options.policy.assessment);
|
|
1607
|
+
const outputDirectory = resolve8(options.outputDirectory ?? join9(scanRoot, ".nklattice-governance"));
|
|
1608
|
+
const inventorySource = resolve8(options.inventory ?? join9(scanRoot, ".nklattice-apps.json"));
|
|
1609
|
+
let inventory;
|
|
1610
|
+
const issues = [];
|
|
1611
|
+
try {
|
|
1612
|
+
inventory = await loadInventory(inventorySource, scanRoot);
|
|
1613
|
+
} catch (error) {
|
|
1614
|
+
issues.push(`Inventory: ${error instanceof Error ? error.message : String(error)}`);
|
|
1615
|
+
}
|
|
1616
|
+
let apps = [];
|
|
1617
|
+
try {
|
|
1618
|
+
apps = await discoverApps(scanRoot, workspaceRoot);
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
issues.push(`Discovery: ${error instanceof Error ? error.message : String(error)}`);
|
|
1621
|
+
}
|
|
1622
|
+
if (apps.length === 0) issues.push("No managed applications discovered");
|
|
1623
|
+
if (inventory) issues.push(...compareInventory(inventory, apps, scanRoot));
|
|
1624
|
+
if (options.expectedApps !== void 0 && apps.length !== options.expectedApps) {
|
|
1625
|
+
issues.push(`Expected ${options.expectedApps} managed apps; discovered ${apps.length}`);
|
|
1626
|
+
}
|
|
1627
|
+
const results = [];
|
|
1628
|
+
for (const app of apps) {
|
|
1629
|
+
const appPath = relative6(scanRoot, app.directory).replaceAll("\\", "/") || ".";
|
|
1630
|
+
const inventoryIssues = issues.filter((issue) => issue.includes(appPath) || issue.startsWith("Inventory:") || issue.startsWith("Discovery:"));
|
|
1631
|
+
const inventoryDomain = { status: inventoryIssues.length ? "incomplete" : "pass", issues: inventoryIssues };
|
|
1632
|
+
const dependencyDomain = { status: "pass", issues: [], findings: [] };
|
|
1633
|
+
try {
|
|
1634
|
+
dependencyDomain.findings = await checkApp(app, workspaceRoot, options.policy);
|
|
1635
|
+
if (dependencyDomain.findings.length) {
|
|
1636
|
+
dependencyDomain.status = "fail";
|
|
1637
|
+
dependencyDomain.issues = dependencyDomain.findings.map((item) => `${item.packageName}: ${item.reason}; required ${item.expected}, declared ${item.declared ?? "missing"}, locked ${item.locked ?? "missing"}`);
|
|
1638
|
+
}
|
|
1639
|
+
} catch (error) {
|
|
1640
|
+
dependencyDomain.status = "incomplete";
|
|
1641
|
+
dependencyDomain.issues = [error instanceof Error ? error.message : String(error)];
|
|
1642
|
+
}
|
|
1643
|
+
const architectureDomain = {
|
|
1644
|
+
status: "pass",
|
|
1645
|
+
issues: [],
|
|
1646
|
+
filesScanned: 0,
|
|
1647
|
+
localEdges: 0,
|
|
1648
|
+
errors: 0,
|
|
1649
|
+
findings: [],
|
|
1650
|
+
diagnostics: []
|
|
1651
|
+
};
|
|
1652
|
+
try {
|
|
1653
|
+
const scan = await scanProjectDetailed(app.directory, assessment.architecture.analysis.aliases);
|
|
1654
|
+
architectureDomain.filesScanned = scan.modules.length;
|
|
1655
|
+
architectureDomain.diagnostics = scan.diagnostics;
|
|
1656
|
+
const graph = buildGraph(scan.modules);
|
|
1657
|
+
architectureDomain.localEdges = graph.edges.length;
|
|
1658
|
+
architectureDomain.findings = evaluateRules(graph, assessment.architecture.analysis);
|
|
1659
|
+
const score = scoreFindings(architectureDomain.findings, assessment.architecture.analysis);
|
|
1660
|
+
architectureDomain.score = score.score;
|
|
1661
|
+
architectureDomain.grade = score.grade;
|
|
1662
|
+
architectureDomain.scoreBreakdown = score;
|
|
1663
|
+
architectureDomain.errors = architectureDomain.findings.filter((item) => item.severity === "error").length;
|
|
1664
|
+
if (scan.modules.length === 0) architectureDomain.diagnostics.push("No supported source files found");
|
|
1665
|
+
if (architectureDomain.diagnostics.length) {
|
|
1666
|
+
architectureDomain.status = "incomplete";
|
|
1667
|
+
architectureDomain.issues.push(...architectureDomain.diagnostics);
|
|
1668
|
+
}
|
|
1669
|
+
if (score.score < assessment.architecture.minimumScore) {
|
|
1670
|
+
architectureDomain.issues.push(`Score ${score.score} is below minimum ${assessment.architecture.minimumScore}`);
|
|
1671
|
+
}
|
|
1672
|
+
if (architectureDomain.errors > assessment.architecture.maximumErrors) {
|
|
1673
|
+
architectureDomain.issues.push(`${architectureDomain.errors} error findings exceed maximum ${assessment.architecture.maximumErrors}`);
|
|
1674
|
+
}
|
|
1675
|
+
const blocked = architectureDomain.findings.filter((item) => assessment.architecture.blockedRuleIds.includes(item.ruleId));
|
|
1676
|
+
for (const finding of blocked) architectureDomain.issues.push(`Blocked ${finding.ruleId} at ${finding.file}:${finding.line}: ${finding.evidence}`);
|
|
1677
|
+
if (architectureDomain.status !== "incomplete" && architectureDomain.issues.length) architectureDomain.status = "fail";
|
|
1678
|
+
} catch (error) {
|
|
1679
|
+
architectureDomain.status = "incomplete";
|
|
1680
|
+
architectureDomain.issues.push(error instanceof Error ? error.message : String(error));
|
|
1681
|
+
}
|
|
1682
|
+
const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain };
|
|
1683
|
+
results.push({ path: appPath, profile: app.profile, status: statusOf(Object.values(domains).map((domain) => domain.status)), domains });
|
|
1684
|
+
}
|
|
1685
|
+
const report = {
|
|
1686
|
+
schema: "urn:nklattice:governance-assessment:1",
|
|
1687
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1688
|
+
status: issues.length ? "incomplete" : statusOf(results.map((app) => app.status)),
|
|
1689
|
+
policy: { version: options.policy.policyVersion, source: options.policy.source, sha256: options.policy.sha256 },
|
|
1690
|
+
inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
|
|
1691
|
+
scope: { domains: ["explicit app inventory", "dependency minimums", "source architecture"], notEvaluated },
|
|
1692
|
+
issues,
|
|
1693
|
+
apps: results
|
|
1694
|
+
};
|
|
1695
|
+
await mkdir3(outputDirectory, { recursive: true });
|
|
1696
|
+
await writeFile2(join9(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
|
|
1697
|
+
`);
|
|
1698
|
+
await writeFile2(join9(outputDirectory, "report.html"), renderAssessmentHtml(report));
|
|
1699
|
+
return report;
|
|
963
1700
|
}
|
|
964
1701
|
|
|
965
1702
|
// src/runtime.ts
|
|
@@ -981,16 +1718,16 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
981
1718
|
}
|
|
982
1719
|
let exitCode = 0;
|
|
983
1720
|
const program = new Command();
|
|
984
|
-
program.name("lattice").description("Architecture analysis and dependency governance for React teams").version(
|
|
1721
|
+
program.name("lattice").description("Architecture analysis and dependency governance for React teams").version(package_default.version);
|
|
985
1722
|
program.configureOutput({ writeOut: output.out, writeErr: output.err });
|
|
986
1723
|
program.exitOverride();
|
|
987
1724
|
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 =
|
|
1725
|
+
const root = resolve9(path ?? ".");
|
|
1726
|
+
const destination = resolve9(flags.output ?? join10(root, ".nklattice-report"));
|
|
990
1727
|
const report = await analyzeProject(root, { outputDirectory: destination });
|
|
991
1728
|
output.out(`NKLattice analyzed ${report.graph.modules.length} modules. Score ${report.score.score} (${report.score.grade}); ${report.findings.length} findings.
|
|
992
1729
|
`);
|
|
993
|
-
output.out(`Reports: ${
|
|
1730
|
+
output.out(`Reports: ${join10(destination, "report.html")} and ${join10(destination, "analysis.json")}
|
|
994
1731
|
`);
|
|
995
1732
|
});
|
|
996
1733
|
program.command("doctor [path]").description("Check whether a directory contains supported source files").action(async (path) => {
|
|
@@ -1000,19 +1737,20 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1000
1737
|
`);
|
|
1001
1738
|
});
|
|
1002
1739
|
program.command("init [path]").description("Create a project configuration file").action(async (path) => {
|
|
1003
|
-
const destination =
|
|
1004
|
-
await
|
|
1740
|
+
const destination = join10(resolve9(path ?? "."), ".nklattice.json");
|
|
1741
|
+
await writeFile3(destination, '{\n "analysis": {},\n "policies": []\n}\n', { flag: "wx" });
|
|
1005
1742
|
output.out(`Created ${destination}
|
|
1006
1743
|
`);
|
|
1007
1744
|
});
|
|
1008
|
-
program.command("governance").description("
|
|
1745
|
+
const governance = program.command("governance").description("Assess centrally approved project governance rules");
|
|
1746
|
+
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
1747
|
let expectedApps;
|
|
1010
1748
|
if (flags.expectApps !== void 0) {
|
|
1011
1749
|
if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
|
|
1012
1750
|
expectedApps = Number(flags.expectApps);
|
|
1013
1751
|
if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
|
|
1014
1752
|
}
|
|
1015
|
-
const policyPath = flags.policy ??
|
|
1753
|
+
const policyPath = flags.policy ?? resolve9(dirname3(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
1016
1754
|
const policy = await loadPolicy(policyPath);
|
|
1017
1755
|
const result = await checkGovernance({
|
|
1018
1756
|
scanRoot: path ?? ".",
|
|
@@ -1038,6 +1776,41 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1038
1776
|
}
|
|
1039
1777
|
if (result.findings.length > 0) exitCode = 7;
|
|
1040
1778
|
});
|
|
1779
|
+
governance.command("assess [path]").description("Assess app inventory, dependencies, and source architecture").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) => {
|
|
1780
|
+
let expectedApps;
|
|
1781
|
+
if (flags.expectApps !== void 0) {
|
|
1782
|
+
if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
|
|
1783
|
+
expectedApps = Number(flags.expectApps);
|
|
1784
|
+
if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
|
|
1785
|
+
}
|
|
1786
|
+
const scanRoot = resolve9(path ?? ".");
|
|
1787
|
+
const policyPath = flags.policy ?? resolve9(dirname3(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
1788
|
+
const report = await assessGovernance({
|
|
1789
|
+
scanRoot,
|
|
1790
|
+
workspaceRoot: flags.workspaceRoot,
|
|
1791
|
+
inventory: flags.inventory,
|
|
1792
|
+
expectedApps,
|
|
1793
|
+
outputDirectory: flags.output,
|
|
1794
|
+
policy: await loadPolicy(policyPath)
|
|
1795
|
+
});
|
|
1796
|
+
if (flags.json) output.out(`${JSON.stringify(report, null, 2)}
|
|
1797
|
+
`);
|
|
1798
|
+
else {
|
|
1799
|
+
output.out(`Governance ${report.status}: ${report.apps.length} app(s); policy ${report.policy.version} (${report.policy.sha256}).
|
|
1800
|
+
`);
|
|
1801
|
+
for (const issue of report.issues) output.out(`Inventory: ${issue}
|
|
1802
|
+
`);
|
|
1803
|
+
for (const app of report.apps) {
|
|
1804
|
+
output.out(`${app.path} (${app.profile}): ${app.status}; dependencies ${app.domains.dependencies.status}; architecture ${app.domains.architecture.status} (${app.domains.architecture.score ?? "unavailable"}).
|
|
1805
|
+
`);
|
|
1806
|
+
for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
|
|
1807
|
+
`);
|
|
1808
|
+
}
|
|
1809
|
+
output.out(`Reports: ${resolve9(flags.output ?? join10(scanRoot, ".nklattice-governance"))}
|
|
1810
|
+
`);
|
|
1811
|
+
}
|
|
1812
|
+
exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
|
|
1813
|
+
});
|
|
1041
1814
|
try {
|
|
1042
1815
|
await program.parseAsync(arguments_, { from: "user" });
|
|
1043
1816
|
} catch (error) {
|
|
@@ -1049,7 +1822,7 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1049
1822
|
}
|
|
1050
1823
|
return exitCode;
|
|
1051
1824
|
}
|
|
1052
|
-
if (process.argv[1] && realpathSync(
|
|
1825
|
+
if (process.argv[1] && realpathSync(resolve9(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
1053
1826
|
runCli(process.argv.slice(2)).then((code) => {
|
|
1054
1827
|
process.exitCode = code;
|
|
1055
1828
|
});
|