@anhcodo13/lattice 0.1.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 +36 -17
- package/dist/cli.js +933 -146
- package/dist/index.d.ts +119 -2
- package/dist/index.js +974 -58
- package/governance/policy.json +26 -13
- package/package.json +4 -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)}`] };
|
|
559
938
|
}
|
|
560
|
-
const json = ts2.parseConfigFileTextToJson(path, content);
|
|
561
|
-
if (json.error) return {};
|
|
562
|
-
const parsed = ts2.parseJsonConfigFileContent(json.config, ts2.sys, root);
|
|
563
|
-
return parsed.options;
|
|
564
939
|
}
|
|
565
|
-
|
|
566
|
-
|
|
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);
|
|
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);
|
|
@@ -663,6 +1160,7 @@ async function analyzeProject(projectRoot, options = {}) {
|
|
|
663
1160
|
|
|
664
1161
|
// src/governance/check.ts
|
|
665
1162
|
import { resolve as resolve5, relative as relative4, sep as sep3 } from "path";
|
|
1163
|
+
import { satisfies, subset, valid, validRange } from "semver";
|
|
666
1164
|
|
|
667
1165
|
// src/governance/discover.ts
|
|
668
1166
|
import { readFile as readFile5 } from "fs/promises";
|
|
@@ -706,8 +1204,9 @@ async function discoverApps(scanRoot, workspaceRoot) {
|
|
|
706
1204
|
cwd: scanRoot,
|
|
707
1205
|
onlyFiles: true,
|
|
708
1206
|
unique: true,
|
|
1207
|
+
dot: true,
|
|
709
1208
|
followSymbolicLinks: false,
|
|
710
|
-
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/.next/**", "**/coverage/**"]
|
|
1209
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/out/**", "**/.next/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**"]
|
|
711
1210
|
});
|
|
712
1211
|
const apps = [];
|
|
713
1212
|
for (const path of manifests.sort()) {
|
|
@@ -734,7 +1233,7 @@ async function discoverApps(scanRoot, workspaceRoot) {
|
|
|
734
1233
|
|
|
735
1234
|
// src/governance/lockfiles.ts
|
|
736
1235
|
import { access as access2, readFile as readFile6 } from "fs/promises";
|
|
737
|
-
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";
|
|
738
1237
|
import YAML from "yaml";
|
|
739
1238
|
function record2(value) {
|
|
740
1239
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -763,7 +1262,7 @@ async function findLockfile(appDirectory, workspaceRoot) {
|
|
|
763
1262
|
if (hasNpm) return { path: npm, kind: "npm", root: directory };
|
|
764
1263
|
if (hasPnpm) return { path: pnpm, kind: "pnpm", root: directory };
|
|
765
1264
|
if (directory === boundary) break;
|
|
766
|
-
directory =
|
|
1265
|
+
directory = dirname2(directory);
|
|
767
1266
|
}
|
|
768
1267
|
throw new Error(`No lockfile found for ${appDirectory}`);
|
|
769
1268
|
}
|
|
@@ -844,6 +1343,39 @@ async function readResolution(app, workspaceRoot, name) {
|
|
|
844
1343
|
}
|
|
845
1344
|
|
|
846
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
|
+
}
|
|
847
1379
|
async function checkGovernance(options) {
|
|
848
1380
|
const scanRoot = resolve5(options.scanRoot);
|
|
849
1381
|
const workspaceRoot = resolve5(options.workspaceRoot ?? scanRoot);
|
|
@@ -853,29 +1385,7 @@ async function checkGovernance(options) {
|
|
|
853
1385
|
}
|
|
854
1386
|
const apps = await discoverApps(scanRoot, workspaceRoot);
|
|
855
1387
|
if (apps.length === 0) throw new Error("No managed applications found");
|
|
856
|
-
const findings =
|
|
857
|
-
for (const app of apps) {
|
|
858
|
-
const requirements = options.policy.profiles[app.profile];
|
|
859
|
-
if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
|
|
860
|
-
for (const [packageName, expected] of Object.entries(requirements)) {
|
|
861
|
-
const declared = app.dependencies[packageName];
|
|
862
|
-
const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
|
|
863
|
-
const reasons = [];
|
|
864
|
-
if (declared !== expected) reasons.push("manifest version differs");
|
|
865
|
-
if (resolution.specifier !== declared) reasons.push("lockfile specifier differs");
|
|
866
|
-
if (resolution.version !== expected) reasons.push("lockfile resolution differs");
|
|
867
|
-
if (reasons.length) {
|
|
868
|
-
findings.push({
|
|
869
|
-
app: app.relativePath,
|
|
870
|
-
packageName,
|
|
871
|
-
expected,
|
|
872
|
-
declared,
|
|
873
|
-
locked: resolution.version,
|
|
874
|
-
reason: reasons.join("; ")
|
|
875
|
-
});
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
}
|
|
1388
|
+
const findings = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
|
|
879
1389
|
return {
|
|
880
1390
|
appsChecked: apps.length,
|
|
881
1391
|
findings,
|
|
@@ -885,14 +1395,120 @@ async function checkGovernance(options) {
|
|
|
885
1395
|
};
|
|
886
1396
|
}
|
|
887
1397
|
|
|
888
|
-
// 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
|
|
889
1403
|
import { createHash } from "crypto";
|
|
890
|
-
import { readFile as readFile7 } from "fs/promises";
|
|
891
|
-
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";
|
|
1464
|
+
import { valid as valid2 } from "semver";
|
|
892
1465
|
var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
1466
|
+
var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
893
1467
|
function record3(value) {
|
|
894
1468
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
895
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
|
+
}
|
|
896
1512
|
function parsePolicy(raw, source) {
|
|
897
1513
|
let parsed;
|
|
898
1514
|
try {
|
|
@@ -900,35 +1516,36 @@ function parsePolicy(raw, source) {
|
|
|
900
1516
|
} catch {
|
|
901
1517
|
throw new Error(`Policy at ${source} is not valid JSON`);
|
|
902
1518
|
}
|
|
903
|
-
if (!record3(parsed) || parsed.schemaVersion !==
|
|
904
|
-
throw new Error("Policy schemaVersion must be
|
|
1519
|
+
if (!record3(parsed) || parsed.schemaVersion !== 2) {
|
|
1520
|
+
throw new Error("Policy schemaVersion must be 2");
|
|
905
1521
|
}
|
|
906
|
-
if (typeof parsed.policyVersion !== "string" || !exactVersion.test(parsed.policyVersion)) {
|
|
1522
|
+
if (typeof parsed.policyVersion !== "string" || !exactVersion.test(parsed.policyVersion) || valid2(parsed.policyVersion) === null) {
|
|
907
1523
|
throw new Error("Policy policyVersion must be an exact version");
|
|
908
1524
|
}
|
|
909
1525
|
if (!record3(parsed.profiles) || Object.keys(parsed.profiles).length === 0) {
|
|
910
1526
|
throw new Error("Policy profiles must be a nonempty object");
|
|
911
1527
|
}
|
|
912
|
-
const
|
|
1528
|
+
const profiles3 = {};
|
|
913
1529
|
for (const [name, rules] of Object.entries(parsed.profiles)) {
|
|
914
1530
|
if (!record3(rules) || Object.keys(rules).length === 0) {
|
|
915
1531
|
throw new Error(`Policy profile ${name} must list packages`);
|
|
916
1532
|
}
|
|
917
1533
|
const versions = {};
|
|
918
1534
|
for (const [packageName, version] of Object.entries(rules)) {
|
|
919
|
-
if (typeof version !== "string" || !
|
|
920
|
-
throw new Error(`Policy
|
|
1535
|
+
if (typeof version !== "string" || !minimumVersion.test(version) || valid2(version.slice(2)) === null) {
|
|
1536
|
+
throw new Error(`Policy minimum version (>=X.Y.Z) required for ${packageName} in ${name}`);
|
|
921
1537
|
}
|
|
922
1538
|
versions[packageName] = version;
|
|
923
1539
|
}
|
|
924
|
-
|
|
1540
|
+
profiles3[name] = versions;
|
|
925
1541
|
}
|
|
926
1542
|
return {
|
|
927
|
-
schemaVersion:
|
|
1543
|
+
schemaVersion: 2,
|
|
928
1544
|
policyVersion: parsed.policyVersion,
|
|
929
|
-
profiles:
|
|
1545
|
+
profiles: profiles3,
|
|
1546
|
+
...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
|
|
930
1547
|
source,
|
|
931
|
-
sha256:
|
|
1548
|
+
sha256: createHash2("sha256").update(raw).digest("hex")
|
|
932
1549
|
};
|
|
933
1550
|
}
|
|
934
1551
|
async function loadPolicy(source) {
|
|
@@ -945,7 +1562,141 @@ async function loadPolicy(source) {
|
|
|
945
1562
|
}
|
|
946
1563
|
return parsePolicy(await response.text(), source);
|
|
947
1564
|
}
|
|
948
|
-
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;
|
|
949
1700
|
}
|
|
950
1701
|
|
|
951
1702
|
// src/runtime.ts
|
|
@@ -967,16 +1718,16 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
967
1718
|
}
|
|
968
1719
|
let exitCode = 0;
|
|
969
1720
|
const program = new Command();
|
|
970
|
-
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);
|
|
971
1722
|
program.configureOutput({ writeOut: output.out, writeErr: output.err });
|
|
972
1723
|
program.exitOverride();
|
|
973
1724
|
program.command("analyze [path]").description("Analyze local architecture and write offline reports").option("--output <directory>", "Output directory").action(async (path, flags) => {
|
|
974
|
-
const root =
|
|
975
|
-
const destination =
|
|
1725
|
+
const root = resolve9(path ?? ".");
|
|
1726
|
+
const destination = resolve9(flags.output ?? join10(root, ".nklattice-report"));
|
|
976
1727
|
const report = await analyzeProject(root, { outputDirectory: destination });
|
|
977
1728
|
output.out(`NKLattice analyzed ${report.graph.modules.length} modules. Score ${report.score.score} (${report.score.grade}); ${report.findings.length} findings.
|
|
978
1729
|
`);
|
|
979
|
-
output.out(`Reports: ${
|
|
1730
|
+
output.out(`Reports: ${join10(destination, "report.html")} and ${join10(destination, "analysis.json")}
|
|
980
1731
|
`);
|
|
981
1732
|
});
|
|
982
1733
|
program.command("doctor [path]").description("Check whether a directory contains supported source files").action(async (path) => {
|
|
@@ -986,19 +1737,20 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
986
1737
|
`);
|
|
987
1738
|
});
|
|
988
1739
|
program.command("init [path]").description("Create a project configuration file").action(async (path) => {
|
|
989
|
-
const destination =
|
|
990
|
-
await
|
|
1740
|
+
const destination = join10(resolve9(path ?? "."), ".nklattice.json");
|
|
1741
|
+
await writeFile3(destination, '{\n "analysis": {},\n "policies": []\n}\n', { flag: "wx" });
|
|
991
1742
|
output.out(`Created ${destination}
|
|
992
1743
|
`);
|
|
993
1744
|
});
|
|
994
|
-
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) => {
|
|
995
1747
|
let expectedApps;
|
|
996
1748
|
if (flags.expectApps !== void 0) {
|
|
997
1749
|
if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
|
|
998
1750
|
expectedApps = Number(flags.expectApps);
|
|
999
1751
|
if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
|
|
1000
1752
|
}
|
|
1001
|
-
const policyPath = flags.policy ??
|
|
1753
|
+
const policyPath = flags.policy ?? resolve9(dirname3(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
1002
1754
|
const policy = await loadPolicy(policyPath);
|
|
1003
1755
|
const result = await checkGovernance({
|
|
1004
1756
|
scanRoot: path ?? ".",
|
|
@@ -1018,12 +1770,47 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1018
1770
|
output.out(`${result.appsChecked} application${result.appsChecked === 1 ? "" : "s"} checked; ${result.findings.length} dependency mismatch${result.findings.length === 1 ? "" : "es"}.
|
|
1019
1771
|
`);
|
|
1020
1772
|
for (const finding of result.findings) {
|
|
1021
|
-
output.out(`${finding.app}: ${finding.packageName}
|
|
1773
|
+
output.out(`${finding.app}: ${finding.packageName} required ${finding.expected}; declared ${finding.declared ?? "missing"}; lockfile specifier ${finding.lockfileSpecifier ?? "missing"}; locked ${finding.locked ?? "missing"} (${finding.reason}).
|
|
1022
1774
|
`);
|
|
1023
1775
|
}
|
|
1024
1776
|
}
|
|
1025
1777
|
if (result.findings.length > 0) exitCode = 7;
|
|
1026
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
|
+
});
|
|
1027
1814
|
try {
|
|
1028
1815
|
await program.parseAsync(arguments_, { from: "user" });
|
|
1029
1816
|
} catch (error) {
|
|
@@ -1035,7 +1822,7 @@ async function runCli(arguments_, output = defaultOutput) {
|
|
|
1035
1822
|
}
|
|
1036
1823
|
return exitCode;
|
|
1037
1824
|
}
|
|
1038
|
-
if (process.argv[1] && realpathSync(
|
|
1825
|
+
if (process.argv[1] && realpathSync(resolve9(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
1039
1826
|
runCli(process.argv.slice(2)).then((code) => {
|
|
1040
1827
|
process.exitCode = code;
|
|
1041
1828
|
});
|