@anhcodo13/lattice 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 writeFile4 } 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 dirname5, join as join12, resolve as resolve11 } from "path";
8
8
  import { Command, CommanderError } from "commander";
9
9
 
10
+ // package.json
11
+ var package_default = {
12
+ name: "@anhcodo13/lattice",
13
+ version: "0.4.0",
14
+ private: false,
15
+ packageManager: "pnpm@10.30.3",
16
+ type: "module",
17
+ description: "Project governance assessment and architecture analysis for React teams",
18
+ author: "KhucHieuNghi",
19
+ license: "MIT",
20
+ repository: { type: "git", url: "git+https://github.com/KhucHieuNghi/nklattice.git" },
21
+ homepage: "https://khuchieunghi.github.io/nklattice/",
22
+ bugs: { url: "https://github.com/KhucHieuNghi/nklattice/issues" },
23
+ publishConfig: { access: "public" },
24
+ bin: { lattice: "dist/cli.js" },
25
+ exports: { ".": { types: "./dist/index.d.ts", import: "./dist/index.js" } },
26
+ files: ["dist", "governance/policy.json", "LICENSE"],
27
+ engines: { node: ">=24" },
28
+ scripts: {
29
+ test: "vitest run",
30
+ typecheck: "tsc --noEmit",
31
+ build: "tsup",
32
+ verify: "pnpm typecheck && pnpm test && pnpm build && node scripts/smoke-built-cli.mjs",
33
+ "verify:package": "node scripts/smoke-tarball.mjs"
34
+ },
35
+ dependencies: {
36
+ commander: "^14.0.0",
37
+ "fast-glob": "^3.3.3",
38
+ semver: "7.8.5",
39
+ typescript: "5.9.3",
40
+ yaml: "^2.9.1"
41
+ },
42
+ devDependencies: {
43
+ "@types/node": "^24.0.0",
44
+ "@types/semver": "^7.8.0",
45
+ tsup: "^8.5.0",
46
+ vitest: "^3.2.0"
47
+ }
48
+ };
49
+
10
50
  // src/analysis/analyze.ts
11
51
  import { mkdir as mkdir2, readFile as readFile4, writeFile } from "fs/promises";
12
52
  import { basename, join as join5, resolve as resolve3 } from "path";
@@ -37,22 +77,8 @@ function patterns(value, label) {
37
77
  }
38
78
  return value;
39
79
  }
40
- 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
@@ -170,28 +223,28 @@ async function exists(path) {
170
223
  }
171
224
  }
172
225
  async function archivePrevious(outputDirectory) {
173
- const json = join2(outputDirectory, "analysis.json");
226
+ const json2 = join2(outputDirectory, "analysis.json");
174
227
  const html = join2(outputDirectory, "report.html");
175
- if (!await exists(json) && !await exists(html)) return;
228
+ if (!await exists(json2) && !await exists(html)) return;
176
229
  const name = `${(/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
177
230
  const destination = join2(outputDirectory, "history", name);
178
231
  await mkdir(destination, { recursive: true });
179
- if (await exists(json)) await rename(json, join2(destination, "analysis.json"));
232
+ if (await exists(json2)) await rename(json2, join2(destination, "analysis.json"));
180
233
  if (await exists(html)) await rename(html, join2(destination, "report.html"));
181
234
  }
182
235
 
183
236
  // src/analysis/recommend.ts
184
- function recommendActions(findings) {
237
+ function recommendActions(findings2) {
185
238
  const points = { error: 5, warning: 2, info: 1 };
186
239
  const grouped = /* @__PURE__ */ new Map();
187
- for (const finding of findings) grouped.set(finding.file, [...grouped.get(finding.file) ?? [], finding]);
240
+ for (const finding of findings2) grouped.set(finding.file, [...grouped.get(finding.file) ?? [], finding]);
188
241
  const ranked = [...grouped.entries()].map(([file, items]) => ({ file, items, priority: items.reduce((sum, item) => sum + points[item.severity], 0) })).sort((a, b) => b.priority - a.priority || a.file.localeCompare(b.file));
189
242
  const hotspots = ranked.slice(0, 8).map(({ file, items, priority }) => ({
190
243
  file,
191
244
  findingCount: items.length,
192
245
  estimatedRecovery: `Estimate: up to ${Math.min(15, priority)} points if these findings are resolved`
193
246
  }));
194
- const quickWins = findings.filter((finding) => finding.severity === "info" || finding.ruleId === "duplicate-import").slice(0, 8).map((finding) => ({ file: finding.file, ruleId: finding.ruleId, recommendation: finding.recommendation }));
247
+ const quickWins = findings2.filter((finding) => finding.severity === "info" || finding.ruleId === "duplicate-import").slice(0, 8).map((finding) => ({ file: finding.file, ruleId: finding.ruleId, recommendation: finding.recommendation }));
195
248
  const roadmap = ranked.slice(0, 8).map(({ file, items, priority }) => ({
196
249
  file,
197
250
  priority,
@@ -204,79 +257,365 @@ function recommendActions(findings) {
204
257
  function escapeHtml(value) {
205
258
  return value.replaceAll("&", "&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
272
611
  import { matchesGlob } from "path";
273
612
  function evaluateRules(graph, config) {
274
- const findings = [];
613
+ const findings2 = [];
275
614
  const outgoing = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly)]));
276
615
  const incoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path && !edge.isTypeOnly)]));
277
616
  const allIncoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path)]));
278
617
  for (const cycle of findCycles(graph)) {
279
- findings.push({
618
+ findings2.push({
280
619
  ruleId: "module-cycle",
281
620
  category: "architecture",
282
621
  severity: "error",
@@ -293,7 +632,7 @@ function evaluateRules(graph, config) {
293
632
  for (const imported of module.imports) {
294
633
  if (imported.isTypeOnly) continue;
295
634
  if (seen.has(imported.specifier)) {
296
- findings.push({
635
+ findings2.push({
297
636
  ruleId: "duplicate-import",
298
637
  category: "imports",
299
638
  severity: "warning",
@@ -306,7 +645,7 @@ function evaluateRules(graph, config) {
306
645
  seen.add(imported.specifier);
307
646
  }
308
647
  if (out.length > config.thresholds.maxFanOut) {
309
- findings.push({
648
+ findings2.push({
310
649
  ruleId: "high-fan-out",
311
650
  category: "architecture",
312
651
  severity: "warning",
@@ -317,7 +656,7 @@ function evaluateRules(graph, config) {
317
656
  });
318
657
  }
319
658
  if (into.length > config.thresholds.maxFanIn) {
320
- findings.push({
659
+ findings2.push({
321
660
  ruleId: "high-fan-in",
322
661
  category: "architecture",
323
662
  severity: "warning",
@@ -328,7 +667,7 @@ function evaluateRules(graph, config) {
328
667
  });
329
668
  }
330
669
  if (into.length === 0 && out.length === 0 && graph.modules.length > 1 && !module.isRoute) {
331
- findings.push({
670
+ findings2.push({
332
671
  ruleId: "orphan-module",
333
672
  category: "architecture",
334
673
  severity: "info",
@@ -344,7 +683,7 @@ function evaluateRules(graph, config) {
344
683
  for (const name of module.exports) {
345
684
  if (name === "*" || module.typeExports.includes(name)) continue;
346
685
  if (!usedNames.has(name)) {
347
- findings.push({
686
+ findings2.push({
348
687
  ruleId: "unused-export",
349
688
  category: "imports",
350
689
  severity: "info",
@@ -358,7 +697,7 @@ function evaluateRules(graph, config) {
358
697
  }
359
698
  }
360
699
  if (module.componentCount > 0 && module.lines > config.thresholds.maxComponentLines) {
361
- findings.push({
700
+ findings2.push({
362
701
  ruleId: "large-component",
363
702
  category: "components",
364
703
  severity: "warning",
@@ -369,7 +708,7 @@ function evaluateRules(graph, config) {
369
708
  });
370
709
  }
371
710
  if (module.jsxDepth > config.thresholds.maxJsxDepth) {
372
- findings.push({
711
+ findings2.push({
373
712
  ruleId: "deep-jsx",
374
713
  category: "components",
375
714
  severity: "warning",
@@ -380,7 +719,7 @@ function evaluateRules(graph, config) {
380
719
  });
381
720
  }
382
721
  if (module.complexity > config.thresholds.maxComplexity) {
383
- findings.push({
722
+ findings2.push({
384
723
  ruleId: "high-complexity",
385
724
  category: "complexity",
386
725
  severity: "warning",
@@ -396,7 +735,7 @@ function evaluateRules(graph, config) {
396
735
  const allowed = policy.allow?.some((pattern) => matchesGlob(edge.to, pattern));
397
736
  const disallowed = policy.disallow?.some((pattern) => matchesGlob(edge.to, pattern));
398
737
  if (policy.allow && !allowed || policy.disallow && disallowed) {
399
- findings.push({
738
+ findings2.push({
400
739
  ruleId: policy.id,
401
740
  category: "architecture",
402
741
  severity: policy.severity,
@@ -423,7 +762,7 @@ function evaluateRules(graph, config) {
423
762
  return false;
424
763
  };
425
764
  if (visit(module.path, /* @__PURE__ */ new Set([module.path]), 0)) {
426
- findings.push({
765
+ findings2.push({
427
766
  ruleId: "deep-dependency-chain",
428
767
  category: "architecture",
429
768
  severity: "warning",
@@ -434,7 +773,7 @@ function evaluateRules(graph, config) {
434
773
  });
435
774
  }
436
775
  }
437
- return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
776
+ return findings2.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
438
777
  }
439
778
 
440
779
  // src/analysis/scan.ts
@@ -522,6 +861,17 @@ function parseModule(path, contents) {
522
861
  }
523
862
  }
524
863
  function visit(node, depth) {
864
+ if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
865
+ const argument = node.arguments[0];
866
+ if (argument && ts.isStringLiteralLike(argument)) {
867
+ imports.push({
868
+ specifier: argument.text,
869
+ line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
870
+ importedNames: ["*"],
871
+ isTypeOnly: false
872
+ });
873
+ }
874
+ }
525
875
  const isJsx = ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
526
876
  const nextDepth = depth + (isJsx ? 1 : 0);
527
877
  jsxDepth = Math.max(jsxDepth, nextDepth);
@@ -544,56 +894,203 @@ function parseModule(path, contents) {
544
894
  isRoute: /(^|\/)(app|pages)\/.+(page|layout|route|index)\.[cm]?[jt]sx?$/.test(path) || /(^|\/)app\/(page|layout|route)\.[cm]?[jt]sx?$/.test(path)
545
895
  };
546
896
  }
897
+ function moduleParseDiagnostics(path, contents) {
898
+ const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
899
+ const diagnostics = source.parseDiagnostics ?? [];
900
+ const messages = diagnostics.map((diagnostic) => {
901
+ const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1;
902
+ return `${path}:${line}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`;
903
+ });
904
+ function visit(node) {
905
+ if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
906
+ const argument = node.arguments[0];
907
+ if (!argument || !ts.isStringLiteralLike(argument)) {
908
+ const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
909
+ messages.push(`${path}:${line}: nonliteral dynamic import or require cannot be analyzed`);
910
+ }
911
+ }
912
+ ts.forEachChild(node, visit);
913
+ }
914
+ visit(source);
915
+ return messages;
916
+ }
547
917
 
548
918
  // src/analysis/resolve.ts
549
- import { readFile as readFile2 } from "fs/promises";
550
- import { join as join3, relative, resolve, sep } from "path";
919
+ import { readFile as readFile2, stat } from "fs/promises";
920
+ import { dirname, extname, join as join3, relative, resolve, sep } from "path";
551
921
  import ts2 from "typescript";
552
922
  async function compilerOptions(root) {
553
923
  const path = join3(root, "tsconfig.json");
554
924
  let content;
555
925
  try {
556
926
  content = await readFile2(path, "utf8");
557
- } catch {
558
- return {};
927
+ } catch (error) {
928
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return { options: {}, diagnostics: [] };
929
+ throw error;
930
+ }
931
+ try {
932
+ const json2 = ts2.parseConfigFileTextToJson(path, content);
933
+ if (json2.error) return { options: {}, diagnostics: [`tsconfig.json: ${ts2.flattenDiagnosticMessageText(json2.error.messageText, " ")}`] };
934
+ const parsed = ts2.parseJsonConfigFileContent(json2.config, ts2.sys, root);
935
+ return { options: parsed.options, diagnostics: parsed.errors.map((error) => `tsconfig.json: ${ts2.flattenDiagnosticMessageText(error.messageText, " ")}`) };
936
+ } catch (error) {
937
+ return { options: {}, diagnostics: [`tsconfig.json: ${error instanceof Error ? error.message : String(error)}`] };
938
+ }
939
+ }
940
+ function propertyName(name) {
941
+ if (ts2.isIdentifier(name) || ts2.isStringLiteral(name)) return name.text;
942
+ return void 0;
943
+ }
944
+ function staticAliasTarget(node) {
945
+ if (ts2.isStringLiteralLike(node)) return node.text;
946
+ if (ts2.isCallExpression(node) || ts2.isNewExpression(node)) {
947
+ for (const argument of node.arguments ?? []) {
948
+ const found = staticAliasTarget(argument);
949
+ if (found?.startsWith("./")) return found;
950
+ }
951
+ }
952
+ return void 0;
953
+ }
954
+ async function viteAliasDiagnostics(root, options, aliases) {
955
+ const diagnostics = [];
956
+ for (const file of ["vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs", "vite.config.cts", "vite.config.cjs"]) {
957
+ let visit2 = function(node) {
958
+ if (ts2.isPropertyAssignment(node) && propertyName(node.name) === "resolve") {
959
+ if (!ts2.isObjectLiteralExpression(node.initializer)) {
960
+ diagnostics.push(`${file}: dynamic Vite resolve configuration cannot be verified`);
961
+ ts2.forEachChild(node, visit2);
962
+ return;
963
+ }
964
+ for (const property of node.initializer.properties) {
965
+ if (!ts2.isPropertyAssignment(property) || !propertyName(property.name)) {
966
+ diagnostics.push(`${file}: dynamic Vite resolve entry cannot be verified`);
967
+ continue;
968
+ }
969
+ if (propertyName(property.name) !== "alias") continue;
970
+ if (!ts2.isObjectLiteralExpression(property.initializer)) {
971
+ diagnostics.push(`${file}: dynamic Vite alias configuration cannot be verified`);
972
+ continue;
973
+ }
974
+ for (const alias of property.initializer.properties) {
975
+ if (!ts2.isPropertyAssignment(alias) || !propertyName(alias.name)) {
976
+ diagnostics.push(`${file}: dynamic Vite alias entry cannot be verified`);
977
+ continue;
978
+ }
979
+ const key = propertyName(alias.name);
980
+ const prefix = key.endsWith("/") ? key : `${key}/`;
981
+ const centralTarget = aliases[prefix];
982
+ const tsTarget = options.paths?.[`${key}/*`]?.[0];
983
+ const mapped = centralTarget ?? (tsTarget?.endsWith("/*") ? tsTarget.slice(0, -1).replace(/^\.\//, "") : void 0);
984
+ if (!mapped) {
985
+ diagnostics.push(`${file}: Vite alias ${key} needs a central analysis.aliases or matching tsconfig paths mapping`);
986
+ continue;
987
+ }
988
+ const target = staticAliasTarget(alias.initializer);
989
+ if (!target?.startsWith("./")) {
990
+ diagnostics.push(`${file}: Vite alias ${key} target cannot be verified statically`);
991
+ continue;
992
+ }
993
+ const expected = `${target.slice(2).replace(/\/$/, "")}/`;
994
+ if (mapped !== expected) diagnostics.push(`${file}: Vite alias ${key} maps to ${expected}, but assessment uses ${mapped}`);
995
+ }
996
+ }
997
+ }
998
+ ts2.forEachChild(node, visit2);
999
+ };
1000
+ var visit = visit2;
1001
+ let sourceText;
1002
+ try {
1003
+ sourceText = await readFile2(join3(root, file), "utf8");
1004
+ } catch (error) {
1005
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue;
1006
+ throw error;
1007
+ }
1008
+ const source = ts2.createSourceFile(file, sourceText, ts2.ScriptTarget.Latest, true);
1009
+ const defaultExport = source.statements.find((statement) => ts2.isExportAssignment(statement) && !statement.isExportEquals);
1010
+ if (!defaultExport || !ts2.isExportAssignment(defaultExport)) {
1011
+ diagnostics.push(`${file}: Vite configuration export cannot be verified statically`);
1012
+ } else {
1013
+ const expression = defaultExport.expression;
1014
+ const config = ts2.isCallExpression(expression) && expression.expression.getText(source) === "defineConfig" ? expression.arguments[0] : expression;
1015
+ if (!config || !ts2.isObjectLiteralExpression(config)) {
1016
+ diagnostics.push(`${file}: Vite configuration export cannot be verified statically`);
1017
+ } else if (config.properties.some((property) => !ts2.isPropertyAssignment(property) || !propertyName(property.name))) {
1018
+ diagnostics.push(`${file}: dynamic Vite configuration entry cannot be verified`);
1019
+ }
1020
+ }
1021
+ visit2(source);
559
1022
  }
560
- 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;
1023
+ return diagnostics;
564
1024
  }
565
- async function resolveImports(root, modules) {
566
- const options = await compilerOptions(root);
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
@@ -608,10 +1105,10 @@ function grade(score) {
608
1105
  if (score >= 55) return "D";
609
1106
  return "F";
610
1107
  }
611
- function scoreFindings(findings, config) {
1108
+ function scoreFindings(findings2, config) {
612
1109
  const penalties = { error: 18, warning: 7, info: 2 };
613
1110
  const categories = Object.entries(config.weights).map(([id, weight]) => {
614
- const categoryFindings = findings.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
1111
+ const categoryFindings = findings2.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
615
1112
  const occurrences = /* @__PURE__ */ new Map();
616
1113
  let deduction = 0;
617
1114
  for (const finding of categoryFindings) {
@@ -623,8 +1120,8 @@ function scoreFindings(findings, config) {
623
1120
  return { id, weight, score: round(100 - deduction), deduction: round(deduction), findingCount: categoryFindings.length };
624
1121
  });
625
1122
  const weightedDeduction = round(categories.reduce((sum, category) => sum + category.deduction * category.weight / 100, 0));
626
- const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings.length - 10) * 0.15));
627
- const criticalAdjustment = round(Math.min(10, findings.filter((finding) => finding.severity === "error").length * 1.5));
1123
+ const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings2.length - 10) * 0.15));
1124
+ const criticalAdjustment = round(Math.min(10, findings2.filter((finding) => finding.severity === "error").length * 1.5));
628
1125
  const score = round(Math.max(0, Math.min(100, 100 - weightedDeduction - maintenanceAdjustment - criticalAdjustment)));
