@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/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 writeFile2 } from "fs/promises";
4
+ import { writeFile as writeFile3 } from "fs/promises";
5
5
  import { realpathSync } from "fs";
6
6
  import { fileURLToPath } from "url";
7
- import { dirname as dirname2, join as join8, resolve as resolve7 } from "path";
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
- async function loadAnalysisConfig(root) {
41
- const path = join(root, ".nklattice.json");
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
- return { thresholds, weights, policies };
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
206
259
  }
207
- function renderHtml(report) {
208
- const data = JSON.stringify(report).replaceAll("<", "\\u003c");
209
- const title = escapeHtml(report.project.name);
210
- return `<!doctype html>
211
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
212
- <title>${title} \xB7 NKLattice architecture report</title>
213
- <style>
214
- :root{color-scheme:dark;--bg:#0b1118;--panel:#111c27;--panel2:#172532;--line:#284150;--text:#e6f0f3;--muted:#99adb9;--accent:#69e0bd;--warn:#f3bd72;--error:#fa8e9a}
215
- *{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 5% 0%,#17343b 0,transparent 28%),var(--bg);color:var(--text);font:15px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
216
- button,input,select{font:inherit}button,select,input{color:var(--text);background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:8px 11px}button{cursor:pointer}button:hover,button:focus-visible{border-color:var(--accent)}a{color:var(--accent)}
217
- .layout{display:grid;grid-template-columns:210px minmax(0,1fr);min-height:100vh}.rail{border-right:1px solid var(--line);background:#0c1720;padding:28px 18px;position:sticky;top:0;height:100vh}.brand{font-size:21px;font-weight:800;letter-spacing:-.05em;color:var(--accent)}.rail p{color:var(--muted);font-size:12px;margin:0 0 32px}.rail a{display:block;padding:9px 11px;border-radius:8px;text-decoration:none;color:var(--muted)}.rail a:hover,.rail a:focus-visible{background:var(--panel2);color:var(--text)}main{max-width:1280px;width:100%;margin:auto;padding:34px clamp(20px,4vw,64px) 80px}.eyebrow{text-transform:uppercase;letter-spacing:.16em;font-size:11px;color:var(--accent);font-weight:800}h1{font-size:clamp(30px,4vw,53px);line-height:1.07;margin:8px 0 10px;letter-spacing:-.05em}h2{font-size:24px;letter-spacing:-.035em;margin:0 0 16px}h3{margin:0 0 5px;font-size:16px}.muted{color:var(--muted)}.summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:30px 0 52px}.metric,.panel{border:1px solid var(--line);background:linear-gradient(145deg,var(--panel),#0e1a22);border-radius:15px;padding:20px}.metric strong{display:block;font-size:29px;line-height:1.1}.metric span{color:var(--muted);font-size:12px}.score{color:var(--accent)}section{scroll-margin-top:24px;margin:0 0 55px}.split{display:grid;grid-template-columns:1fr 1fr;gap:18px}.bars{display:grid;gap:12px}.barhead{display:flex;justify-content:space-between;font-size:13px}.bar{height:8px;border-radius:100px;background:#243443;overflow:hidden}.bar i{display:block;height:100%;background:var(--accent);border-radius:100px}.panel p{margin:3px 0 12px}.controls{display:flex;flex-wrap:wrap;gap:9px;margin-bottom:15px}.controls input{flex:1;min-width:180px}.findings{display:grid;gap:8px}.finding{border:1px solid var(--line);border-radius:11px;padding:13px 16px;background:var(--panel)}.finding-head{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.finding p{margin:7px 0}.finding small{color:var(--muted)}.chip{font-size:11px;font-weight:800;padding:2px 7px;border-radius:5px;background:#294136;color:var(--accent)}.chip.error{background:#51313a;color:var(--error)}.chip.warning{background:#4c3d27;color:var(--warn)}.chip.info{background:#25445b;color:#90d0fa}.path{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;color:var(--muted);overflow-wrap:anywhere}.graph-wrap{overflow:auto}.graph{width:100%;height:auto;min-height:360px;background:#0b1820;border:1px solid var(--line);border-radius:12px}.graph line{stroke:#365565;stroke-width:1}.graph circle{fill:#173e45;stroke:var(--accent);stroke-width:2;cursor:pointer}.graph circle:hover,.graph circle:focus{fill:#2c766e}.graph text{fill:var(--text);font-size:10px;pointer-events:none}.list{list-style:none;margin:0;padding:0}.list li{padding:12px 0;border-bottom:1px solid var(--line)}.list li:last-child{border:0}.empty{color:var(--muted);padding:16px}.footer{border-top:1px solid var(--line);padding-top:24px;color:var(--muted);font-size:12px}
218
- @media(max-width:850px){.layout{display:block}.rail{height:auto;position:static;display:flex;flex-wrap:wrap;align-items:center;gap:4px;border-right:0;border-bottom:1px solid var(--line);padding:12px 16px}.rail p{display:none}.rail a{display:inline-block;padding:6px 8px;font-size:12px}.summary{grid-template-columns:repeat(2,1fr)}.split{grid-template-columns:1fr}}
219
- @media(max-width:520px){.summary{grid-template-columns:1fr 1fr}.metric{padding:14px}.metric strong{font-size:24px}main{padding:25px 16px 55px}.rail a{font-size:11px}}
220
- </style></head><body>
221
- <div class="layout"><nav class="rail" aria-label="Report sections"><div class="brand">NKLattice</div><p>Architecture report</p><a href="#overview">Overview</a><a href="#score">Score</a><a href="#findings">Findings</a><a href="#hotspots">Hotspots</a><a href="#graph">Graph</a><a href="#roadmap">Roadmap</a></nav>
222
- <main><header id="overview"><div class="eyebrow">Local architecture analysis</div><h1>${title}</h1><p class="muted">Generated <time id="generated-at"></time>. Findings are based on static analysis; score recovery is an estimate.</p></header>
223
- <div class="summary"><div class="metric"><strong class="score" id="score-value"></strong><span>Architecture score</span></div><div class="metric"><strong id="grade-value"></strong><span>Grade</span></div><div class="metric"><strong id="module-count"></strong><span>Modules</span></div><div class="metric"><strong id="finding-count"></strong><span>Findings</span></div></div>
224
- <section id="score"><h2>Score breakdown</h2><div class="split"><div class="panel"><div id="category-bars" class="bars"></div></div><div class="panel"><h3>How the score is calculated</h3><p>Starts at 100. Category deductions are weighted, then bounded maintenance and critical-risk adjustments are applied.</p><p id="score-formula" class="path"></p><p class="muted">Category weights and thresholds can be changed in the project configuration.</p></div></div></section>
225
- <section id="findings"><h2>Findings</h2><div class="controls"><label><span class="muted">Severity </span><select id="findings-filter"><option value="all">All</option><option value="error">Error</option><option value="warning">Warning</option><option value="info">Info</option></select></label><label><span class="muted">Category </span><select id="category-filter"><option value="all">All</option></select></label><input id="findings-search" type="search" aria-label="Search findings" placeholder="Search file, rule, or evidence"></div><div id="findings-list" class="findings"></div></section>
226
- <section id="hotspots"><h2>Hotspots and quick wins</h2><div class="split"><div class="panel"><h3>Hotspots</h3><ul id="hotspot-list" class="list"></ul></div><div class="panel"><h3>Quick wins</h3><ul id="quickwin-list" class="list"></ul></div></div></section>
227
- <section id="graph"><h2>Local dependency graph</h2><p class="muted">Select a node to inspect it. The canvas shows up to 32 connected modules; all modules and edges remain in analysis.json.</p><div class="graph-wrap"><svg id="module-graph" class="graph" viewBox="0 0 900 520" role="img" aria-label="Interactive local module dependency graph"></svg></div><p id="graph-detail" class="path" aria-live="polite">Select a module.</p></section>
228
- <section id="roadmap"><h2>Refactoring roadmap</h2><ol id="roadmap-list" class="list"></ol></section>
229
- <footer class="footer">NKLattice \xB7 Offline report \xB7 No source upload or remote scripts</footer></main></div>
230
- <script type="application/json" id="report-data">${data}</script>
231
- <script>
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) => { const node = document.createElement(tag); if(className) node.className=className; node.textContent=String(value); parent.append(node); return node; };
237
- text('generated-at', new Date(report.generatedAt).toLocaleString());
238
- text('score-value', report.score.score.toFixed(2)); text('grade-value', report.score.grade);
239
- text('module-count', report.graph.modules.length); text('finding-count', report.findings.length);
240
- text('score-formula', '100 \u2212 ' + report.score.weightedDeduction + ' weighted \u2212 ' + report.score.maintenanceAdjustment + ' maintenance \u2212 ' + report.score.criticalAdjustment + ' critical = ' + report.score.score);
241
- for(const category of report.score.categories){
242
- const wrap=add(byId('category-bars'),'div',''); const head=add(wrap,'div','', 'barhead');
243
- add(head,'span',category.id + ' \xB7 ' + category.weight + '%'); add(head,'strong',category.score.toFixed(2));
244
- const bar=add(wrap,'div','', 'bar'); const fill=add(bar,'i',''); fill.style.width=category.score+'%';
245
- }
246
- const categories=[...new Set(report.findings.map(item=>item.category))].sort();
247
- for(const category of categories){ const option=add(byId('category-filter'),'option',category); option.value=category; }
248
- const renderFindings=()=>{
249
- const list=byId('findings-list'); list.replaceChildren();
250
- const severity=byId('findings-filter').value, category=byId('category-filter').value, query=byId('findings-search').value.toLowerCase();
251
- const visible=report.findings.filter(item=>(severity==='all'||item.severity===severity)&&(category==='all'||item.category===category)&&[item.file,item.ruleId,item.evidence].join(' ').toLowerCase().includes(query));
252
- if(!visible.length){add(list,'div','No findings match these filters.','empty');return;}
253
- for(const item of visible){const card=add(list,'article','', 'finding');const head=add(card,'div','', 'finding-head');add(head,'span',item.severity,item.severity+' chip');add(head,'strong',item.ruleId);add(head,'span',item.file+':'+item.line,'path');add(card,'p',item.evidence);add(card,'small',item.recommendation);}
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']) byId(id).addEventListener('input',renderFindings);
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
- for(const hotspot of report.actions.hotspots){const item=add(byId('hotspot-list'),'li','');add(item,'strong',hotspot.file);add(item,'div',hotspot.findingCount+' findings \xB7 '+hotspot.estimatedRecovery,'muted');}
258
- for(const win of report.actions.quickWins){const item=add(byId('quickwin-list'),'li','');add(item,'strong',win.file+' \xB7 '+win.ruleId);add(item,'div',win.recommendation,'muted');}
259
- for(const step of report.actions.roadmap){const item=add(byId('roadmap-list'),'li','');add(item,'strong',step.file);add(item,'div',step.summary,'muted');}
260
- const svg=byId('module-graph'), ns='http://www.w3.org/2000/svg';
261
- const degrees=new Map(report.graph.modules.map(module=>[module.path,0]));
262
- for(const edge of report.graph.edges){degrees.set(edge.from,(degrees.get(edge.from)||0)+1);degrees.set(edge.to,(degrees.get(edge.to)||0)+1);}
263
- const modules=[...report.graph.modules].sort((a,b)=>(degrees.get(b.path)||0)-(degrees.get(a.path)||0)||a.path.localeCompare(b.path)).slice(0,32);
264
- const positions=new Map(modules.map((module,index)=>[module.path,{x:450+355*Math.cos(2*Math.PI*index/modules.length),y:260+195*Math.sin(2*Math.PI*index/modules.length)}]));
265
- for(const edge of report.graph.edges){const from=positions.get(edge.from),to=positions.get(edge.to);if(!from||!to)continue;const line=document.createElementNS(ns,'line');for(const [key,value] of Object.entries({x1:from.x,y1:from.y,x2:to.x,y2:to.y}))line.setAttribute(key,String(value));svg.append(line);}
266
- for(const module of modules){const position=positions.get(module.path);const group=document.createElementNS(ns,'g');const circle=document.createElementNS(ns,'circle');circle.setAttribute('cx',position.x);circle.setAttribute('cy',position.y);circle.setAttribute('r','8');circle.setAttribute('tabindex','0');circle.setAttribute('role','button');circle.setAttribute('aria-label','Inspect '+module.path);const inspect=()=>text('graph-detail',module.path+' \xB7 '+(degrees.get(module.path)||0)+' connections \xB7 '+module.lines+' lines');circle.addEventListener('click',inspect);circle.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();inspect();}});group.append(circle);const label=document.createElementNS(ns,'text');label.setAttribute('x',position.x+12);label.setAttribute('y',position.y+4);label.textContent=module.path.split('/').pop();group.append(label);svg.append(group);}
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
- </script></body></html>`;
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
- 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;
952
+ return void 0;
564
953
  }
565
- async function resolveImports(root, modules) {
566
- const options = await compilerOptions(root);
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 result = ts2.resolveModuleName(imported.specifier, from, options, ts2.sys).resolvedModule;
572
- if (!result) continue;
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}`)) continue;
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 modules = await Promise.all(files.sort().map(async (path) => parseModule(
592
- path.replaceAll("\\", "/"),
593
- await readFile3(join4(root, path), "utf8")
594
- )));
595
- await resolveImports(root, modules);
596
- return modules;
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 = dirname(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/policy.ts
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 profiles2 = {};
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
- profiles2[name] = versions;
1540
+ profiles3[name] = versions;
939
1541
  }
940
1542
  return {
941
1543
  schemaVersion: 2,
942
1544
  policyVersion: parsed.policyVersion,
943
- profiles: profiles2,
1545
+ profiles: profiles3,
1546
+ ...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
944
1547
  source,
945
- sha256: createHash("sha256").update(raw).digest("hex")
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 readFile7(resolve6(source), "utf8"), source);
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
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("0.2.0");
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 = resolve7(path ?? ".");
989
- const destination = resolve7(flags.output ?? join8(root, ".nklattice-report"));
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: ${join8(destination, "report.html")} and ${join8(destination, "analysis.json")}
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 = join8(resolve7(path ?? "."), ".nklattice.json");
1004
- await writeFile2(destination, '{\n "analysis": {},\n "policies": []\n}\n', { flag: "wx" });
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("Check centrally approved dependency versions").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) => {
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 ?? resolve7(dirname2(fileURLToPath(import.meta.url)), "../governance/policy.json");
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(resolve7(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
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
  });