629
1126
  return { score, grade: grade(score), weightedDeduction, maintenanceAdjustment, criticalAdjustment, categories };
630
1127
  }
@@ -633,10 +1130,10 @@ function scoreFindings(findings, config) {
633
1130
  async function analyzeProject(projectRoot, options = {}) {
634
1131
  const root = resolve3(projectRoot);
635
1132
  const config = await loadAnalysisConfig(root);
636
- const modules = await scanProject(root);
1133
+ const modules = await scanProject(root, config.aliases);
637
1134
  if (modules.length === 0) throw new Error("No supported source files found");
638
1135
  const graph = buildGraph(modules);
639
- const findings = evaluateRules(graph, config);
1136
+ const findings2 = evaluateRules(graph, config);
640
1137
  let name = basename(root);
641
1138
  try {
642
1139
  const manifest = JSON.parse(await readFile4(join5(root, "package.json"), "utf8"));
@@ -648,9 +1145,9 @@ async function analyzeProject(projectRoot, options = {}) {
648
1145
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
649
1146
  project: { name },
650
1147
  graph,
651
- findings,
652
- score: scoreFindings(findings, config),
653
- actions: recommendActions(findings)
1148
+ findings: findings2,
1149
+ score: scoreFindings(findings2, config),
1150
+ actions: recommendActions(findings2)
654
1151
  };
655
1152
  const outputDirectory = resolve3(options.outputDirectory ?? join5(root, ".nklattice-report"));
656
1153
  await mkdir2(outputDirectory, { recursive: true });
@@ -679,12 +1176,12 @@ function dependencyMap(manifest) {
679
1176
  const entries = manifest[section2];
680
1177
  if (entries === void 0) continue;
681
1178
  if (!isRecord(entries)) throw new Error(`Invalid ${section2} in package.json`);
682
- for (const [name, version] of Object.entries(entries)) {
683
- if (typeof version !== "string") throw new Error(`Invalid version for ${name}`);
684
- if (result[name] !== void 0 && result[name] !== version) {
1179
+ for (const [name, version2] of Object.entries(entries)) {
1180
+ if (typeof version2 !== "string") throw new Error(`Invalid version for ${name}`);
1181
+ if (result[name] !== void 0 && result[name] !== version2) {
685
1182
  throw new Error(`Conflicting declarations for ${name}`);
686
1183
  }
687
- result[name] = version;
1184
+ result[name] = version2;
688
1185
  }
689
1186
  }
690
1187
  return result;
@@ -707,8 +1204,9 @@ async function discoverApps(scanRoot, workspaceRoot) {
707
1204
  cwd: scanRoot,
708
1205
  onlyFiles: true,
709
1206
  unique: true,
1207
+ dot: true,
710
1208
  followSymbolicLinks: false,
711
- ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/.next/**", "**/coverage/**"]
1209
+ ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/out/**", "**/.next/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**"]
712
1210
  });
713
1211
  const apps = [];
714
1212
  for (const path of manifests.sort()) {
@@ -735,7 +1233,7 @@ async function discoverApps(scanRoot, workspaceRoot) {
735
1233
 
736
1234
  // src/governance/lockfiles.ts
737
1235
  import { access as access2, readFile as readFile6 } from "fs/promises";
738
- import { dirname, join as join7, relative as relative3, resolve as resolve4, sep as sep2 } from "path";
1236
+ import { dirname as dirname2, join as join7, relative as relative3, resolve as resolve4, sep as sep2 } from "path";
739
1237
  import YAML from "yaml";
740
1238
  function record2(value) {
741
1239
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -764,7 +1262,7 @@ async function findLockfile(appDirectory, workspaceRoot) {
764
1262
  if (hasNpm) return { path: npm, kind: "npm", root: directory };
765
1263
  if (hasPnpm) return { path: pnpm, kind: "pnpm", root: directory };
766
1264
  if (directory === boundary) break;
767
- directory = dirname(directory);
1265
+ directory = dirname2(directory);
768
1266
  }
769
1267
  throw new Error(`No lockfile found for ${appDirectory}`);
770
1268
  }
@@ -826,17 +1324,17 @@ function readPnpm(raw, root, app, name) {
826
1324
  if (!record2(entry) || typeof entry.specifier !== "string" || typeof entry.version !== "string") {
827
1325
  throw new Error(`Malformed pnpm lockfile dependency for ${name}`);
828
1326
  }
829
- const version = entry.version.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1];
830
- if (!version) throw new Error(`Unsupported pnpm resolution for ${name}`);
831
- const packageKeys = [`${name}@${version}`, `/${name}@${version}`];
1327
+ const version2 = entry.version.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1];
1328
+ if (!version2) throw new Error(`Unsupported pnpm resolution for ${name}`);
1329
+ const packageKeys = [`${name}@${version2}`, `/${name}@${version2}`];
832
1330
  if (!packageKeys.some((key) => record2(lock.packages) && Object.hasOwn(lock.packages, key))) {
833
- throw new Error(`Missing pnpm package entry for ${name}@${version}`);
1331
+ throw new Error(`Missing pnpm package entry for ${name}@${version2}`);
834
1332
  }
835
1333
  const snapshotKeys = [`${name}@${entry.version}`, `/${name}@${entry.version}`, ...packageKeys];
836
1334
  if (!snapshotKeys.some((key) => Object.hasOwn(lock.snapshots, key))) {
837
1335
  throw new Error(`Missing pnpm snapshot for ${name}@${entry.version}`);
838
1336
  }
839
- return { specifier: entry.specifier, version };
1337
+ return { specifier: entry.specifier, version: version2 };
840
1338
  }
841
1339
  async function readResolution(app, workspaceRoot, name) {
842
1340
  const lockfile = await findLockfile(app.directory, workspaceRoot);
@@ -845,6 +1343,39 @@ async function readResolution(app, workspaceRoot, name) {
845
1343
  }
846
1344
 
847
1345
  // src/governance/check.ts
1346
+ async function checkApp(app, workspaceRoot, policy) {
1347
+ const requirements = policy.profiles[app.profile];
1348
+ if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
1349
+ const findings2 = [];
1350
+ for (const [packageName, expected] of Object.entries(requirements)) {
1351
+ const declared = app.dependencies[packageName];
1352
+ const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
1353
+ const reasons = [];
1354
+ const declaredRange = declared === void 0 ? null : validRange(declared);
1355
+ if (declared === void 0) reasons.push("required manifest declaration missing");
1356
+ else if (declaredRange === null) reasons.push("unsupported manifest range");
1357
+ else if (!subset(declaredRange, expected)) reasons.push("manifest range allows versions below minimum");
1358
+ if (resolution.specifier !== declared) reasons.push("lockfile specifier differs");
1359
+ if (resolution.version === void 0) reasons.push("lockfile resolution missing");
1360
+ else if (!valid(resolution.version)) reasons.push("invalid lockfile resolution");
1361
+ else {
1362
+ if (!satisfies(resolution.version, expected)) reasons.push("lockfile resolution does not satisfy minimum");
1363
+ if (declaredRange !== null && !satisfies(resolution.version, declaredRange)) {
1364
+ reasons.push("lockfile resolution outside manifest range");
1365
+ }
1366
+ }
1367
+ if (reasons.length) findings2.push({
1368
+ app: app.relativePath,
1369
+ packageName,
1370
+ expected,
1371
+ declared,
1372
+ lockfileSpecifier: resolution.specifier,
1373
+ locked: resolution.version,
1374
+ reason: reasons.join("; ")
1375
+ });
1376
+ }
1377
+ return findings2;
1378
+ }
848
1379
  async function checkGovernance(options) {
849
1380
  const scanRoot = resolve5(options.scanRoot);
850
1381
  const workspaceRoot = resolve5(options.workspaceRoot ?? scanRoot);
@@ -854,59 +1385,446 @@ async function checkGovernance(options) {
854
1385
  }
855
1386
  const apps = await discoverApps(scanRoot, workspaceRoot);
856
1387
  if (apps.length === 0) throw new Error("No managed applications found");
857
- const 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 findings2 = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
891
1389
  return {
892
1390
  appsChecked: apps.length,
893
- findings,
1391
+ findings: findings2,
894
1392
  policyVersion: options.policy.policyVersion,
895
1393
  policySource: options.policy.source,
896
1394
  policySha256: options.policy.sha256
897
1395
  };
898
1396
  }
899
1397
 
900
- // src/governance/policy.ts
1398
+ // src/governance/assess.ts
1399
+ import { mkdir as mkdir3, writeFile as writeFile2 } from "fs/promises";
1400
+ import { basename as basename2, join as join10, relative as relative7, resolve as resolve9, sep as sep6 } from "path";
1401
+
1402
+ // src/governance/inventory.ts
901
1403
  import { createHash } from "crypto";
902
- import { readFile as readFile7 } from "fs/promises";
903
- import { resolve as resolve6 } from "path";
1404
+ import { readFile as readFile7, realpath } from "fs/promises";
1405
+ import { isAbsolute, join as join8, relative as relative5, resolve as resolve6, sep as sep4 } from "path";
1406
+ var profiles2 = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
1407
+ async function loadInventory(source, scanRoot) {
1408
+ const absolute = resolve6(source);
1409
+ const root = await realpath(scanRoot);
1410
+ const inventoryPath = await realpath(absolute);
1411
+ const inventoryRelative = relative5(root, inventoryPath);
1412
+ if (inventoryRelative === ".." || inventoryRelative.startsWith(`..${sep4}`)) throw new Error("Inventory must be inside scan root");
1413
+ const raw = await readFile7(inventoryPath, "utf8");
1414
+ let value;
1415
+ try {
1416
+ value = JSON.parse(raw);
1417
+ } catch {
1418
+ throw new Error("Invalid app inventory JSON");
1419
+ }
1420
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Invalid app inventory object");
1421
+ const parsed = value;
1422
+ if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.apps) || parsed.apps.length === 0) {
1423
+ throw new Error("Inventory schemaVersion 1 and nonempty apps required");
1424
+ }
1425
+ const apps = [];
1426
+ const seen = /* @__PURE__ */ new Set();
1427
+ for (const entry of parsed.apps) {
1428
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error("Invalid app inventory entry");
1429
+ const app = entry;
1430
+ const path = app.path;
1431
+ if (typeof path !== "string" || path !== "." && (path === "" || path.startsWith("./") || path.includes("\\") || path.split("/").some((segment) => !segment || segment === "." || segment === "..")) || isAbsolute(path)) {
1432
+ throw new Error(`Invalid app inventory path ${String(path)}`);
1433
+ }
1434
+ if (typeof app.profile !== "string" || !profiles2.has(app.profile)) throw new Error(`Invalid profile for ${path}`);
1435
+ if (seen.has(path)) throw new Error(`Duplicate inventory app ${path}`);
1436
+ seen.add(path);
1437
+ try {
1438
+ const actual = await realpath(join8(root, path));
1439
+ const rel = relative5(root, actual);
1440
+ if (rel === ".." || rel.startsWith(`..${sep4}`)) throw new Error(`App path escapes scan root: ${path}`);
1441
+ } catch (error) {
1442
+ if (error instanceof Error && error.message.includes("escapes scan root")) throw error;
1443
+ }
1444
+ apps.push({ path, profile: app.profile });
1445
+ }
1446
+ return { source: absolute, sha256: createHash("sha256").update(raw).digest("hex"), apps };
1447
+ }
1448
+ function compareInventory(inventory, discovered, scanRoot) {
1449
+ const expected = new Map(inventory.apps.map((app) => [app.path, app.profile]));
1450
+ const actual = new Map(discovered.map((app) => [relative5(scanRoot, app.directory).replaceAll("\\", "/") || ".", app.profile]));
1451
+ const issues = [];
1452
+ for (const [path, profile] of expected) {
1453
+ if (!actual.has(path)) issues.push(`Expected app ${path} was not discovered`);
1454
+ else if (actual.get(path) !== profile) issues.push(`App ${path} has profile ${actual.get(path)}; inventory requires ${profile}`);
1455
+ }
1456
+ for (const path of actual.keys()) if (!expected.has(path)) issues.push(`Discovered app ${path} is absent from inventory`);
1457
+ return issues;
1458
+ }
1459
+
1460
+ // src/governance/evidence.ts
1461
+ import { createHash as createHash2 } from "crypto";
1462
+ import { execFile } from "child_process";
1463
+ import { createReadStream } from "fs";
1464
+ import { lstat, readFile as readFile8, realpath as realpath2 } from "fs/promises";
1465
+ import { dirname as dirname3, join as join9, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
1466
+ import { promisify } from "util";
1467
+ import fg from "fast-glob";
1468
+ var execFileAsync = promisify(execFile);
1469
+ function object(value) {
1470
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1471
+ }
1472
+ function string(value) {
1473
+ return typeof value === "string" && value.length > 0;
1474
+ }
1475
+ function nonnegative(value) {
1476
+ return Number.isSafeInteger(value) && value >= 0;
1477
+ }
1478
+ function errors(value) {
1479
+ return Array.isArray(value) && value.every(string);
1480
+ }
1481
+ function findings(value) {
1482
+ return Array.isArray(value) && value.every((item) => object(item) && string(item.id) && string(item.severity) && string(item.subject) && string(item.message) && (item.remediation === void 0 || string(item.remediation)));
1483
+ }
1484
+ function base(value) {
1485
+ return object(value) && string(value.tool) && string(value.version) && typeof value.complete === "boolean" && errors(value.errors);
1486
+ }
1487
+ function validateApp(value) {
1488
+ if (!object(value) || !string(value.path) || !string(value.profile)) return false;
1489
+ const dependency = value.dependencySecurity;
1490
+ const quality = value.codeQuality;
1491
+ const security = value.codeSecurity;
1492
+ const compatibility2 = value.compatibility;
1493
+ if (!base(dependency) || !nonnegative(dependency.packagesScanned) || !findings(dependency.advisories)) return false;
1494
+ if (!object(quality) || !object(quality.lint) || !string(quality.lint.tool) || !string(quality.lint.version) || typeof quality.lint.complete !== "boolean" || !base(quality.typecheck) || !base(quality.tests) || !base(quality.coverage)) return false;
1495
+ if (!nonnegative(quality.lint.filesScanned) || !nonnegative(quality.lint.errorCount) || !nonnegative(quality.lint.warnings) || !errors(quality.lint.errorsDetail) || typeof quality.typecheck.passed !== "boolean" || quality.typecheck.applicable !== void 0 && typeof quality.typecheck.applicable !== "boolean" || !nonnegative(quality.tests.passed) || !nonnegative(quality.tests.failed) || typeof quality.coverage.linesPercent !== "number" || !Number.isFinite(quality.coverage.linesPercent) || quality.coverage.linesPercent < 0 || quality.coverage.linesPercent > 100) return false;
1496
+ if (!object(security) || !base(security.sast) || !base(security.secrets) || !nonnegative(security.sast.filesScanned) || !nonnegative(security.secrets.filesScanned) || !findings(security.sast.findings) || !findings(security.secrets.findings)) return false;
1497
+ if (!base(compatibility2) || !Array.isArray(compatibility2.projects) || !compatibility2.projects.every((project) => object(project) && string(project.name) && nonnegative(project.passed) && nonnegative(project.failed) && nonnegative(project.skipped) && (project.browserVersion === void 0 || string(project.browserVersion)) && (project.engine === void 0 || string(project.engine)) && (project.mobile === void 0 || typeof project.mobile === "boolean") && (project.device === void 0 || string(project.device)) && (project.viewportWidths === void 0 || Array.isArray(project.viewportWidths) && project.viewportWidths.every(nonnegative)))) return false;
1498
+ return true;
1499
+ }
1500
+ async function hashFile(hash, path) {
1501
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
1502
+ }
1503
+ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
1504
+ const root = resolve7(scanRoot);
1505
+ const workspace = resolve7(workspaceRoot);
1506
+ const scanRelative = relative6(workspace, root);
1507
+ if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
1508
+ const paths = await fg("**/*", {
1509
+ cwd: root,
1510
+ dot: true,
1511
+ onlyFiles: true,
1512
+ followSymbolicLinks: false,
1513
+ ignore: [
1514
+ "**/.git/**",
1515
+ "**/node_modules/**",
1516
+ "**/dist/**",
1517
+ "**/build/**",
1518
+ "**/.next/**",
1519
+ "**/coverage/**",
1520
+ "**/playwright-report/**",
1521
+ "**/test-results/**",
1522
+ "**/.nklattice-governance/**",
1523
+ "**/.nklattice-report/**",
1524
+ "**/AGENTS.md",
1525
+ "**/*.tsbuildinfo",
1526
+ "**/next-env.d.ts"
1527
+ ]
1528
+ });
1529
+ paths.sort();
1530
+ const hash = createHash2("sha256");
1531
+ for (const path of paths) {
1532
+ const absolute = resolve7(root, path);
1533
+ const normalized = relative6(root, absolute);
1534
+ if (normalized === ".." || normalized.startsWith(`..${sep5}`)) throw new Error("Input path escapes scan root");
1535
+ if (!(await lstat(absolute)).isFile()) continue;
1536
+ hash.update(path.replaceAll("\\", "/"));
1537
+ hash.update("\0");
1538
+ await hashFile(hash, absolute);
1539
+ hash.update("\0");
1540
+ }
1541
+ let ancestor = root;
1542
+ while (ancestor === workspace || ancestor !== dirname3(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
1543
+ for (const name of [
1544
+ "package.json",
1545
+ "package-lock.json",
1546
+ "pnpm-lock.yaml",
1547
+ "pnpm-workspace.yaml",
1548
+ "tsconfig.json",
1549
+ "eslint.config.js",
1550
+ "eslint.config.mjs",
1551
+ "eslint.config.cjs",
1552
+ "eslint.config.ts",
1553
+ "eslint.config.mts",
1554
+ "vitest.config.js",
1555
+ "vitest.config.mjs",
1556
+ "vitest.config.ts",
1557
+ "vitest.config.mts",
1558
+ "playwright.config.js",
1559
+ "playwright.config.mjs",
1560
+ "playwright.config.ts",
1561
+ "playwright.config.mts",
1562
+ ".semgrep.yml",
1563
+ ".semgrep.yaml",
1564
+ "semgrep.yml",
1565
+ "semgrep.yaml"
1566
+ ]) {
1567
+ const path = join9(ancestor, name);
1568
+ try {
1569
+ const stat2 = await lstat(path);
1570
+ if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
1571
+ if (!stat2.isFile()) continue;
1572
+ hash.update(relative6(workspace, path).replaceAll("\\", "/"));
1573
+ hash.update("\0");
1574
+ await hashFile(hash, path);
1575
+ hash.update("\0");
1576
+ } catch (error) {
1577
+ if (error.code !== "ENOENT") throw error;
1578
+ }
1579
+ }
1580
+ if (ancestor === workspace) break;
1581
+ ancestor = dirname3(ancestor);
1582
+ }
1583
+ return hash.digest("hex");
1584
+ }
1585
+ async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
1586
+ const source = resolve7(path);
1587
+ const stat2 = await lstat(source);
1588
+ if (!stat2.isFile() || stat2.isSymbolicLink() || stat2.size > 1e7) throw new Error("Evidence must be a regular JSON file under 10 MB");
1589
+ const root = resolve7(scanRoot);
1590
+ const relativePath = relative6(root, source);
1591
+ if (relativePath !== ".." && !relativePath.startsWith(`..${sep5}`)) throw new Error("Evidence file must be outside the scan root");
1592
+ const actualRoot = await realpath2(root);
1593
+ const actualSource = await realpath2(source);
1594
+ const actualRelative = relative6(actualRoot, actualSource);
1595
+ if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
1596
+ let parsed;
1597
+ try {
1598
+ parsed = JSON.parse(await readFile8(source, "utf8"));
1599
+ } catch {
1600
+ throw new Error("Evidence is not valid JSON");
1601
+ }
1602
+ return validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours);
1603
+ }
1604
+ async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
1605
+ if (!object(parsed) || parsed.schemaVersion !== 1 || !string(parsed.generatedAt) || !string(parsed.policySha256) || !string(parsed.inputSha256) || !string(parsed.commit) || !/^[0-9a-f]{40}$/.test(parsed.commit) || !Array.isArray(parsed.apps) || !parsed.apps.every(validateApp)) throw new Error("Evidence schema is invalid or incomplete");
1606
+ if (parsed.policySha256 !== policySha256) throw new Error("Evidence was generated for another policy");
1607
+ const generatedAt = Date.parse(parsed.generatedAt);
1608
+ if (!Number.isFinite(generatedAt) || generatedAt > Date.now() + 6e4 || Date.now() - generatedAt > maximumAgeHours * 36e5) {
1609
+ throw new Error("Evidence is stale or has an invalid timestamp");
1610
+ }
1611
+ if (parsed.inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) throw new Error("Evidence inputs changed after scanning");
1612
+ const current = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot })).stdout.trim();
1613
+ if (!/^[0-9a-f]{40}$/.test(current) || parsed.commit !== current) throw new Error("Evidence commit differs from the checked-out commit");
1614
+ const appPaths = parsed.apps.map((app) => app.path);
1615
+ if (new Set(appPaths).size !== appPaths.length) throw new Error("Evidence contains duplicate app paths");
1616
+ return parsed;
1617
+ }
1618
+ var incomplete = (issues, tool, version2, scanned) => ({ status: "incomplete", issues, tool, version: version2, scanned });
1619
+ function evaluateAppEvidence(app, policy) {
1620
+ if (!app) {
1621
+ const missing = incomplete(["No fresh scan evidence for this app"]);
1622
+ return { dependencySecurity: { ...missing }, codeQuality: { ...missing }, codeSecurity: { ...missing }, compatibility: { ...missing } };
1623
+ }
1624
+ const dependency = app.dependencySecurity;
1625
+ let dependencySecurity;
1626
+ if (!dependency.complete || dependency.packagesScanned === 0 || dependency.errors.length) {
1627
+ dependencySecurity = { ...incomplete(["Dependency advisory scan did not cover the lockfile", ...dependency.errors], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories };
1628
+ } else {
1629
+ const high2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "high").length;
1630
+ const critical2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "critical").length;
1631
+ const unknown = dependency.advisories.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
1632
+ dependencySecurity = unknown.length ? { ...incomplete([`${unknown.length} advisories have unknown severity`], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories } : {
1633
+ status: high2 > policy.dependencySecurity.maximumHigh || critical2 > policy.dependencySecurity.maximumCritical ? "fail" : "pass",
1634
+ issues: [
1635
+ ...high2 > policy.dependencySecurity.maximumHigh ? [`${high2} high advisories exceed ${policy.dependencySecurity.maximumHigh}`] : [],
1636
+ ...critical2 > policy.dependencySecurity.maximumCritical ? [`${critical2} critical advisories exceed ${policy.dependencySecurity.maximumCritical}`] : []
1637
+ ],
1638
+ tool: dependency.tool,
1639
+ version: dependency.version,
1640
+ scanned: dependency.packagesScanned,
1641
+ findings: dependency.advisories
1642
+ };
1643
+ }
1644
+ const quality = app.codeQuality;
1645
+ const qualityMissing = [
1646
+ ...!quality.lint.complete || quality.lint.filesScanned === 0 ? ["ESLint did not scan source files", ...quality.lint.errorsDetail] : [],
1647
+ ...policy.codeQuality.requireTypecheck && !quality.typecheck.complete ? ["TypeScript typecheck did not complete", ...quality.typecheck.errors] : [],
1648
+ ...policy.codeQuality.requireTests && (!quality.tests.complete || quality.tests.passed + quality.tests.failed === 0) ? ["Tests did not run", ...quality.tests.errors] : [],
1649
+ ...!quality.coverage.complete ? ["Line coverage is unavailable", ...quality.coverage.errors] : []
1650
+ ];
1651
+ const qualityFailures = [
1652
+ ...quality.lint.errorCount > policy.codeQuality.maximumLintErrors ? [`${quality.lint.errorCount} ESLint errors exceed ${policy.codeQuality.maximumLintErrors}`] : [],
1653
+ ...policy.codeQuality.requireTypecheck && !quality.typecheck.passed ? ["TypeScript typecheck failed"] : [],
1654
+ ...quality.tests.failed > 0 ? [`${quality.tests.failed} tests failed`] : [],
1655
+ ...quality.coverage.linesPercent < policy.codeQuality.minimumLineCoverage ? [`Line coverage ${quality.coverage.linesPercent}% is below ${policy.codeQuality.minimumLineCoverage}%`] : []
1656
+ ];
1657
+ const qualityVersion = `ESLint ${quality.lint.version} \xB7 TypeScript ${quality.typecheck.version} \xB7 Vitest ${quality.tests.version}`;
1658
+ const qualityMetrics = {
1659
+ lintErrors: quality.lint.errorCount,
1660
+ lintWarnings: quality.lint.warnings,
1661
+ typecheck: quality.typecheck.applicable === false ? "not applicable" : quality.typecheck.passed ? "pass" : "fail",
1662
+ testsPassed: quality.tests.passed,
1663
+ testsFailed: quality.tests.failed,
1664
+ lineCoveragePercent: quality.coverage.linesPercent
1665
+ };
1666
+ const codeQuality = qualityMissing.length ? { ...incomplete([...qualityMissing, ...qualityFailures], "ESLint / TypeScript / tests", qualityVersion, quality.lint.filesScanned), metrics: qualityMetrics } : {
1667
+ status: qualityFailures.length ? "fail" : "pass",
1668
+ issues: qualityFailures,
1669
+ tool: "ESLint / TypeScript / tests",
1670
+ version: qualityVersion,
1671
+ scanned: quality.lint.filesScanned,
1672
+ metrics: qualityMetrics
1673
+ };
1674
+ const security = app.codeSecurity;
1675
+ const securityMissing = [
1676
+ ...!security.sast.complete || security.sast.filesScanned === 0 ? ["SAST did not scan source files", ...security.sast.errors] : [],
1677
+ ...!security.secrets.complete || security.secrets.filesScanned === 0 ? ["Secret scanner did not scan source files", ...security.secrets.errors] : []
1678
+ ];
1679
+ const high = security.sast.findings.filter((item) => item.severity.toLowerCase() === "high").length;
1680
+ const critical = security.sast.findings.filter((item) => item.severity.toLowerCase() === "critical").length;
1681
+ const unknownSecurity = security.sast.findings.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
1682
+ if (unknownSecurity.length) securityMissing.push(`${unknownSecurity.length} SAST findings have unknown severity`);
1683
+ const securityFailures = [
1684
+ ...high > policy.codeSecurity.maximumHigh ? [`${high} high SAST findings exceed ${policy.codeSecurity.maximumHigh}`] : [],
1685
+ ...critical > policy.codeSecurity.maximumCritical ? [`${critical} critical SAST findings exceed ${policy.codeSecurity.maximumCritical}`] : [],
1686
+ ...security.secrets.findings.length > policy.codeSecurity.maximumSecrets ? [`${security.secrets.findings.length} detected secrets exceed ${policy.codeSecurity.maximumSecrets}`] : []
1687
+ ];
1688
+ const securityVersion = `Semgrep ${security.sast.version} \xB7 Gitleaks ${security.secrets.version}`;
1689
+ const codeSecurity = securityMissing.length ? { ...incomplete([...securityMissing, ...securityFailures], "Semgrep / Gitleaks", securityVersion, security.sast.filesScanned), findings: [...security.sast.findings, ...security.secrets.findings] } : {
1690
+ status: securityFailures.length ? "fail" : "pass",
1691
+ issues: securityFailures,
1692
+ tool: "Semgrep / Gitleaks",
1693
+ version: securityVersion,
1694
+ scanned: security.sast.filesScanned,
1695
+ findings: [...security.sast.findings, ...security.secrets.findings]
1696
+ };
1697
+ const compatibility2 = app.compatibility;
1698
+ const projectNames = new Set(compatibility2.projects.map((project) => project.name));
1699
+ const missingProjects = policy.compatibility.requiredProjects.filter((name) => !projectNames.has(name));
1700
+ const incompleteProjects = compatibility2.projects.filter((project) => project.passed + project.failed < policy.compatibility.minimumTestsPerProject);
1701
+ const missingBrowserVersions = compatibility2.projects.filter((project) => !project.browserVersion);
1702
+ const expectedEngines = {
1703
+ "chromium-desktop": "chromium",
1704
+ "firefox-desktop": "firefox",
1705
+ "webkit-desktop": "webkit",
1706
+ "android-chrome": "chromium",
1707
+ "iphone-safari": "webkit"
1708
+ };
1709
+ const invalidEngines = compatibility2.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
1710
+ const invalidDevices = compatibility2.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
1711
+ const missingViewports = compatibility2.projects.filter((project) => new Set(project.viewportWidths ?? []).size < 2);
1712
+ const compatibilityMissing = [
1713
+ ...!compatibility2.complete ? ["Compatibility test run did not complete", ...compatibility2.errors] : [],
1714
+ ...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
1715
+ ...incompleteProjects.map((project) => `${project.name} ran fewer than ${policy.compatibility.minimumTestsPerProject} tests`),
1716
+ ...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
1717
+ ...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
1718
+ ...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
1719
+ ...missingViewports.map((project) => `${project.name} did not test two viewport widths`)
1720
+ ];
1721
+ const failedProjects = compatibility2.projects.filter((project) => project.failed > 0);
1722
+ const compatibilityDomain = compatibilityMissing.length ? { ...incomplete([...compatibilityMissing, ...failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`)], compatibility2.tool, compatibility2.version), matrix: compatibility2.projects } : {
1723
+ status: failedProjects.length ? "fail" : "pass",
1724
+ issues: failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`),
1725
+ tool: compatibility2.tool,
1726
+ version: compatibility2.version,
1727
+ scanned: compatibility2.projects.reduce((count, project) => count + project.passed + project.failed, 0),
1728
+ matrix: compatibility2.projects
1729
+ };
1730
+ return { dependencySecurity, codeQuality, codeSecurity, compatibility: compatibilityDomain };
1731
+ }
1732
+
1733
+ // src/governance/policy.ts
1734
+ import { createHash as createHash3 } from "crypto";
1735
+ import { readFile as readFile9 } from "fs/promises";
1736
+ import { resolve as resolve8 } from "path";
904
1737
  import { valid as valid2 } from "semver";
905
1738
  var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
906
1739
  var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
907
1740
  function record3(value) {
908
1741
  return typeof value === "object" && value !== null && !Array.isArray(value);
909
1742
  }
1743
+ var builtInRuleIds = /* @__PURE__ */ new Set([
1744
+ "module-cycle",
1745
+ "duplicate-import",
1746
+ "high-fan-out",
1747
+ "high-fan-in",
1748
+ "orphan-module",
1749
+ "unused-export",
1750
+ "large-component",
1751
+ "deep-jsx",
1752
+ "high-complexity",
1753
+ "deep-dependency-chain"
1754
+ ]);
1755
+ function parseAssessmentPolicy(value) {
1756
+ if (!record3(value) || !record3(value.architecture)) throw new Error("assessment.architecture is required");
1757
+ const architecture = value.architecture;
1758
+ if (typeof architecture.minimumScore !== "number" || !Number.isFinite(architecture.minimumScore) || architecture.minimumScore < 0 || architecture.minimumScore > 100) throw new Error("assessment.architecture.minimumScore must be 0\u2013100");
1759
+ if (!Number.isSafeInteger(architecture.maximumErrors) || architecture.maximumErrors < 0) {
1760
+ throw new Error("assessment.architecture.maximumErrors must be a nonnegative integer");
1761
+ }
1762
+ if (!Array.isArray(architecture.blockedRuleIds) || !architecture.blockedRuleIds.every((item) => typeof item === "string") || new Set(architecture.blockedRuleIds).size !== architecture.blockedRuleIds.length) {
1763
+ throw new Error("assessment.architecture.blockedRuleIds must be a unique string array");
1764
+ }
1765
+ const rawAnalysis = architecture.analysis ?? {};
1766
+ if (!record3(rawAnalysis)) throw new Error("assessment.architecture.analysis must be an object");
1767
+ if (Array.isArray(rawAnalysis.policies) && rawAnalysis.policies.some((item) => record3(item) && item.enabled === false)) {
1768
+ throw new Error("Central architecture policies cannot be disabled");
1769
+ }
1770
+ const analysis = parseAnalysisConfig({ analysis: rawAnalysis, policies: rawAnalysis.policies });
1771
+ for (const policy of analysis.policies) {
1772
+ if (builtInRuleIds.has(policy.id)) throw new Error(`Architecture boundary ID ${policy.id} conflicts with a built-in rule`);
1773
+ }
1774
+ const known = /* @__PURE__ */ new Set([...builtInRuleIds, ...analysis.policies.map((item) => item.id)]);
1775
+ for (const id of architecture.blockedRuleIds) {
1776
+ if (!known.has(id)) throw new Error(`Unknown blocked rule ID ${id}`);
1777
+ }
1778
+ let evidence;
1779
+ if (value.evidence !== void 0) {
1780
+ if (!record3(value.evidence)) throw new Error("assessment.evidence must be an object");
1781
+ const gates = value.evidence;
1782
+ const nonnegative2 = (source, key, label) => {
1783
+ const result = source[key];
1784
+ if (!Number.isSafeInteger(result) || result < 0) throw new Error(`${label}.${key} must be a nonnegative integer`);
1785
+ return result;
1786
+ };
1787
+ if (!Number.isFinite(gates.maximumAgeHours) || gates.maximumAgeHours <= 0 || gates.maximumAgeHours > 168) {
1788
+ throw new Error("assessment.evidence.maximumAgeHours must be greater than 0 and at most 168");
1789
+ }
1790
+ for (const domain of ["dependencySecurity", "codeQuality", "codeSecurity", "compatibility"]) {
1791
+ if (!record3(gates[domain])) throw new Error(`assessment.evidence.${domain} is required`);
1792
+ }
1793
+ const dependencySecurity = gates.dependencySecurity;
1794
+ const codeQuality = gates.codeQuality;
1795
+ const codeSecurity = gates.codeSecurity;
1796
+ const compatibility2 = gates.compatibility;
1797
+ const minimumLineCoverage = codeQuality.minimumLineCoverage;
1798
+ if (typeof minimumLineCoverage !== "number" || !Number.isFinite(minimumLineCoverage) || minimumLineCoverage < 0 || minimumLineCoverage > 100) {
1799
+ throw new Error("assessment.evidence.codeQuality.minimumLineCoverage must be 0\u2013100");
1800
+ }
1801
+ if (typeof codeQuality.requireTypecheck !== "boolean" || typeof codeQuality.requireTests !== "boolean") {
1802
+ throw new Error("assessment.evidence.codeQuality requires boolean requireTypecheck and requireTests");
1803
+ }
1804
+ const projects = compatibility2.requiredProjects;
1805
+ if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
1806
+ throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
1807
+ }
1808
+ evidence = {
1809
+ maximumAgeHours: gates.maximumAgeHours,
1810
+ dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
1811
+ codeQuality: {
1812
+ maximumLintErrors: nonnegative2(codeQuality, "maximumLintErrors", "assessment.evidence.codeQuality"),
1813
+ minimumLineCoverage,
1814
+ requireTypecheck: codeQuality.requireTypecheck,
1815
+ requireTests: codeQuality.requireTests
1816
+ },
1817
+ codeSecurity: { maximumHigh: nonnegative2(codeSecurity, "maximumHigh", "assessment.evidence.codeSecurity"), maximumCritical: nonnegative2(codeSecurity, "maximumCritical", "assessment.evidence.codeSecurity"), maximumSecrets: nonnegative2(codeSecurity, "maximumSecrets", "assessment.evidence.codeSecurity") },
1818
+ compatibility: { requiredProjects: projects, minimumTestsPerProject: nonnegative2(compatibility2, "minimumTestsPerProject", "assessment.evidence.compatibility") }
1819
+ };
1820
+ }
1821
+ return { architecture: {
1822
+ minimumScore: architecture.minimumScore,
1823
+ maximumErrors: architecture.maximumErrors,
1824
+ blockedRuleIds: architecture.blockedRuleIds,
1825
+ analysis
1826
+ }, ...evidence ? { evidence } : {} };
1827
+ }
910
1828
  function parsePolicy(raw, source) {
911
1829
  let parsed;
912
1830
  try {
@@ -923,26 +1841,27 @@ function parsePolicy(raw, source) {
923
1841
  if (!record3(parsed.profiles) || Object.keys(parsed.profiles).length === 0) {
924
1842
  throw new Error("Policy profiles must be a nonempty object");
925
1843
  }
926
- const profiles2 = {};
1844
+ const profiles3 = {};
927
1845
  for (const [name, rules] of Object.entries(parsed.profiles)) {
928
1846
  if (!record3(rules) || Object.keys(rules).length === 0) {
929
1847
  throw new Error(`Policy profile ${name} must list packages`);
930
1848
  }
931
1849
  const versions = {};
932
- for (const [packageName, version] of Object.entries(rules)) {
933
- if (typeof version !== "string" || !minimumVersion.test(version) || valid2(version.slice(2)) === null) {
1850
+ for (const [packageName, version2] of Object.entries(rules)) {
1851
+ if (typeof version2 !== "string" || !minimumVersion.test(version2) || valid2(version2.slice(2)) === null) {
934
1852
  throw new Error(`Policy minimum version (>=X.Y.Z) required for ${packageName} in ${name}`);
935
1853
  }
936
- versions[packageName] = version;
1854
+ versions[packageName] = version2;
937
1855
  }
938
- profiles2[name] = versions;
1856
+ profiles3[name] = versions;
939
1857
  }
940
1858
  return {
941
1859
  schemaVersion: 2,
942
1860
  policyVersion: parsed.policyVersion,
943
- profiles: profiles2,
1861
+ profiles: profiles3,
1862
+ ...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
944
1863
  source,
945
- sha256: createHash("sha256").update(raw).digest("hex")
1864
+ sha256: createHash3("sha256").update(raw).digest("hex")
946
1865
  };
947
1866
  }
948
1867
  async function loadPolicy(source) {
@@ -959,12 +1878,693 @@ async function loadPolicy(source) {
959
1878
  }
960
1879
  return parsePolicy(await response.text(), source);
961
1880
  }
962
- return parsePolicy(await readFile7(resolve6(source), "utf8"), source);
1881
+ return parsePolicy(await readFile9(resolve8(source), "utf8"), source);
1882
+ }
1883
+
1884
+ // src/governance/assess.ts
1885
+ var notEvaluated = [
1886
+ "vulnerability databases",
1887
+ "licenses",
1888
+ "secret detection",
1889
+ "test execution",
1890
+ "runtime behavior",
1891
+ "branch protection",
1892
+ "repository access controls",
1893
+ "cross-repository rollout status"
1894
+ ];
1895
+ function statusOf(statuses) {
1896
+ if (statuses.includes("incomplete")) return "incomplete";
1897
+ if (statuses.includes("fail")) return "fail";
1898
+ return "pass";
1899
+ }
1900
+ function escapeHtml2(value) {
1901
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1902
+ }
1903
+ function renderAssessmentHtml(report) {
1904
+ const labels = {
1905
+ inventory: "App inventory",
1906
+ dependencies: "Dependency policy",
1907
+ architecture: "Architecture",
1908
+ dependencySecurity: "Dependency security",
1909
+ codeQuality: "Code quality",
1910
+ codeSecurity: "Code security",
1911
+ compatibility: "Browser compatibility"
1912
+ };
1913
+ const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
1914
+ const overview = domainNames.map((name) => {
1915
+ const statuses = report.apps.map((app) => app.domains[name]?.status ?? "incomplete");
1916
+ const status = statusOf(statuses);
1917
+ const passed = statuses.filter((item) => item === "pass").length;
1918
+ return `<li><span>${escapeHtml2(labels[name] ?? name)}</span><strong class="${status}">${escapeHtml2(status)}</strong><small>${passed}/${report.apps.length} apps passed</small></li>`;
1919
+ }).join("");
1920
+ const apps = report.apps.map((app) => {
1921
+ const domains = Object.entries(app.domains).map(([name, domain]) => {
1922
+ const evidence = domain;
1923
+ const identity = evidence.tool ? `<p><small>${escapeHtml2(evidence.tool)} ${escapeHtml2(evidence.version ?? "")} \xB7 scanned ${escapeHtml2(evidence.scanned ?? "unknown")}</small></p>` : "";
1924
+ const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml2(key)}: ${escapeHtml2(value)}`).join(" \xB7 ")}</small></p>` : "";
1925
+ const findings3 = evidence.findings?.length ? `<details><summary>${evidence.findings.length} finding(s)</summary><ul>${evidence.findings.map((item) => `<li><strong>${escapeHtml2(item.severity)}</strong> ${escapeHtml2(item.id)} \xB7 <code>${escapeHtml2(item.subject)}</code> \u2014 ${escapeHtml2(item.message)}${item.remediation ? ` \xB7 ${escapeHtml2(item.remediation)}` : ""}</li>`).join("")}</ul></details>` : "";
1926
+ const matrix = evidence.matrix?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>Browser / device</th><th>Widths</th></tr></thead><tbody>${evidence.matrix.map((item) => `<tr><th scope="row">${escapeHtml2(item.name)}</th><td>${item.passed}</td><td>${item.failed}</td><td>${item.skipped}</td><td>${escapeHtml2([item.browserVersion, item.device].filter(Boolean).join(" \xB7 ") || "Not recorded")}</td><td>${escapeHtml2(item.viewportWidths?.join(", ") ?? "Not recorded")}</td></tr>`).join("")}</tbody></table></div>` : "";
1927
+ return `<tr><th scope="row">${escapeHtml2(labels[name] ?? name)}</th><td class="${domain.status}">${escapeHtml2(domain.status)}</td><td>${identity}${metrics}${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : "No issues"}${findings3}${matrix}</td></tr>`;
1928
+ }).join("");
1929
+ const architecture = app.domains.architecture;
1930
+ const categoryRows = architecture.scoreBreakdown?.categories.map((category) => `<tr><th scope="row">${escapeHtml2(category.id)}</th><td>${escapeHtml2(category.weight)}%</td><td>${escapeHtml2(category.score)}</td><td>${escapeHtml2(category.findingCount)}</td></tr>`).join("") ?? "";
1931
+ const findings2 = architecture.findings.map((finding) => `<tr><td>${escapeHtml2(finding.severity)}</td><td>${escapeHtml2(finding.ruleId)}</td><td><code>${escapeHtml2(finding.file)}:${finding.line}</code></td><td>${escapeHtml2(finding.evidence)}</td></tr>`).join("");
1932
+ return `<section class="app"><h2>${escapeHtml2(app.path)} <span class="${app.status}">${escapeHtml2(app.status)}</span></h2><p>Profile: ${escapeHtml2(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${findings2 ? `<details><summary>Architecture findings (${architecture.findings.length})</summary><div class="scroll"><table><thead><tr><th>Severity</th><th>Rule</th><th>Location</th><th>Evidence</th></tr></thead><tbody>${findings2}</tbody></table></div></details>` : ""}</section>`;
1933
+ }).join("");
1934
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>NKLattice governance assessment</title><style>
1935
+ :root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#f3f6f4;color:#1b2925}body{max-width:1160px;margin:auto;padding:2rem 1rem}header,.app,.meta,.overview{background:white;border:1px solid #d9e1dc;border-radius:12px;padding:1.4rem;margin:0 0 1.2rem}header{background:#13231d;color:#f5f8f4;padding:2rem}header small{color:#9de2bc;font-weight:700;letter-spacing:.12em}header p{color:#c8d6ce}h1{font-size:clamp(2rem,5vw,3.5rem);line-height:1.08;margin:.3rem 0 1rem}h2{font-size:1.35rem;margin:0 0 .75rem}p{margin:.5rem 0}code{overflow-wrap:anywhere}.pass{color:#146c43;font-weight:700}.fail{color:#b3242b;font-weight:700}.incomplete{color:#8a5500;font-weight:700}header .pass{color:#9de2bc}.scroll{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d9e1dc;padding:.8rem}th:first-child{width:12rem}ul{padding-left:1.2rem;margin:.1rem 0}li+li{margin-top:.3rem}small{color:#50645c}details{border-top:1px solid #d9e1dc;margin-top:1rem;padding-top:1rem}summary{cursor:pointer;font-weight:650}details[open] summary{margin-bottom:.6rem}.domain-overview{list-style:none;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.65rem;padding:0;margin:1rem 0 0}.domain-overview li{border:1px solid #d9e1dc;background:#f8faf8;border-radius:8px;padding:.9rem;display:grid;grid-template-columns:1fr auto;gap:.35rem}.domain-overview li small{grid-column:1/-1}.domain-overview li+li{margin:0}
1936
+ @media(max-width:800px){.domain-overview{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:480px){.domain-overview{grid-template-columns:1fr}}</style></head><body><header><small>NKLATTICE \xB7 CONFIGURED POLICY ASSESSMENT</small><h1>Project governance <span class="${report.status}">${escapeHtml2(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s) \xB7 policy ${escapeHtml2(report.policy.version)}</p></header><section class="overview"><h2>Assessment by domain</h2><p>Each status is based on the checked apps and fresh scanner evidence. Open an app below for findings and browser versions.</p><ul class="domain-overview">${overview}</ul></section><section class="meta"><h2>Evidence identity</h2><p>Policy ${escapeHtml2(report.policy.version)} \xB7 <code>${escapeHtml2(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml2(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml2(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml2(basename2(report.inventory.source))}</code></p>${report.evidence ? `<p>Scanners: ${escapeHtml2(report.evidence.generatedAt)} \xB7 commit <code>${escapeHtml2(report.evidence.commit)}</code> \xB7 input <code>${escapeHtml2(report.evidence.inputSha256)}</code></p>` : "<p>Scanner evidence unavailable.</p>"}${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml2).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml2).join(", ")}.</p></section></body></html>
1937
+ `;
1938
+ }
1939
+ async function assessGovernance(options) {
1940
+ const scanRoot = resolve9(options.scanRoot);
1941
+ const workspaceRoot = resolve9(options.workspaceRoot ?? scanRoot);
1942
+ const scanRelative = relative7(workspaceRoot, scanRoot);
1943
+ if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
1944
+ if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
1945
+ const assessment = parseAssessmentPolicy(options.policy.assessment);
1946
+ const outputDirectory = resolve9(options.outputDirectory ?? join10(scanRoot, ".nklattice-governance"));
1947
+ const inventorySource = resolve9(options.inventory ?? join10(scanRoot, ".nklattice-apps.json"));
1948
+ let inventory;
1949
+ const issues = [];
1950
+ try {
1951
+ inventory = await loadInventory(inventorySource, scanRoot);
1952
+ } catch (error) {
1953
+ issues.push(`Inventory: ${error instanceof Error ? error.message : String(error)}`);
1954
+ }
1955
+ let apps = [];
1956
+ try {
1957
+ apps = await discoverApps(scanRoot, workspaceRoot);
1958
+ } catch (error) {
1959
+ issues.push(`Discovery: ${error instanceof Error ? error.message : String(error)}`);
1960
+ }
1961
+ if (apps.length === 0) issues.push("No managed applications discovered");
1962
+ if (inventory) issues.push(...compareInventory(inventory, apps, scanRoot));
1963
+ if (options.expectedApps !== void 0 && apps.length !== options.expectedApps) {
1964
+ issues.push(`Expected ${options.expectedApps} managed apps; discovered ${apps.length}`);
1965
+ }
1966
+ let evidence;
1967
+ if (assessment.evidence) {
1968
+ if (!options.evidence && !options.trustedEvidence) issues.push("Scan evidence is required by the policy; run governance collect and pass --evidence");
1969
+ else try {
1970
+ evidence = options.trustedEvidence ? await validateScanEvidence(options.trustedEvidence, scanRoot, workspaceRoot, options.policy.sha256, assessment.evidence.maximumAgeHours) : await loadScanEvidence(options.evidence, scanRoot, workspaceRoot, options.policy.sha256, assessment.evidence.maximumAgeHours);
1971
+ } catch (error) {
1972
+ issues.push(`Scan evidence: ${error instanceof Error ? error.message : String(error)}`);
1973
+ }
1974
+ if (evidence) {
1975
+ const discovered = new Set(apps.map((app) => relative7(scanRoot, app.directory).replaceAll("\\", "/") || "."));
1976
+ for (const item of evidence.apps) if (!discovered.has(item.path)) issues.push(`Scan evidence contains unrecognized app ${item.path}`);
1977
+ }
1978
+ }
1979
+ const results = [];
1980
+ for (const app of apps) {
1981
+ const appPath = relative7(scanRoot, app.directory).replaceAll("\\", "/") || ".";
1982
+ const inventoryIssues = issues.filter((issue) => issue.includes(appPath) || issue.startsWith("Inventory:") || issue.startsWith("Discovery:"));
1983
+ const inventoryDomain = { status: inventoryIssues.length ? "incomplete" : "pass", issues: inventoryIssues };
1984
+ const dependencyDomain = { status: "pass", issues: [], findings: [] };
1985
+ try {
1986
+ dependencyDomain.findings = await checkApp(app, workspaceRoot, options.policy);
1987
+ if (dependencyDomain.findings.length) {
1988
+ dependencyDomain.status = "fail";
1989
+ dependencyDomain.issues = dependencyDomain.findings.map((item) => `${item.packageName}: ${item.reason}; required ${item.expected}, declared ${item.declared ?? "missing"}, locked ${item.locked ?? "missing"}`);
1990
+ }
1991
+ } catch (error) {
1992
+ dependencyDomain.status = "incomplete";
1993
+ dependencyDomain.issues = [error instanceof Error ? error.message : String(error)];
1994
+ }
1995
+ const architectureDomain = {
1996
+ status: "pass",
1997
+ issues: [],
1998
+ filesScanned: 0,
1999
+ localEdges: 0,
2000
+ errors: 0,
2001
+ findings: [],
2002
+ diagnostics: []
2003
+ };
2004
+ try {
2005
+ const scan = await scanProjectDetailed(app.directory, assessment.architecture.analysis.aliases);
2006
+ architectureDomain.filesScanned = scan.modules.length;
2007
+ architectureDomain.diagnostics = scan.diagnostics;
2008
+ const graph = buildGraph(scan.modules);
2009
+ architectureDomain.localEdges = graph.edges.length;
2010
+ architectureDomain.findings = evaluateRules(graph, assessment.architecture.analysis);
2011
+ const score = scoreFindings(architectureDomain.findings, assessment.architecture.analysis);
2012
+ architectureDomain.score = score.score;
2013
+ architectureDomain.grade = score.grade;
2014
+ architectureDomain.scoreBreakdown = score;
2015
+ architectureDomain.errors = architectureDomain.findings.filter((item) => item.severity === "error").length;
2016
+ if (scan.modules.length === 0) architectureDomain.diagnostics.push("No supported source files found");
2017
+ if (architectureDomain.diagnostics.length) {
2018
+ architectureDomain.status = "incomplete";
2019
+ architectureDomain.issues.push(...architectureDomain.diagnostics);
2020
+ }
2021
+ if (score.score < assessment.architecture.minimumScore) {
2022
+ architectureDomain.issues.push(`Score ${score.score} is below minimum ${assessment.architecture.minimumScore}`);
2023
+ }
2024
+ if (architectureDomain.errors > assessment.architecture.maximumErrors) {
2025
+ architectureDomain.issues.push(`${architectureDomain.errors} error findings exceed maximum ${assessment.architecture.maximumErrors}`);
2026
+ }
2027
+ const blocked = architectureDomain.findings.filter((item) => assessment.architecture.blockedRuleIds.includes(item.ruleId));
2028
+ for (const finding of blocked) architectureDomain.issues.push(`Blocked ${finding.ruleId} at ${finding.file}:${finding.line}: ${finding.evidence}`);
2029
+ if (architectureDomain.status !== "incomplete" && architectureDomain.issues.length) architectureDomain.status = "fail";
2030
+ } catch (error) {
2031
+ architectureDomain.status = "incomplete";
2032
+ architectureDomain.issues.push(error instanceof Error ? error.message : String(error));
2033
+ }
2034
+ const extra = assessment.evidence ? evaluateAppEvidence(evidence?.apps.find((item) => item.path === appPath && item.profile === app.profile), assessment.evidence) : {};
2035
+ const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain, ...extra };
2036
+ results.push({ path: appPath, profile: app.profile, status: statusOf(Object.values(domains).map((domain) => domain.status)), domains });
2037
+ }
2038
+ const report = {
2039
+ schema: "urn:nklattice:governance-assessment:1",
2040
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2041
+ status: issues.length ? "incomplete" : statusOf(results.map((app) => app.status)),
2042
+ policy: { version: options.policy.policyVersion, source: options.policy.source, sha256: options.policy.sha256 },
2043
+ ...evidence ? { evidence: { source: options.evidence ? resolve9(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
2044
+ inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
2045
+ scope: {
2046
+ domains: ["explicit app inventory", "dependency minimums", "source architecture", ...assessment.evidence ? ["known dependency advisories", "source quality", "source security", "browser and mobile emulation"] : []],
2047
+ notEvaluated: assessment.evidence ? ["licenses", "physical Android/iOS devices", "runtime performance", "cryptographic evidence attestation", "branch protection", "repository access controls", "cross-repository rollout status"] : notEvaluated
2048
+ },
2049
+ issues,
2050
+ apps: results
2051
+ };
2052
+ await mkdir3(outputDirectory, { recursive: true });
2053
+ await writeFile2(join10(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
2054
+ `);
2055
+ await writeFile2(join10(outputDirectory, "report.html"), renderAssessmentHtml(report));
2056
+ return report;
2057
+ }
2058
+
2059
+ // src/governance/collect.ts
2060
+ import { execFile as execFile2 } from "child_process";
2061
+ import { createHash as createHash4 } from "crypto";
2062
+ import { mkdtemp, mkdir as mkdir4, readFile as readFile10, rm, writeFile as writeFile3 } from "fs/promises";
2063
+ import { tmpdir } from "os";
2064
+ import { dirname as dirname4, join as join11, relative as relative8, resolve as resolve10, sep as sep7 } from "path";
2065
+ import { promisify as promisify2 } from "util";
2066
+ import fg2 from "fast-glob";
2067
+ import YAML2 from "yaml";
2068
+ var execFileAsync2 = promisify2(execFile2);
2069
+ var cap = 16 * 1024 * 1024;
2070
+ async function run(file, args, cwd, env) {
2071
+ try {
2072
+ const result = await execFileAsync2(file, args, { cwd, env, timeout: 3e5, maxBuffer: cap });
2073
+ return { code: 0, stdout: result.stdout, stderr: result.stderr };
2074
+ } catch (error) {
2075
+ const failure = error;
2076
+ return {
2077
+ code: typeof failure.code === "number" ? failure.code : -1,
2078
+ stdout: failure.stdout ?? "",
2079
+ stderr: failure.stderr ?? "",
2080
+ ...typeof failure.code === "number" ? {} : { error: failure.killed ? "Scanner timed out" : failure.message }
2081
+ };
2082
+ }
2083
+ }
2084
+ function inside(parent, child) {
2085
+ const path = relative8(parent, child);
2086
+ return path === "" || path !== ".." && !path.startsWith(`..${sep7}`);
2087
+ }
2088
+ async function binary(name, app, workspaceRoot) {
2089
+ let directory = app;
2090
+ while (inside(workspaceRoot, directory)) {
2091
+ const candidate = join11(directory, "node_modules", ".bin", name);
2092
+ try {
2093
+ await readFile10(candidate);
2094
+ return candidate;
2095
+ } catch {
2096
+ }
2097
+ if (directory === workspaceRoot) break;
2098
+ directory = dirname4(directory);
2099
+ }
2100
+ return name;
2101
+ }
2102
+ async function version(file, cwd) {
2103
+ const result = await run(file, ["--version"], cwd);
2104
+ if (result.code !== 0) return "unavailable";
2105
+ const reported = result.stdout.trim().slice(0, 120) || "unknown";
2106
+ if (file === "gitleaks" && reported.includes("version is set by build process")) {
2107
+ const located = await run("which", [file], cwd);
2108
+ if (located.code === 0) {
2109
+ const build = await run("go", ["version", "-m", located.stdout.trim()], cwd);
2110
+ const match = build.stdout.match(/^\s*mod\s+\S+\/gitleaks\/v8\s+(v\d+\.\d+\.\d+)/m);
2111
+ if (match) return match[1];
2112
+ }
2113
+ }
2114
+ return reported;
2115
+ }
2116
+ function json(text) {
2117
+ try {
2118
+ return JSON.parse(text);
2119
+ } catch {
2120
+ return void 0;
2121
+ }
2122
+ }
2123
+ function obj(value) {
2124
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2125
+ }
2126
+ function str(value) {
2127
+ return typeof value === "string" ? value : "";
2128
+ }
2129
+ function num(value) {
2130
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
2131
+ }
2132
+ async function sourcePaths(app) {
2133
+ return fg2("**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}", {
2134
+ cwd: app,
2135
+ dot: true,
2136
+ onlyFiles: true,
2137
+ followSymbolicLinks: false,
2138
+ ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/build/**", "**/coverage/**", "**/*.d.ts"]
2139
+ });
2140
+ }
2141
+ async function sourceCount(app) {
2142
+ return (await sourcePaths(app)).length;
2143
+ }
2144
+ async function productionSources(app) {
2145
+ return (await sourcePaths(app)).filter((path) => !/(^|\/)(e2e|tests?|__tests__|fixtures)\//.test(path) && !/\.(test|spec|config)\.[cm]?[jt]sx?$/.test(path));
2146
+ }
2147
+ async function dependencyAudit(app, workspaceRoot, cache) {
2148
+ const failure = (errors2, tool = "npm/pnpm audit") => ({ tool, version: "unavailable", complete: false, packagesScanned: 0, advisories: [], errors: errors2 });
2149
+ let lock;
2150
+ try {
2151
+ lock = await findLockfile(app.directory, workspaceRoot);
2152
+ } catch (error) {
2153
+ return failure([error instanceof Error ? error.message : String(error)]);
2154
+ }
2155
+ const cached = cache.get(lock.path);
2156
+ if (cached) return cached;
2157
+ const raw = await readFile10(lock.path, "utf8");
2158
+ const parsedLock = lock.kind === "npm" ? json(raw) : YAML2.parse(raw);
2159
+ const packages = obj(parsedLock) && obj(parsedLock.packages) ? Object.keys(parsedLock.packages).filter((key) => key !== "").length : 0;
2160
+ const executable = lock.kind === "npm" ? "npm" : "pnpm";
2161
+ const toolVersion = await version(executable, lock.root);
2162
+ const result = await run(executable, ["audit", "--json"], lock.root);
2163
+ const report = json(result.stdout);
2164
+ if (!obj(report)) {
2165
+ const evidence2 = { ...failure([result.error ?? "Audit JSON output is unavailable"], `${executable} audit`), version: toolVersion, packagesScanned: packages };
2166
+ cache.set(lock.path, evidence2);
2167
+ return evidence2;
2168
+ }
2169
+ const auditMetadata = obj(report.metadata) ? report.metadata : void 0;
2170
+ const auditedPackages = lock.kind === "npm" && auditMetadata && obj(auditMetadata.dependencies) ? num(auditMetadata.dependencies.total) : lock.kind === "pnpm" && auditMetadata ? num(auditMetadata.totalDependencies) : 0;
2171
+ const supportedShape = lock.kind === "npm" ? report.auditReportVersion === 2 && obj(report.vulnerabilities) : obj(report.advisories);
2172
+ if (!supportedShape || packages === 0 || auditedPackages < packages || result.error || result.code > 1) {
2173
+ const evidence2 = { ...failure([result.error ?? "Audit output was unavailable or lockfile package inventory was empty"], `${executable} audit`), version: toolVersion, packagesScanned: packages };
2174
+ evidence2.errors = [result.error ?? `Audit schema or package coverage is incomplete (${auditedPackages}/${packages})`];
2175
+ cache.set(lock.path, evidence2);
2176
+ return evidence2;
2177
+ }
2178
+ const advisories = [];
2179
+ if (lock.kind === "npm" && obj(report.vulnerabilities)) {
2180
+ for (const [name, value] of Object.entries(report.vulnerabilities)) {
2181
+ if (!obj(value)) continue;
2182
+ const via = Array.isArray(value.via) ? value.via.find(obj) : void 0;
2183
+ advisories.push({
2184
+ id: obj(via) ? str(via.url) || str(via.title) || `npm:${name}` : `npm:${name}`,
2185
+ severity: str(value.severity) || "unknown",
2186
+ subject: name,
2187
+ message: "Known advisory affects a dependency",
2188
+ remediation: "Upgrade the affected package or apply a reviewed exception"
2189
+ });
2190
+ }
2191
+ } else if (lock.kind === "pnpm" && obj(report.advisories)) {
2192
+ for (const [key, value] of Object.entries(report.advisories)) {
2193
+ if (!obj(value)) continue;
2194
+ advisories.push({
2195
+ id: str(value.url) || str(value.id) || key,
2196
+ severity: str(value.severity) || "unknown",
2197
+ subject: str(value.module_name) || "workspace dependency",
2198
+ message: "Known advisory affects a workspace dependency",
2199
+ remediation: "Upgrade the affected package or apply a reviewed exception"
2200
+ });
2201
+ }
2202
+ } else if (result.code !== 0) {
2203
+ const evidence2 = { tool: `${executable} audit`, version: toolVersion, complete: false, packagesScanned: packages, advisories: [], errors: ["Audit returned an unsupported JSON shape"] };
2204
+ cache.set(lock.path, evidence2);
2205
+ return evidence2;
2206
+ }
2207
+ const registryErrors = [report.error, report.errors].filter(Boolean);
2208
+ const evidence = {
2209
+ tool: `${executable} audit${lock.root !== app.directory ? " (workspace-wide)" : ""}`,
2210
+ version: toolVersion,
2211
+ complete: registryErrors.length === 0,
2212
+ packagesScanned: packages,
2213
+ advisories,
2214
+ errors: registryErrors.length ? ["Advisory service returned an error"] : []
2215
+ };
2216
+ cache.set(lock.path, evidence);
2217
+ return evidence;
2218
+ }
2219
+ async function lint(app, workspaceRoot) {
2220
+ const file = await binary("eslint", app.directory, workspaceRoot);
2221
+ const toolVersion = await version(file, app.directory);
2222
+ const result = await run(file, [".", "--format", "json"], app.directory);
2223
+ const parsed = json(result.stdout);
2224
+ if (!Array.isArray(parsed) || result.error || !parsed.every(obj)) {
2225
+ return {
2226
+ tool: "ESLint",
2227
+ version: toolVersion,
2228
+ complete: false,
2229
+ filesScanned: 0,
2230
+ errorCount: 0,
2231
+ warnings: 0,
2232
+ errorsDetail: [result.error ?? "ESLint JSON output is unavailable; install and configure ESLint"]
2233
+ };
2234
+ }
2235
+ const files = parsed;
2236
+ if (!files.every((item) => str(item.filePath) && Number.isSafeInteger(item.errorCount) && Number.isSafeInteger(item.warningCount))) {
2237
+ return {
2238
+ tool: "ESLint",
2239
+ version: toolVersion,
2240
+ complete: false,
2241
+ filesScanned: 0,
2242
+ errorCount: 0,
2243
+ warnings: 0,
2244
+ errorsDetail: ["ESLint JSON result did not contain reliable file paths or diagnostic counts"]
2245
+ };
2246
+ }
2247
+ const expected = await sourcePaths(app.directory);
2248
+ const analyzed = new Set(files.map((item) => str(item.filePath).replaceAll("\\", "/")));
2249
+ const missing = expected.filter((path) => ![...analyzed].some((file2) => file2.endsWith(`/${path}`) || file2 === path));
2250
+ return {
2251
+ tool: "ESLint",
2252
+ version: toolVersion,
2253
+ complete: missing.length === 0,
2254
+ filesScanned: expected.length - missing.length,
2255
+ errorCount: files.reduce((sum, file2) => sum + num(file2.errorCount), 0),
2256
+ warnings: files.reduce((sum, file2) => sum + num(file2.warningCount), 0),
2257
+ errorsDetail: missing.length ? [`ESLint skipped ${missing.length} eligible source files`] : []
2258
+ };
2259
+ }
2260
+ async function typecheck(app, workspaceRoot) {
2261
+ const production = await productionSources(app.directory);
2262
+ const applicable = production.some((path) => /\.(ts|tsx|mts|cts)$/.test(path));
2263
+ const file = await binary("tsc", app.directory, workspaceRoot);
2264
+ const toolVersion = await version(file, app.directory);
2265
+ if (!applicable) return { tool: "TypeScript", version: toolVersion, applicable: false, complete: true, passed: true, errors: [] };
2266
+ const config = await readFile10(join11(app.directory, "tsconfig.json"), "utf8").catch(() => "");
2267
+ if (!config) return {
2268
+ tool: "TypeScript",
2269
+ version: toolVersion,
2270
+ applicable: true,
2271
+ complete: false,
2272
+ passed: false,
2273
+ errors: ["TypeScript source has no readable tsconfig.json"]
2274
+ };
2275
+ const result = await run(file, ["--project", app.directory, "--noEmit", "--pretty", "false"], app.directory);
2276
+ return {
2277
+ tool: "TypeScript",
2278
+ version: toolVersion,
2279
+ applicable: true,
2280
+ complete: !result.error && toolVersion !== "unavailable",
2281
+ passed: result.code === 0,
2282
+ errors: result.error ? [result.error] : result.code === 0 ? [] : [result.stdout.trim().slice(0, 500) || "TypeScript typecheck failed"]
2283
+ };
2284
+ }
2285
+ async function testAndCoverage(app, workspaceRoot, temp) {
2286
+ const file = await binary("vitest", app.directory, workspaceRoot);
2287
+ const toolVersion = await version(file, app.directory);
2288
+ const resultPath = join11(temp, "vitest.json");
2289
+ const coverageDirectory = join11(temp, "coverage");
2290
+ const result = await run(file, [
2291
+ "run",
2292
+ "--reporter=json",
2293
+ "--outputFile",
2294
+ resultPath,
2295
+ "--coverage",
2296
+ "--coverage.reporter=json-summary",
2297
+ "--coverage.reportsDirectory",
2298
+ coverageDirectory
2299
+ ], app.directory);
2300
+ const output = await readFile10(resultPath, "utf8").catch(() => "");
2301
+ const parsed = json(output);
2302
+ const summary = json(await readFile10(join11(coverageDirectory, "coverage-summary.json"), "utf8").catch(() => ""));
2303
+ const tests = obj(parsed) && typeof parsed.numPassedTests === "number" && typeof parsed.numFailedTests === "number";
2304
+ const lineCoverage = obj(summary) && obj(summary.total) && obj(summary.total.lines) ? summary.total.lines.pct : void 0;
2305
+ const expectedFiles = await productionSources(app.directory);
2306
+ const coveredFiles = obj(summary) ? Object.keys(summary).filter((key) => key !== "total") : [];
2307
+ const coveredPaths = new Set(coveredFiles.map((file2) => resolve10(app.directory, file2)));
2308
+ const uncovered = expectedFiles.filter((path) => !coveredPaths.has(resolve10(app.directory, path)));
2309
+ const coverage = typeof lineCoverage === "number" && expectedFiles.length > 0 && uncovered.length === 0;
2310
+ const unhandled = obj(parsed) && Array.isArray(parsed.unhandledErrors) && parsed.unhandledErrors.length > 0;
2311
+ const reporterFailedWithoutTests = tests && result.code !== 0 && num(parsed.numFailedTests) === 0;
2312
+ return {
2313
+ tests: {
2314
+ tool: "Vitest",
2315
+ version: toolVersion,
2316
+ complete: tests && !result.error && !unhandled && !reporterFailedWithoutTests,
2317
+ passed: tests ? num(parsed.numPassedTests) : 0,
2318
+ failed: tests ? num(parsed.numFailedTests) : 0,
2319
+ errors: tests && !unhandled && !reporterFailedWithoutTests ? [] : [result.error ?? "Vitest reporter recorded an unsuccessful run or unhandled error"]
2320
+ },
2321
+ coverage: {
2322
+ tool: "Vitest coverage",
2323
+ version: toolVersion,
2324
+ complete: coverage,
2325
+ linesPercent: coverage ? lineCoverage : 0,
2326
+ errors: coverage ? [] : [uncovered.length ? `Coverage omitted ${uncovered.length} eligible source files` : "Vitest line coverage JSON summary is unavailable"]
2327
+ }
2328
+ };
2329
+ }
2330
+ async function sast(app, temp) {
2331
+ const file = "semgrep";
2332
+ const toolVersion = await version(file, app.directory);
2333
+ const outputPath = join11(temp, "semgrep.json");
2334
+ const result = await run(file, [
2335
+ "scan",
2336
+ "--config",
2337
+ "p/ci",
2338
+ "--no-git-ignore",
2339
+ "--exclude",
2340
+ "**/node_modules/**",
2341
+ "--exclude",
2342
+ "**/.next/**",
2343
+ "--exclude",
2344
+ "**/coverage/**",
2345
+ "--exclude",
2346
+ "**/dist/**",
2347
+ "--json",
2348
+ "--output",
2349
+ outputPath,
2350
+ app.directory
2351
+ ], app.directory);
2352
+ const parsed = json(await readFile10(outputPath, "utf8").catch(() => result.stdout));
2353
+ const expectedSources = await sourcePaths(app.directory);
2354
+ if (!obj(parsed) || !Array.isArray(parsed.results) || result.error) {
2355
+ return {
2356
+ tool: "Semgrep",
2357
+ version: toolVersion,
2358
+ complete: false,
2359
+ filesScanned: 0,
2360
+ findings: [],
2361
+ errors: [result.error ?? "Semgrep JSON result is unavailable; install Semgrep and enable rule download"]
2362
+ };
2363
+ }
2364
+ const findings2 = parsed.results.filter(obj).map((item) => {
2365
+ const extra = obj(item.extra) ? item.extra : {};
2366
+ const metadata = obj(extra.metadata) ? extra.metadata : {};
2367
+ const impact = str(metadata.impact).toLowerCase();
2368
+ const severity = impact === "high" || impact === "critical" ? impact : str(extra.severity).toLowerCase() === "error" ? "high" : "moderate";
2369
+ return {
2370
+ id: str(item.check_id) || "semgrep:unknown",
2371
+ severity,
2372
+ subject: `${relative8(app.directory, str(item.path) || app.directory)}:${obj(item.start) ? num(item.start.line) : 0}`,
2373
+ message: "Static security rule matched; inspect the source location",
2374
+ remediation: "Review and fix the matched rule"
2375
+ };
2376
+ });
2377
+ const scannedPaths = obj(parsed.paths) && Array.isArray(parsed.paths.scanned) ? parsed.paths.scanned.filter((item) => typeof item === "string") : [];
2378
+ const missing = expectedSources.filter((path) => !scannedPaths.some((scanned) => scanned.replaceAll("\\", "/").endsWith(`/${path}`) || scanned === path));
2379
+ const scanErrors = [
2380
+ ...Array.isArray(parsed.errors) && parsed.errors.length ? ["Semgrep reported scanner errors"] : [],
2381
+ ...missing.length ? [`Semgrep skipped ${missing.length} eligible source files`] : []
2382
+ ];
2383
+ return { tool: "Semgrep", version: toolVersion, complete: scanErrors.length === 0, filesScanned: expectedSources.length - missing.length, findings: findings2, errors: scanErrors };
2384
+ }
2385
+ async function secrets(app, temp) {
2386
+ const file = "gitleaks";
2387
+ const toolVersion = await version(file, app.directory);
2388
+ const outputPath = join11(temp, "gitleaks.json");
2389
+ const configPath = join11(temp, "gitleaks.toml");
2390
+ await writeFile3(configPath, '[extend]\nuseDefault = true\n\n[allowlist]\npaths = ["(^|/)(node_modules|\\\\.next|dist|build|coverage|test-results|playwright-report)/"]\n');
2391
+ const result = await run(file, [
2392
+ "dir",
2393
+ app.directory,
2394
+ "--config",
2395
+ configPath,
2396
+ "--report-format",
2397
+ "json",
2398
+ "--report-path",
2399
+ outputPath,
2400
+ "--redact",
2401
+ "--no-banner"
2402
+ ], app.directory);
2403
+ const parsed = json(await readFile10(outputPath, "utf8").catch(() => ""));
2404
+ const count = await sourceCount(app.directory);
2405
+ if (!Array.isArray(parsed) || result.error) {
2406
+ return {
2407
+ tool: "Gitleaks",
2408
+ version: toolVersion,
2409
+ complete: false,
2410
+ filesScanned: 0,
2411
+ findings: [],
2412
+ errors: [result.error ?? "Gitleaks JSON result is unavailable; install Gitleaks"]
2413
+ };
2414
+ }
2415
+ const findings2 = parsed.filter(obj).map((item) => ({
2416
+ id: str(item.RuleID) || "gitleaks:secret",
2417
+ severity: "critical",
2418
+ subject: `${str(item.File) ? relative8(app.directory, resolve10(app.directory, str(item.File))).replaceAll("\\", "/") : "source"}:${num(item.StartLine)}`,
2419
+ message: `Potential secret detected (fingerprint ${createHash4("sha256").update(str(item.Fingerprint)).digest("hex").slice(0, 16)})`,
2420
+ remediation: "Revoke the credential and remove it from source/history"
2421
+ }));
2422
+ return {
2423
+ tool: "Gitleaks",
2424
+ version: toolVersion,
2425
+ complete: result.code === 0 || result.code === 1,
2426
+ filesScanned: count,
2427
+ findings: findings2,
2428
+ errors: result.code > 1 ? ["Gitleaks scanner failed"] : []
2429
+ };
2430
+ }
2431
+ function playwrightProjects(value) {
2432
+ if (!obj(value) || !Array.isArray(value.suites)) return [];
2433
+ const totals = /* @__PURE__ */ new Map();
2434
+ const visit = (suite) => {
2435
+ if (!obj(suite)) return;
2436
+ for (const spec of Array.isArray(suite.specs) ? suite.specs : []) {
2437
+ if (!obj(spec) || !Array.isArray(spec.tests)) continue;
2438
+ for (const test of spec.tests) {
2439
+ if (!obj(test) || !str(test.projectName)) continue;
2440
+ const projectName = str(test.projectName);
2441
+ const project = totals.get(projectName) ?? {
2442
+ name: projectName,
2443
+ passed: 0,
2444
+ failed: 0,
2445
+ skipped: 0,
2446
+ viewportWidths: /* @__PURE__ */ new Set()
2447
+ };
2448
+ for (const annotation of Array.isArray(test.annotations) ? test.annotations : []) {
2449
+ if (!obj(annotation)) continue;
2450
+ if (annotation.type === "browser-version" && str(annotation.description)) project.browserVersion = str(annotation.description);
2451
+ if (annotation.type === "viewport-width" && /^\d+$/.test(str(annotation.description))) project.viewportWidths.add(Number(annotation.description));
2452
+ }
2453
+ const status = str(test.status);
2454
+ if (status === "expected") project.passed++;
2455
+ else if (status === "skipped") project.skipped++;
2456
+ else project.failed++;
2457
+ totals.set(project.name, project);
2458
+ }
2459
+ }
2460
+ for (const child of Array.isArray(suite.suites) ? suite.suites : []) visit(child);
2461
+ };
2462
+ for (const suite of value.suites) visit(suite);
2463
+ return [...totals.values()].map((project) => ({ ...project, viewportWidths: [...project.viewportWidths].sort((a, b) => a - b) }));
2464
+ }
2465
+ async function compatibility(app, workspaceRoot, temp) {
2466
+ const file = await binary("playwright", app.directory, workspaceRoot);
2467
+ const toolVersion = await version(file, app.directory);
2468
+ const outputPath = join11(temp, "playwright.json");
2469
+ const configPath = join11(temp, "playwright-projects.json");
2470
+ const reporterPath = join11(temp, "lattice-playwright-reporter.cjs");
2471
+ await writeFile3(reporterPath, 'module.exports=class { onBegin(config) { require("node:fs").writeFileSync(process.env.LATTICE_PLAYWRIGHT_CONFIG_OUT, JSON.stringify(config.projects.map(p => ({ name: p.name, engine: p.use.browserName || p.use.defaultBrowserType, mobile: p.use.isMobile === true, touch: p.use.hasTouch === true, userAgent: p.use.userAgent || "" })))); } };\n');
2472
+ const result = await run(
2473
+ file,
2474
+ ["test", `--reporter=json,${reporterPath}`],
2475
+ app.directory,
2476
+ { ...process.env, PLAYWRIGHT_JSON_OUTPUT_FILE: outputPath, LATTICE_PLAYWRIGHT_CONFIG_OUT: configPath }
2477
+ );
2478
+ const parsed = json(await readFile10(outputPath, "utf8").catch(() => result.stdout));
2479
+ const projects = playwrightProjects(parsed);
2480
+ const config = json(await readFile10(configPath, "utf8").catch(() => ""));
2481
+ const configurations = Array.isArray(config) && config.every(obj) ? config : [];
2482
+ for (const project of projects) {
2483
+ const configured = configurations.find((item) => item.name === project.name);
2484
+ if (!configured) continue;
2485
+ project.engine = str(configured.engine);
2486
+ project.mobile = configured.mobile === true && configured.touch === true;
2487
+ const agent = str(configured.userAgent);
2488
+ const android = agent.match(/Android\s+([\d.]+)/i);
2489
+ const iphone = agent.match(/iPhone OS\s+([\d_]+)/i);
2490
+ if (project.name === "android-chrome" && android && project.mobile) project.device = `Android ${android[1]} UA emulation`;
2491
+ if (project.name === "iphone-safari" && iphone && project.mobile) project.device = `iOS ${iphone[1].replaceAll("_", ".")} UA emulation`;
2492
+ }
2493
+ const reportedErrors = obj(parsed) && Array.isArray(parsed.errors) ? parsed.errors : [];
2494
+ const infrastructureFailure = reportedErrors.length > 0 || configurations.length === 0 || typeof result.stderr === "string" && /browser executable.*doesn't exist|failed to launch browser|install playwright browsers/i.test(result.stderr);
2495
+ return {
2496
+ tool: "Playwright",
2497
+ version: toolVersion,
2498
+ complete: Boolean(parsed) && !result.error && !infrastructureFailure && projects.length > 0,
2499
+ projects,
2500
+ errors: infrastructureFailure ? ["Playwright browser infrastructure or configuration failed"] : projects.length ? [] : [result.error ?? "Playwright JSON results are unavailable; configure required browser and mobile projects"]
2501
+ };
2502
+ }
2503
+ async function collectGovernanceEvidence(options) {
2504
+ const scanRoot = resolve10(options.scanRoot);
2505
+ const workspaceRoot = resolve10(options.workspaceRoot ?? scanRoot);
2506
+ const destination = resolve10(options.evidence);
2507
+ if (!inside(workspaceRoot, scanRoot)) throw new Error("Scan root must be inside workspace root");
2508
+ if (inside(scanRoot, destination)) throw new Error("Evidence file must be outside the scan root");
2509
+ if (!options.policy.assessment?.evidence) throw new Error("Policy assessment.evidence is required for governance collect");
2510
+ const inputSha256 = await hashAssessmentInputs(scanRoot, workspaceRoot);
2511
+ const assertStableInputs = async (stage) => {
2512
+ if (inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) {
2513
+ throw new Error(`Assessment inputs changed ${stage}; rerun on a stable checkout`);
2514
+ }
2515
+ };
2516
+ const git = await run("git", ["rev-parse", "HEAD"], workspaceRoot);
2517
+ const commit = git.code === 0 && /^[0-9a-f]{40}$/.test(git.stdout.trim()) ? git.stdout.trim() : "unversioned";
2518
+ const apps = await discoverApps(scanRoot, workspaceRoot);
2519
+ const auditCache = /* @__PURE__ */ new Map();
2520
+ const scans = [];
2521
+ for (const app of apps) {
2522
+ const temp = await mkdtemp(join11(tmpdir(), "lattice-scanners-"));
2523
+ try {
2524
+ const dependencySecurity = await dependencyAudit(app, workspaceRoot, auditCache);
2525
+ await assertStableInputs("after dependency audit");
2526
+ const qualityLint = await lint(app, workspaceRoot);
2527
+ await assertStableInputs("after ESLint");
2528
+ const qualityTypecheck = await typecheck(app, workspaceRoot);
2529
+ await assertStableInputs("after TypeScript");
2530
+ const qualityTests = await testAndCoverage(app, workspaceRoot, temp);
2531
+ await assertStableInputs("after Vitest");
2532
+ const securitySast = await sast(app, temp);
2533
+ await assertStableInputs("after Semgrep");
2534
+ const securitySecrets = await secrets(app, temp);
2535
+ await assertStableInputs("after Gitleaks");
2536
+ const compatibilityResult = await compatibility(app, workspaceRoot, temp);
2537
+ await assertStableInputs("after Playwright");
2538
+ scans.push({
2539
+ path: relative8(scanRoot, app.directory).replaceAll("\\", "/") || ".",
2540
+ profile: app.profile,
2541
+ dependencySecurity,
2542
+ codeQuality: { lint: qualityLint, typecheck: qualityTypecheck, ...qualityTests },
2543
+ codeSecurity: { sast: securitySast, secrets: securitySecrets },
2544
+ compatibility: compatibilityResult
2545
+ });
2546
+ } finally {
2547
+ await rm(temp, { recursive: true, force: true });
2548
+ }
2549
+ }
2550
+ await assertStableInputs("before evidence write");
2551
+ const report = {
2552
+ schemaVersion: 1,
2553
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2554
+ policySha256: options.policy.sha256,
2555
+ inputSha256,
2556
+ commit,
2557
+ apps: scans
2558
+ };
2559
+ await mkdir4(dirname4(destination), { recursive: true });
2560
+ await writeFile3(destination, `${JSON.stringify(report, null, 2)}
2561
+ `, { flag: "w" });
2562
+ return report;
963
2563
  }
964
2564
 
965
2565
  // src/runtime.ts
966
- function isSupportedNodeVersion(version) {
967
- const major = Number(version.split(".", 1)[0]);
2566
+ function isSupportedNodeVersion(version2) {
2567
+ const major = Number(version2.split(".", 1)[0]);
968
2568
  return Number.isInteger(major) && major >= 24;
969
2569
  }
970
2570
 
@@ -981,16 +2581,16 @@ async function runCli(arguments_, output = defaultOutput) {
981
2581
  }
982
2582
  let exitCode = 0;
983
2583
  const program = new Command();
984
- program.name("lattice").description("Architecture analysis and dependency governance for React teams").version("0.2.0");
2584
+ program.name("lattice").description("Architecture analysis and dependency governance for React teams").version(package_default.version);
985
2585
  program.configureOutput({ writeOut: output.out, writeErr: output.err });
986
2586
  program.exitOverride();
987
2587
  program.command("analyze [path]").description("Analyze local architecture and write offline reports").option("--output <directory>", "Output directory").action(async (path, flags) => {
988
- const root = resolve7(path ?? ".");
989
- const destination = resolve7(flags.output ?? join8(root, ".nklattice-report"));
2588
+ const root = resolve11(path ?? ".");
2589
+ const destination = resolve11(flags.output ?? join12(root, ".nklattice-report"));
990
2590
  const report = await analyzeProject(root, { outputDirectory: destination });
991
2591
  output.out(`NKLattice analyzed ${report.graph.modules.length} modules. Score ${report.score.score} (${report.score.grade}); ${report.findings.length} findings.
992
2592
  `);
993
- output.out(`Reports: ${join8(destination, "report.html")} and ${join8(destination, "analysis.json")}
2593
+ output.out(`Reports: ${join12(destination, "report.html")} and ${join12(destination, "analysis.json")}
994
2594
  `);
995
2595
  });
996
2596
  program.command("doctor [path]").description("Check whether a directory contains supported source files").action(async (path) => {
@@ -1000,19 +2600,20 @@ async function runCli(arguments_, output = defaultOutput) {
1000
2600
  `);
1001
2601
  });
1002
2602
  program.command("init [path]").description("Create a project configuration file").action(async (path) => {
1003
- const destination = join8(resolve7(path ?? "."), ".nklattice.json");
1004
- await writeFile2(destination, '{\n "analysis": {},\n "policies": []\n}\n', { flag: "wx" });
2603
+ const destination = join12(resolve11(path ?? "."), ".nklattice.json");
2604
+ await writeFile4(destination, '{\n "analysis": {},\n "policies": []\n}\n', { flag: "wx" });
1005
2605
  output.out(`Created ${destination}
1006
2606
  `);
1007
2607
  });
1008
- program.command("governance").description("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) => {
2608
+ const governance = program.command("governance").description("Assess centrally approved project governance rules");
2609
+ governance.command("check [path]").option("--policy <source>", "Local file or HTTPS policy URL").option("--workspace-root <path>", "Repository root containing a shared lockfile").option("--expect-apps <count>", "Required number of managed applications").option("--json", "Machine-readable result").action(async (path, flags) => {
1009
2610
  let expectedApps;
1010
2611
  if (flags.expectApps !== void 0) {
1011
2612
  if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
1012
2613
  expectedApps = Number(flags.expectApps);
1013
2614
  if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
1014
2615
  }
1015
- const policyPath = flags.policy ?? resolve7(dirname2(fileURLToPath(import.meta.url)), "../governance/policy.json");
2616
+ const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
1016
2617
  const policy = await loadPolicy(policyPath);
1017
2618
  const result = await checkGovernance({
1018
2619
  scanRoot: path ?? ".",
@@ -1038,6 +2639,88 @@ async function runCli(arguments_, output = defaultOutput) {
1038
2639
  }
1039
2640
  if (result.findings.length > 0) exitCode = 7;
1040
2641
  });
2642
+ governance.command("assess [path]").description("Assess app inventory, dependency, architecture, security, quality, and compatibility policy").option("--policy <source>", "Local file or HTTPS policy URL").option("--inventory <path>", "Reviewed app inventory JSON").option("--workspace-root <path>", "Repository root containing a shared lockfile").option("--expect-apps <count>", "Additional expected managed application count").option("--output <directory>", "Assessment report directory").option("--evidence <file>", "Fresh governance collect JSON outside the scan root").option("--json", "Print machine-readable assessment").action(async (path, flags) => {
2643
+ let expectedApps;
2644
+ if (flags.expectApps !== void 0) {
2645
+ if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
2646
+ expectedApps = Number(flags.expectApps);
2647
+ if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
2648
+ }
2649
+ const scanRoot = resolve11(path ?? ".");
2650
+ const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
2651
+ const report = await assessGovernance({
2652
+ scanRoot,
2653
+ workspaceRoot: flags.workspaceRoot,
2654
+ inventory: flags.inventory,
2655
+ expectedApps,
2656
+ outputDirectory: flags.output,
2657
+ evidence: flags.evidence,
2658
+ policy: await loadPolicy(policyPath)
2659
+ });
2660
+ if (flags.json) output.out(`${JSON.stringify(report, null, 2)}
2661
+ `);
2662
+ else {
2663
+ output.out(`Governance ${report.status}: ${report.apps.length} app(s); policy ${report.policy.version} (${report.policy.sha256}).
2664
+ `);
2665
+ for (const issue of report.issues) output.out(`Inventory: ${issue}
2666
+ `);
2667
+ for (const app of report.apps) {
2668
+ output.out(`${app.path} (${app.profile}): ${app.status}; ${Object.entries(app.domains).map(([name, domain]) => `${name} ${domain.status}`).join("; ")}; architecture score ${app.domains.architecture.score ?? "unavailable"}.
2669
+ `);
2670
+ for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
2671
+ `);
2672
+ }
2673
+ output.out(`Reports: ${resolve11(flags.output ?? join12(scanRoot, ".nklattice-governance"))}
2674
+ `);
2675
+ }
2676
+ exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
2677
+ });
2678
+ governance.command("collect [path]").description("Run project scanners and write fresh machine-readable evidence").requiredOption("--evidence <file>", "Evidence JSON outside the scan root").option("--policy <source>", "Local file or HTTPS policy URL").option("--workspace-root <path>", "Repository root containing a shared lockfile").action(async (path, flags) => {
2679
+ const scanRoot = resolve11(path ?? ".");
2680
+ const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
2681
+ const result = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy: await loadPolicy(policyPath) });
2682
+ output.out(`Collected scanner evidence for ${result.apps.length} app(s) at ${resolve11(flags.evidence)}.
2683
+ `);
2684
+ });
2685
+ governance.command("run [path]").description("Collect and assess all governance domains in one trusted process").requiredOption("--evidence <file>", "Evidence artifact outside the scan root").option("--policy <source>", "Local file or HTTPS policy URL").option("--inventory <path>", "Reviewed app inventory JSON").option("--workspace-root <path>", "Repository root containing a shared lockfile").option("--expect-apps <count>", "Additional expected managed application count").option("--output <directory>", "Assessment report directory").option("--json", "Print machine-readable assessment").action(async (path, flags) => {
2686
+ let expectedApps;
2687
+ if (flags.expectApps !== void 0) {
2688
+ if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
2689
+ expectedApps = Number(flags.expectApps);
2690
+ if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
2691
+ }
2692
+ const scanRoot = resolve11(path ?? ".");
2693
+ const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
2694
+ const policy = await loadPolicy(policyPath);
2695
+ const collected = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy });
2696
+ const report = await assessGovernance({
2697
+ scanRoot,
2698
+ workspaceRoot: flags.workspaceRoot,
2699
+ inventory: flags.inventory,
2700
+ expectedApps,
2701
+ outputDirectory: flags.output,
2702
+ evidence: flags.evidence,
2703
+ trustedEvidence: collected,
2704
+ policy
2705
+ });
2706
+ if (flags.json) output.out(`${JSON.stringify(report, null, 2)}
2707
+ `);
2708
+ else {
2709
+ output.out(`Governance ${report.status}: ${report.apps.length} app(s); policy ${report.policy.version} (${report.policy.sha256}).
2710
+ `);
2711
+ for (const issue of report.issues) output.out(`Assessment: ${issue}
2712
+ `);
2713
+ for (const app of report.apps) {
2714
+ output.out(`${app.path} (${app.profile}): ${app.status}; ${Object.entries(app.domains).map(([name, domain]) => `${name} ${domain.status}`).join("; ")}; architecture score ${app.domains.architecture.score ?? "unavailable"}.
2715
+ `);
2716
+ for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
2717
+ `);
2718
+ }
2719
+ output.out(`Reports: ${resolve11(flags.output ?? join12(scanRoot, ".nklattice-governance"))}
2720
+ `);
2721
+ }
2722
+ exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
2723
+ });
1041
2724
  try {
1042
2725
  await program.parseAsync(arguments_, { from: "user" });
1043
2726
  } catch (error) {
@@ -1049,7 +2732,7 @@ async function runCli(arguments_, output = defaultOutput) {
1049
2732
  }
1050
2733
  return exitCode;
1051
2734
  }
1052
- if (process.argv[1] && realpathSync(resolve7(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
2735
+ if (process.argv[1] && realpathSync(resolve11(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
1053
2736
  runCli(process.argv.slice(2)).then((code) => {
1054
2737
  process.exitCode = code;
1055
2738
  });