@anhcodo13/lattice 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +72 -2
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +1059 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +304 -0
- package/governance/policy.json +23 -0
- package/package.json +36 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
5
|
+
import { realpathSync } from "fs";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { dirname as dirname2, join as join8, resolve as resolve7 } from "path";
|
|
8
|
+
import { Command, CommanderError } from "commander";
|
|
9
|
+
|
|
10
|
+
// src/analysis/analyze.ts
|
|
11
|
+
import { mkdir as mkdir2, readFile as readFile4, writeFile } from "fs/promises";
|
|
12
|
+
import { basename, join as join5, resolve as resolve3 } from "path";
|
|
13
|
+
|
|
14
|
+
// src/analysis/config.ts
|
|
15
|
+
import { readFile } from "fs/promises";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
var defaultThresholds = {
|
|
18
|
+
maxFanIn: 8,
|
|
19
|
+
maxFanOut: 10,
|
|
20
|
+
maxChainDepth: 6,
|
|
21
|
+
maxComponentLines: 200,
|
|
22
|
+
maxJsxDepth: 6,
|
|
23
|
+
maxComplexity: 15
|
|
24
|
+
};
|
|
25
|
+
var defaultWeights = {
|
|
26
|
+
architecture: 40,
|
|
27
|
+
imports: 25,
|
|
28
|
+
components: 20,
|
|
29
|
+
complexity: 15
|
|
30
|
+
};
|
|
31
|
+
function record(value) {
|
|
32
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
function patterns(value, label) {
|
|
35
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every((item) => typeof item === "string" && item.trim())) {
|
|
36
|
+
throw new Error(`${label} must be a nonempty string array`);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
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");
|
|
56
|
+
const analysis = value.analysis === void 0 ? {} : value.analysis;
|
|
57
|
+
if (!record(analysis)) throw new Error("analysis must be an object");
|
|
58
|
+
const overrides = analysis.thresholds === void 0 ? {} : analysis.thresholds;
|
|
59
|
+
if (!record(overrides)) throw new Error("analysis.thresholds must be an object");
|
|
60
|
+
const thresholds = { ...defaultThresholds };
|
|
61
|
+
for (const [name, limit] of Object.entries(overrides)) {
|
|
62
|
+
if (!Object.hasOwn(thresholds, name) || !Number.isInteger(limit) || limit < 0) {
|
|
63
|
+
throw new Error(`Invalid analysis threshold ${name}`);
|
|
64
|
+
}
|
|
65
|
+
thresholds[name] = limit;
|
|
66
|
+
}
|
|
67
|
+
const rawWeights = analysis.weights === void 0 ? defaultWeights : analysis.weights;
|
|
68
|
+
if (!record(rawWeights) || Object.keys(rawWeights).length !== Object.keys(defaultWeights).length) {
|
|
69
|
+
throw new Error("analysis.weights must define every category and total 100");
|
|
70
|
+
}
|
|
71
|
+
const weights = { ...defaultWeights };
|
|
72
|
+
for (const [name, weight] of Object.entries(rawWeights)) {
|
|
73
|
+
if (!Object.hasOwn(weights, name) || typeof weight !== "number" || !Number.isFinite(weight) || weight < 0) {
|
|
74
|
+
throw new Error(`Invalid analysis weight ${name}`);
|
|
75
|
+
}
|
|
76
|
+
weights[name] = weight;
|
|
77
|
+
}
|
|
78
|
+
if (Object.values(weights).reduce((sum, weight) => sum + weight, 0) !== 100) {
|
|
79
|
+
throw new Error("analysis.weights must total 100");
|
|
80
|
+
}
|
|
81
|
+
const rawPolicies = value.policies ?? [];
|
|
82
|
+
if (!Array.isArray(rawPolicies)) throw new Error("policies must be an array");
|
|
83
|
+
const policies = [];
|
|
84
|
+
const ids = /* @__PURE__ */ new Set();
|
|
85
|
+
for (const raw of rawPolicies) {
|
|
86
|
+
if (!record(raw) || typeof raw.id !== "string" || !/^[a-z][a-z0-9-]*$/.test(raw.id) || typeof raw.description !== "string" || !raw.description.trim()) {
|
|
87
|
+
throw new Error("Invalid policy id or description");
|
|
88
|
+
}
|
|
89
|
+
if (ids.has(raw.id)) throw new Error(`Duplicate policy id ${raw.id}`);
|
|
90
|
+
ids.add(raw.id);
|
|
91
|
+
if (raw.enabled === false) continue;
|
|
92
|
+
const from = patterns(raw.from, `${raw.id}.from`);
|
|
93
|
+
const hasAllow = raw.allow !== void 0;
|
|
94
|
+
const hasDisallow = raw.disallow !== void 0;
|
|
95
|
+
if (hasAllow === hasDisallow) throw new Error(`Policy ${raw.id} must choose exactly one of allow or disallow`);
|
|
96
|
+
if (!["info", "warning", "error"].includes(String(raw.severity))) throw new Error(`Invalid severity in ${raw.id}`);
|
|
97
|
+
if (raw.recommendation !== void 0 && typeof raw.recommendation !== "string") {
|
|
98
|
+
throw new Error(`Invalid recommendation in ${raw.id}`);
|
|
99
|
+
}
|
|
100
|
+
policies.push({
|
|
101
|
+
id: raw.id,
|
|
102
|
+
description: raw.description,
|
|
103
|
+
from,
|
|
104
|
+
...hasAllow ? { allow: patterns(raw.allow, `${raw.id}.allow`) } : { disallow: patterns(raw.disallow, `${raw.id}.disallow`) },
|
|
105
|
+
severity: raw.severity,
|
|
106
|
+
recommendation: raw.recommendation
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return { thresholds, weights, policies };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/analysis/graph.ts
|
|
113
|
+
function buildGraph(_modules) {
|
|
114
|
+
const modules = [..._modules].sort((a, b) => a.path.localeCompare(b.path));
|
|
115
|
+
const edges = modules.flatMap((module) => module.imports.filter((imported) => imported.resolved).map((imported) => ({
|
|
116
|
+
from: module.path,
|
|
117
|
+
to: imported.resolved,
|
|
118
|
+
line: imported.line,
|
|
119
|
+
specifier: imported.specifier,
|
|
120
|
+
importedNames: imported.importedNames,
|
|
121
|
+
isTypeOnly: imported.isTypeOnly
|
|
122
|
+
})));
|
|
123
|
+
return { modules, edges };
|
|
124
|
+
}
|
|
125
|
+
function findCycles(graph) {
|
|
126
|
+
const adjacent = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly).map((edge) => edge.to)]));
|
|
127
|
+
const index = /* @__PURE__ */ new Map();
|
|
128
|
+
const low = /* @__PURE__ */ new Map();
|
|
129
|
+
const stack = [];
|
|
130
|
+
const inStack = /* @__PURE__ */ new Set();
|
|
131
|
+
const cycles = [];
|
|
132
|
+
let counter = 0;
|
|
133
|
+
function visit(path) {
|
|
134
|
+
index.set(path, counter);
|
|
135
|
+
low.set(path, counter++);
|
|
136
|
+
stack.push(path);
|
|
137
|
+
inStack.add(path);
|
|
138
|
+
for (const target of adjacent.get(path) ?? []) {
|
|
139
|
+
if (!index.has(target)) {
|
|
140
|
+
visit(target);
|
|
141
|
+
low.set(path, Math.min(low.get(path), low.get(target)));
|
|
142
|
+
} else if (inStack.has(target)) {
|
|
143
|
+
low.set(path, Math.min(low.get(path), index.get(target)));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (low.get(path) !== index.get(path)) return;
|
|
147
|
+
const component = [];
|
|
148
|
+
while (stack.length) {
|
|
149
|
+
const current = stack.pop();
|
|
150
|
+
inStack.delete(current);
|
|
151
|
+
component.push(current);
|
|
152
|
+
if (current === path) break;
|
|
153
|
+
}
|
|
154
|
+
if (component.length > 1 || (adjacent.get(path) ?? []).includes(path)) cycles.push(component.sort());
|
|
155
|
+
}
|
|
156
|
+
for (const module of graph.modules) if (!index.has(module.path)) visit(module.path);
|
|
157
|
+
return cycles.sort((a, b) => a[0].localeCompare(b[0]));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/analysis/history.ts
|
|
161
|
+
import { randomUUID } from "crypto";
|
|
162
|
+
import { access, mkdir, rename } from "fs/promises";
|
|
163
|
+
import { join as join2 } from "path";
|
|
164
|
+
async function exists(path) {
|
|
165
|
+
try {
|
|
166
|
+
await access(path);
|
|
167
|
+
return true;
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function archivePrevious(outputDirectory) {
|
|
173
|
+
const json = join2(outputDirectory, "analysis.json");
|
|
174
|
+
const html = join2(outputDirectory, "report.html");
|
|
175
|
+
if (!await exists(json) && !await exists(html)) return;
|
|
176
|
+
const name = `${(/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
|
177
|
+
const destination = join2(outputDirectory, "history", name);
|
|
178
|
+
await mkdir(destination, { recursive: true });
|
|
179
|
+
if (await exists(json)) await rename(json, join2(destination, "analysis.json"));
|
|
180
|
+
if (await exists(html)) await rename(html, join2(destination, "report.html"));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// src/analysis/recommend.ts
|
|
184
|
+
function recommendActions(findings) {
|
|
185
|
+
const points = { error: 5, warning: 2, info: 1 };
|
|
186
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
187
|
+
for (const finding of findings) grouped.set(finding.file, [...grouped.get(finding.file) ?? [], finding]);
|
|
188
|
+
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
|
+
const hotspots = ranked.slice(0, 8).map(({ file, items, priority }) => ({
|
|
190
|
+
file,
|
|
191
|
+
findingCount: items.length,
|
|
192
|
+
estimatedRecovery: `Estimate: up to ${Math.min(15, priority)} points if these findings are resolved`
|
|
193
|
+
}));
|
|
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 }));
|
|
195
|
+
const roadmap = ranked.slice(0, 8).map(({ file, items, priority }) => ({
|
|
196
|
+
file,
|
|
197
|
+
priority,
|
|
198
|
+
summary: `Review ${items.map((item) => item.ruleId).join(", ")} in ${file}`
|
|
199
|
+
}));
|
|
200
|
+
return { hotspots, quickWins, roadmap };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/analysis/report.ts
|
|
204
|
+
function escapeHtml(value) {
|
|
205
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
206
|
+
}
|
|
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>
|
|
232
|
+
(() => {
|
|
233
|
+
const report = JSON.parse(document.getElementById('report-data').textContent);
|
|
234
|
+
const byId = id => document.getElementById(id);
|
|
235
|
+
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);}
|
|
254
|
+
};
|
|
255
|
+
for(const id of ['findings-filter','category-filter','findings-search']) byId(id).addEventListener('input',renderFindings);
|
|
256
|
+
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);}
|
|
267
|
+
})();
|
|
268
|
+
</script></body></html>`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/analysis/rules.ts
|
|
272
|
+
import { matchesGlob } from "path";
|
|
273
|
+
function evaluateRules(graph, config) {
|
|
274
|
+
const findings = [];
|
|
275
|
+
const outgoing = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly)]));
|
|
276
|
+
const incoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path && !edge.isTypeOnly)]));
|
|
277
|
+
const allIncoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path)]));
|
|
278
|
+
for (const cycle of findCycles(graph)) {
|
|
279
|
+
findings.push({
|
|
280
|
+
ruleId: "module-cycle",
|
|
281
|
+
category: "architecture",
|
|
282
|
+
severity: "error",
|
|
283
|
+
file: cycle[0],
|
|
284
|
+
line: 1,
|
|
285
|
+
evidence: `Circular imports connect ${cycle.join(" \u2192 ")} \u2192 ${cycle[0]}.`,
|
|
286
|
+
recommendation: "Move shared contracts into a lower-level module and remove one import direction."
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
for (const module of graph.modules) {
|
|
290
|
+
const out = outgoing.get(module.path) ?? [];
|
|
291
|
+
const into = incoming.get(module.path) ?? [];
|
|
292
|
+
const seen = /* @__PURE__ */ new Set();
|
|
293
|
+
for (const imported of module.imports) {
|
|
294
|
+
if (imported.isTypeOnly) continue;
|
|
295
|
+
if (seen.has(imported.specifier)) {
|
|
296
|
+
findings.push({
|
|
297
|
+
ruleId: "duplicate-import",
|
|
298
|
+
category: "imports",
|
|
299
|
+
severity: "warning",
|
|
300
|
+
file: module.path,
|
|
301
|
+
line: imported.line,
|
|
302
|
+
evidence: `Repeated import from ${imported.specifier}.`,
|
|
303
|
+
recommendation: "Combine imports from the same module."
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
seen.add(imported.specifier);
|
|
307
|
+
}
|
|
308
|
+
if (out.length > config.thresholds.maxFanOut) {
|
|
309
|
+
findings.push({
|
|
310
|
+
ruleId: "high-fan-out",
|
|
311
|
+
category: "architecture",
|
|
312
|
+
severity: "warning",
|
|
313
|
+
file: module.path,
|
|
314
|
+
line: 1,
|
|
315
|
+
evidence: `${out.length} outgoing module dependencies exceed ${config.thresholds.maxFanOut}.`,
|
|
316
|
+
recommendation: "Separate unrelated responsibilities and narrow direct dependencies."
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (into.length > config.thresholds.maxFanIn) {
|
|
320
|
+
findings.push({
|
|
321
|
+
ruleId: "high-fan-in",
|
|
322
|
+
category: "architecture",
|
|
323
|
+
severity: "warning",
|
|
324
|
+
file: module.path,
|
|
325
|
+
line: 1,
|
|
326
|
+
evidence: `${into.length} incoming module dependencies exceed ${config.thresholds.maxFanIn}.`,
|
|
327
|
+
recommendation: "Stabilize this shared interface before making breaking changes."
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (into.length === 0 && out.length === 0 && graph.modules.length > 1 && !module.isRoute) {
|
|
331
|
+
findings.push({
|
|
332
|
+
ruleId: "orphan-module",
|
|
333
|
+
category: "architecture",
|
|
334
|
+
severity: "info",
|
|
335
|
+
file: module.path,
|
|
336
|
+
line: 1,
|
|
337
|
+
evidence: "No local module imports to or from this file were resolved.",
|
|
338
|
+
recommendation: "Confirm whether the file is an entry point or unused code."
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
if (into.length > 0 && !module.isRoute) {
|
|
342
|
+
const usedNames = new Set((allIncoming.get(module.path) ?? []).flatMap((edge) => edge.importedNames));
|
|
343
|
+
if (!usedNames.has("*")) {
|
|
344
|
+
for (const name of module.exports) {
|
|
345
|
+
if (name === "*" || module.typeExports.includes(name)) continue;
|
|
346
|
+
if (!usedNames.has(name)) {
|
|
347
|
+
findings.push({
|
|
348
|
+
ruleId: "unused-export",
|
|
349
|
+
category: "imports",
|
|
350
|
+
severity: "info",
|
|
351
|
+
file: module.path,
|
|
352
|
+
line: 1,
|
|
353
|
+
evidence: `Export ${name} has no resolved local importer.`,
|
|
354
|
+
recommendation: "Remove the export or document its external entry-point usage."
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (module.componentCount > 0 && module.lines > config.thresholds.maxComponentLines) {
|
|
361
|
+
findings.push({
|
|
362
|
+
ruleId: "large-component",
|
|
363
|
+
category: "components",
|
|
364
|
+
severity: "warning",
|
|
365
|
+
file: module.path,
|
|
366
|
+
line: 1,
|
|
367
|
+
evidence: `A component file has ${module.lines} lines; limit is ${config.thresholds.maxComponentLines}.`,
|
|
368
|
+
recommendation: "Extract cohesive child components or hooks."
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
if (module.jsxDepth > config.thresholds.maxJsxDepth) {
|
|
372
|
+
findings.push({
|
|
373
|
+
ruleId: "deep-jsx",
|
|
374
|
+
category: "components",
|
|
375
|
+
severity: "warning",
|
|
376
|
+
file: module.path,
|
|
377
|
+
line: 1,
|
|
378
|
+
evidence: `JSX nesting depth ${module.jsxDepth} exceeds ${config.thresholds.maxJsxDepth}.`,
|
|
379
|
+
recommendation: "Split deeply nested JSX into named components."
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
if (module.complexity > config.thresholds.maxComplexity) {
|
|
383
|
+
findings.push({
|
|
384
|
+
ruleId: "high-complexity",
|
|
385
|
+
category: "complexity",
|
|
386
|
+
severity: "warning",
|
|
387
|
+
file: module.path,
|
|
388
|
+
line: 1,
|
|
389
|
+
evidence: `Approximate branch complexity ${module.complexity} exceeds ${config.thresholds.maxComplexity}.`,
|
|
390
|
+
recommendation: "Simplify branches and extract focused functions."
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
for (const policy of config.policies) {
|
|
394
|
+
if (!policy.from.some((pattern) => matchesGlob(module.path, pattern))) continue;
|
|
395
|
+
for (const edge of out) {
|
|
396
|
+
const allowed = policy.allow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
397
|
+
const disallowed = policy.disallow?.some((pattern) => matchesGlob(edge.to, pattern));
|
|
398
|
+
if (policy.allow && !allowed || policy.disallow && disallowed) {
|
|
399
|
+
findings.push({
|
|
400
|
+
ruleId: policy.id,
|
|
401
|
+
category: "architecture",
|
|
402
|
+
severity: policy.severity,
|
|
403
|
+
file: module.path,
|
|
404
|
+
line: edge.line,
|
|
405
|
+
evidence: `${module.path} imports ${edge.to}, violating ${policy.description}.`,
|
|
406
|
+
recommendation: policy.recommendation ?? "Refactor the import to respect this module boundary."
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const maxDepth = config.thresholds.maxChainDepth;
|
|
413
|
+
for (const module of graph.modules) {
|
|
414
|
+
if (maxDepth <= 0) continue;
|
|
415
|
+
const visit = (path, visited, depth) => {
|
|
416
|
+
if (depth > maxDepth) return true;
|
|
417
|
+
for (const edge of outgoing.get(path) ?? []) {
|
|
418
|
+
if (visited.has(edge.to)) continue;
|
|
419
|
+
const next = new Set(visited);
|
|
420
|
+
next.add(edge.to);
|
|
421
|
+
if (visit(edge.to, next, depth + 1)) return true;
|
|
422
|
+
}
|
|
423
|
+
return false;
|
|
424
|
+
};
|
|
425
|
+
if (visit(module.path, /* @__PURE__ */ new Set([module.path]), 0)) {
|
|
426
|
+
findings.push({
|
|
427
|
+
ruleId: "deep-dependency-chain",
|
|
428
|
+
category: "architecture",
|
|
429
|
+
severity: "warning",
|
|
430
|
+
file: module.path,
|
|
431
|
+
line: 1,
|
|
432
|
+
evidence: `A dependency chain exceeds ${maxDepth} local hops.`,
|
|
433
|
+
recommendation: "Introduce a stable boundary or flatten unnecessary layers."
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// src/analysis/scan.ts
|
|
441
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
442
|
+
import { join as join4, resolve as resolve2 } from "path";
|
|
443
|
+
import fastGlob from "fast-glob";
|
|
444
|
+
|
|
445
|
+
// src/analysis/parse.ts
|
|
446
|
+
import ts from "typescript";
|
|
447
|
+
function hasModifier(node, kind) {
|
|
448
|
+
return ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) === true;
|
|
449
|
+
}
|
|
450
|
+
function containsJsx(node) {
|
|
451
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true;
|
|
452
|
+
return ts.forEachChild(node, containsJsx) === true;
|
|
453
|
+
}
|
|
454
|
+
function importNames(clause) {
|
|
455
|
+
if (!clause) return [];
|
|
456
|
+
const names = [];
|
|
457
|
+
if (clause.name) names.push("default");
|
|
458
|
+
if (clause.namedBindings) {
|
|
459
|
+
if (ts.isNamespaceImport(clause.namedBindings)) names.push("*");
|
|
460
|
+
else for (const item of clause.namedBindings.elements) names.push(item.propertyName?.text ?? item.name.text);
|
|
461
|
+
}
|
|
462
|
+
return names;
|
|
463
|
+
}
|
|
464
|
+
function sourceKind(path) {
|
|
465
|
+
if (path.endsWith(".tsx")) return ts.ScriptKind.TSX;
|
|
466
|
+
if (path.endsWith(".jsx")) return ts.ScriptKind.JSX;
|
|
467
|
+
if (path.endsWith(".js") || path.endsWith(".mjs") || path.endsWith(".cjs")) return ts.ScriptKind.JS;
|
|
468
|
+
return ts.ScriptKind.TS;
|
|
469
|
+
}
|
|
470
|
+
function parseModule(path, contents) {
|
|
471
|
+
const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
|
|
472
|
+
const imports = [];
|
|
473
|
+
const exports = [];
|
|
474
|
+
const typeExports = [];
|
|
475
|
+
let jsxDepth = 0;
|
|
476
|
+
let complexity = 1;
|
|
477
|
+
let componentCount = 0;
|
|
478
|
+
for (const node of source.statements) {
|
|
479
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
480
|
+
imports.push({
|
|
481
|
+
specifier: node.moduleSpecifier.text,
|
|
482
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
483
|
+
importedNames: importNames(node.importClause),
|
|
484
|
+
isTypeOnly: node.importClause?.isTypeOnly ?? false
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
if (ts.isExportDeclaration(node)) {
|
|
488
|
+
if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
489
|
+
imports.push({
|
|
490
|
+
specifier: node.moduleSpecifier.text,
|
|
491
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
492
|
+
importedNames: node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements.map((item) => item.propertyName?.text ?? item.name.text) : ["*"],
|
|
493
|
+
isTypeOnly: node.isTypeOnly
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
497
|
+
exports.push(...node.exportClause.elements.map((item) => item.name.text));
|
|
498
|
+
if (node.isTypeOnly) typeExports.push(...node.exportClause.elements.map((item) => item.name.text));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) {
|
|
502
|
+
if (hasModifier(node, ts.SyntaxKind.DefaultKeyword)) exports.push("default");
|
|
503
|
+
else if (ts.isVariableStatement(node)) {
|
|
504
|
+
for (const declaration of node.declarationList.declarations) {
|
|
505
|
+
if (ts.isIdentifier(declaration.name)) exports.push(declaration.name.text);
|
|
506
|
+
}
|
|
507
|
+
} else if ("name" in node && node.name && ts.isIdentifier(node.name)) {
|
|
508
|
+
const name = node.name.text;
|
|
509
|
+
exports.push(name);
|
|
510
|
+
if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) typeExports.push(name);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (ts.isFunctionDeclaration(node) && node.name && /^[A-Z]/.test(node.name.text) && containsJsx(node)) {
|
|
514
|
+
componentCount++;
|
|
515
|
+
}
|
|
516
|
+
if (ts.isVariableStatement(node)) {
|
|
517
|
+
for (const declaration of node.declarationList.declarations) {
|
|
518
|
+
if (ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text) && declaration.initializer && containsJsx(declaration.initializer)) {
|
|
519
|
+
componentCount++;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function visit(node, depth) {
|
|
525
|
+
const isJsx = ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
|
|
526
|
+
const nextDepth = depth + (isJsx ? 1 : 0);
|
|
527
|
+
jsxDepth = Math.max(jsxDepth, nextDepth);
|
|
528
|
+
if (ts.isIfStatement(node) || ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isCatchClause(node) || ts.isConditionalExpression(node) || ts.isCaseBlock(node)) complexity++;
|
|
529
|
+
if (ts.isBinaryExpression(node) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind)) {
|
|
530
|
+
complexity++;
|
|
531
|
+
}
|
|
532
|
+
ts.forEachChild(node, (child) => visit(child, nextDepth));
|
|
533
|
+
}
|
|
534
|
+
visit(source, 0);
|
|
535
|
+
return {
|
|
536
|
+
path,
|
|
537
|
+
imports,
|
|
538
|
+
exports: [...new Set(exports)].sort(),
|
|
539
|
+
typeExports: [...new Set(typeExports)].sort(),
|
|
540
|
+
lines: contents.split(/\r?\n/).length,
|
|
541
|
+
jsxDepth,
|
|
542
|
+
componentCount,
|
|
543
|
+
complexity,
|
|
544
|
+
isRoute: /(^|\/)(app|pages)\/.+(page|layout|route|index)\.[cm]?[jt]sx?$/.test(path) || /(^|\/)app\/(page|layout|route)\.[cm]?[jt]sx?$/.test(path)
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/analysis/resolve.ts
|
|
549
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
550
|
+
import { join as join3, relative, resolve, sep } from "path";
|
|
551
|
+
import ts2 from "typescript";
|
|
552
|
+
async function compilerOptions(root) {
|
|
553
|
+
const path = join3(root, "tsconfig.json");
|
|
554
|
+
let content;
|
|
555
|
+
try {
|
|
556
|
+
content = await readFile2(path, "utf8");
|
|
557
|
+
} catch {
|
|
558
|
+
return {};
|
|
559
|
+
}
|
|
560
|
+
const json = ts2.parseConfigFileTextToJson(path, content);
|
|
561
|
+
if (json.error) return {};
|
|
562
|
+
const parsed = ts2.parseJsonConfigFileContent(json.config, ts2.sys, root);
|
|
563
|
+
return parsed.options;
|
|
564
|
+
}
|
|
565
|
+
async function resolveImports(root, modules) {
|
|
566
|
+
const options = await compilerOptions(root);
|
|
567
|
+
const files = new Set(modules.map((module) => module.path));
|
|
568
|
+
for (const module of modules) {
|
|
569
|
+
const from = join3(root, module.path);
|
|
570
|
+
for (const imported of module.imports) {
|
|
571
|
+
const result = ts2.resolveModuleName(imported.specifier, from, options, ts2.sys).resolvedModule;
|
|
572
|
+
if (!result) continue;
|
|
573
|
+
const path = relative(root, resolve(result.resolvedFileName));
|
|
574
|
+
if (path === ".." || path.startsWith(`..${sep}`)) continue;
|
|
575
|
+
const normalized = path.replaceAll("\\", "/");
|
|
576
|
+
if (files.has(normalized)) imported.resolved = normalized;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// src/analysis/scan.ts
|
|
582
|
+
async function scanProject(projectRoot) {
|
|
583
|
+
const root = resolve2(projectRoot);
|
|
584
|
+
const files = await fastGlob("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
|
|
585
|
+
cwd: root,
|
|
586
|
+
onlyFiles: true,
|
|
587
|
+
unique: true,
|
|
588
|
+
followSymbolicLinks: false,
|
|
589
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/.next/**", "**/dist/**", "**/coverage/**", "**/*.d.ts"]
|
|
590
|
+
});
|
|
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;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// src/analysis/score.ts
|
|
600
|
+
function round(value) {
|
|
601
|
+
return Math.round(value * 100) / 100;
|
|
602
|
+
}
|
|
603
|
+
function grade(score) {
|
|
604
|
+
if (score >= 95) return "A+";
|
|
605
|
+
if (score >= 90) return "A";
|
|
606
|
+
if (score >= 80) return "B";
|
|
607
|
+
if (score >= 70) return "C";
|
|
608
|
+
if (score >= 55) return "D";
|
|
609
|
+
return "F";
|
|
610
|
+
}
|
|
611
|
+
function scoreFindings(findings, config) {
|
|
612
|
+
const penalties = { error: 18, warning: 7, info: 2 };
|
|
613
|
+
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));
|
|
615
|
+
const occurrences = /* @__PURE__ */ new Map();
|
|
616
|
+
let deduction = 0;
|
|
617
|
+
for (const finding of categoryFindings) {
|
|
618
|
+
const occurrence = (occurrences.get(finding.ruleId) ?? 0) + 1;
|
|
619
|
+
occurrences.set(finding.ruleId, occurrence);
|
|
620
|
+
deduction += penalties[finding.severity] / Math.sqrt(occurrence);
|
|
621
|
+
}
|
|
622
|
+
deduction = Math.min(100, deduction);
|
|
623
|
+
return { id, weight, score: round(100 - deduction), deduction: round(deduction), findingCount: categoryFindings.length };
|
|
624
|
+
});
|
|
625
|
+
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));
|
|
628
|
+
const score = round(Math.max(0, Math.min(100, 100 - weightedDeduction - maintenanceAdjustment - criticalAdjustment)));
|
|
629
|
+
return { score, grade: grade(score), weightedDeduction, maintenanceAdjustment, criticalAdjustment, categories };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// src/analysis/analyze.ts
|
|
633
|
+
async function analyzeProject(projectRoot, options = {}) {
|
|
634
|
+
const root = resolve3(projectRoot);
|
|
635
|
+
const config = await loadAnalysisConfig(root);
|
|
636
|
+
const modules = await scanProject(root);
|
|
637
|
+
if (modules.length === 0) throw new Error("No supported source files found");
|
|
638
|
+
const graph = buildGraph(modules);
|
|
639
|
+
const findings = evaluateRules(graph, config);
|
|
640
|
+
let name = basename(root);
|
|
641
|
+
try {
|
|
642
|
+
const manifest = JSON.parse(await readFile4(join5(root, "package.json"), "utf8"));
|
|
643
|
+
if (manifest && typeof manifest === "object" && "name" in manifest && typeof manifest.name === "string") name = manifest.name;
|
|
644
|
+
} catch {
|
|
645
|
+
}
|
|
646
|
+
const report = {
|
|
647
|
+
schema: "urn:nklattice:analysis:1",
|
|
648
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
649
|
+
project: { name },
|
|
650
|
+
graph,
|
|
651
|
+
findings,
|
|
652
|
+
score: scoreFindings(findings, config),
|
|
653
|
+
actions: recommendActions(findings)
|
|
654
|
+
};
|
|
655
|
+
const outputDirectory = resolve3(options.outputDirectory ?? join5(root, ".nklattice-report"));
|
|
656
|
+
await mkdir2(outputDirectory, { recursive: true });
|
|
657
|
+
await archivePrevious(outputDirectory);
|
|
658
|
+
await writeFile(join5(outputDirectory, "analysis.json"), `${JSON.stringify(report, null, 2)}
|
|
659
|
+
`);
|
|
660
|
+
await writeFile(join5(outputDirectory, "report.html"), renderHtml(report));
|
|
661
|
+
return report;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/governance/check.ts
|
|
665
|
+
import { resolve as resolve5, relative as relative4, sep as sep3 } from "path";
|
|
666
|
+
import { satisfies, subset, valid, validRange } from "semver";
|
|
667
|
+
|
|
668
|
+
// src/governance/discover.ts
|
|
669
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
670
|
+
import { join as join6, relative as relative2 } from "path";
|
|
671
|
+
import fastGlob2 from "fast-glob";
|
|
672
|
+
var profiles = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
|
|
673
|
+
function isRecord(value) {
|
|
674
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
675
|
+
}
|
|
676
|
+
function dependencyMap(manifest) {
|
|
677
|
+
const result = {};
|
|
678
|
+
for (const section2 of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
679
|
+
const entries = manifest[section2];
|
|
680
|
+
if (entries === void 0) continue;
|
|
681
|
+
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) {
|
|
685
|
+
throw new Error(`Conflicting declarations for ${name}`);
|
|
686
|
+
}
|
|
687
|
+
result[name] = version;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return result;
|
|
691
|
+
}
|
|
692
|
+
function profileFor(manifest, dependencies) {
|
|
693
|
+
const config = manifest.nklattice;
|
|
694
|
+
if (config !== void 0) {
|
|
695
|
+
if (!isRecord(config) || typeof config.profile !== "string" || !profiles.has(config.profile)) {
|
|
696
|
+
throw new Error("Invalid nklattice.profile in package.json");
|
|
697
|
+
}
|
|
698
|
+
return config.profile;
|
|
699
|
+
}
|
|
700
|
+
if (dependencies.next !== void 0) return "next";
|
|
701
|
+
if (dependencies.vite !== void 0 && dependencies.react !== void 0) return "vite-react";
|
|
702
|
+
if (dependencies.react !== void 0) return "react";
|
|
703
|
+
return void 0;
|
|
704
|
+
}
|
|
705
|
+
async function discoverApps(scanRoot, workspaceRoot) {
|
|
706
|
+
const manifests = await fastGlob2(["package.json", "**/package.json"], {
|
|
707
|
+
cwd: scanRoot,
|
|
708
|
+
onlyFiles: true,
|
|
709
|
+
unique: true,
|
|
710
|
+
followSymbolicLinks: false,
|
|
711
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/.next/**", "**/coverage/**"]
|
|
712
|
+
});
|
|
713
|
+
const apps = [];
|
|
714
|
+
for (const path of manifests.sort()) {
|
|
715
|
+
const directory = join6(scanRoot, path, "..");
|
|
716
|
+
let parsed;
|
|
717
|
+
try {
|
|
718
|
+
parsed = JSON.parse(await readFile5(join6(scanRoot, path), "utf8"));
|
|
719
|
+
} catch {
|
|
720
|
+
throw new Error(`Invalid package.json: ${path}`);
|
|
721
|
+
}
|
|
722
|
+
if (!isRecord(parsed)) throw new Error(`Invalid package.json: ${path}`);
|
|
723
|
+
const dependencies = dependencyMap(parsed);
|
|
724
|
+
const profile = profileFor(parsed, dependencies);
|
|
725
|
+
if (!profile) continue;
|
|
726
|
+
apps.push({
|
|
727
|
+
directory,
|
|
728
|
+
relativePath: relative2(workspaceRoot, directory).replaceAll("\\", "/") || ".",
|
|
729
|
+
profile,
|
|
730
|
+
dependencies
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
return apps;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/governance/lockfiles.ts
|
|
737
|
+
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";
|
|
739
|
+
import YAML from "yaml";
|
|
740
|
+
function record2(value) {
|
|
741
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
742
|
+
}
|
|
743
|
+
async function exists2(path) {
|
|
744
|
+
try {
|
|
745
|
+
await access2(path);
|
|
746
|
+
return true;
|
|
747
|
+
} catch {
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
function within(parent, child) {
|
|
752
|
+
const rel = relative3(parent, child);
|
|
753
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep2}`);
|
|
754
|
+
}
|
|
755
|
+
async function findLockfile(appDirectory, workspaceRoot) {
|
|
756
|
+
let directory = resolve4(appDirectory);
|
|
757
|
+
const boundary = resolve4(workspaceRoot);
|
|
758
|
+
if (!within(boundary, directory)) throw new Error("Application is outside workspace root");
|
|
759
|
+
while (within(boundary, directory)) {
|
|
760
|
+
const npm = join7(directory, "package-lock.json");
|
|
761
|
+
const pnpm = join7(directory, "pnpm-lock.yaml");
|
|
762
|
+
const [hasNpm, hasPnpm] = await Promise.all([exists2(npm), exists2(pnpm)]);
|
|
763
|
+
if (hasNpm && hasPnpm) throw new Error(`Ambiguous lockfiles in ${directory}`);
|
|
764
|
+
if (hasNpm) return { path: npm, kind: "npm", root: directory };
|
|
765
|
+
if (hasPnpm) return { path: pnpm, kind: "pnpm", root: directory };
|
|
766
|
+
if (directory === boundary) break;
|
|
767
|
+
directory = dirname(directory);
|
|
768
|
+
}
|
|
769
|
+
throw new Error(`No lockfile found for ${appDirectory}`);
|
|
770
|
+
}
|
|
771
|
+
function section(importer, name) {
|
|
772
|
+
for (const key of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
773
|
+
const entries = importer[key];
|
|
774
|
+
if (record2(entries) && Object.hasOwn(entries, name)) return entries[name];
|
|
775
|
+
}
|
|
776
|
+
return void 0;
|
|
777
|
+
}
|
|
778
|
+
function readNpm(raw, root, app, name) {
|
|
779
|
+
let lock;
|
|
780
|
+
try {
|
|
781
|
+
lock = JSON.parse(raw);
|
|
782
|
+
} catch {
|
|
783
|
+
throw new Error("Malformed npm lockfile JSON");
|
|
784
|
+
}
|
|
785
|
+
if (!record2(lock) || lock.lockfileVersion !== 3 || !record2(lock.packages)) {
|
|
786
|
+
throw new Error("Unsupported or malformed npm lockfile");
|
|
787
|
+
}
|
|
788
|
+
const importerKey = relative3(root, app.directory).replaceAll("\\", "/");
|
|
789
|
+
const importer = lock.packages[importerKey];
|
|
790
|
+
if (!record2(importer)) throw new Error(`Missing npm lockfile importer ${importerKey || "."}`);
|
|
791
|
+
const specifier = section(importer, name);
|
|
792
|
+
if (specifier !== void 0 && typeof specifier !== "string") {
|
|
793
|
+
throw new Error(`Malformed npm lockfile specifier for ${name}`);
|
|
794
|
+
}
|
|
795
|
+
let resolved;
|
|
796
|
+
const segments = importerKey ? importerKey.split("/") : [];
|
|
797
|
+
while (true) {
|
|
798
|
+
const prefix = segments.length ? `${segments.join("/")}/` : "";
|
|
799
|
+
resolved = lock.packages[`${prefix}node_modules/${name}`];
|
|
800
|
+
if (resolved !== void 0 || segments.length === 0) break;
|
|
801
|
+
segments.pop();
|
|
802
|
+
}
|
|
803
|
+
if (specifier !== void 0 && !record2(resolved)) {
|
|
804
|
+
throw new Error(`Missing npm lockfile package resolution for ${name}`);
|
|
805
|
+
}
|
|
806
|
+
if (record2(resolved) && typeof resolved.version !== "string") {
|
|
807
|
+
throw new Error(`Malformed npm lockfile package version for ${name}`);
|
|
808
|
+
}
|
|
809
|
+
return { specifier, version: record2(resolved) ? resolved.version : void 0 };
|
|
810
|
+
}
|
|
811
|
+
function readPnpm(raw, root, app, name) {
|
|
812
|
+
let lock;
|
|
813
|
+
try {
|
|
814
|
+
lock = YAML.parse(raw);
|
|
815
|
+
} catch {
|
|
816
|
+
throw new Error("Malformed pnpm lockfile YAML");
|
|
817
|
+
}
|
|
818
|
+
if (!record2(lock) || String(lock.lockfileVersion) !== "9.0" || !record2(lock.importers) || !record2(lock.packages) || !record2(lock.snapshots)) {
|
|
819
|
+
throw new Error("Unsupported or malformed pnpm lockfile");
|
|
820
|
+
}
|
|
821
|
+
const importerKey = relative3(root, app.directory).replaceAll("\\", "/") || ".";
|
|
822
|
+
const importer = lock.importers[importerKey];
|
|
823
|
+
if (!record2(importer)) throw new Error(`Missing pnpm lockfile importer ${importerKey}`);
|
|
824
|
+
const entry = section(importer, name);
|
|
825
|
+
if (entry === void 0) return {};
|
|
826
|
+
if (!record2(entry) || typeof entry.specifier !== "string" || typeof entry.version !== "string") {
|
|
827
|
+
throw new Error(`Malformed pnpm lockfile dependency for ${name}`);
|
|
828
|
+
}
|
|
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}`];
|
|
832
|
+
if (!packageKeys.some((key) => record2(lock.packages) && Object.hasOwn(lock.packages, key))) {
|
|
833
|
+
throw new Error(`Missing pnpm package entry for ${name}@${version}`);
|
|
834
|
+
}
|
|
835
|
+
const snapshotKeys = [`${name}@${entry.version}`, `/${name}@${entry.version}`, ...packageKeys];
|
|
836
|
+
if (!snapshotKeys.some((key) => Object.hasOwn(lock.snapshots, key))) {
|
|
837
|
+
throw new Error(`Missing pnpm snapshot for ${name}@${entry.version}`);
|
|
838
|
+
}
|
|
839
|
+
return { specifier: entry.specifier, version };
|
|
840
|
+
}
|
|
841
|
+
async function readResolution(app, workspaceRoot, name) {
|
|
842
|
+
const lockfile = await findLockfile(app.directory, workspaceRoot);
|
|
843
|
+
const raw = await readFile6(lockfile.path, "utf8");
|
|
844
|
+
return lockfile.kind === "npm" ? readNpm(raw, lockfile.root, app, name) : readPnpm(raw, lockfile.root, app, name);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// src/governance/check.ts
|
|
848
|
+
async function checkGovernance(options) {
|
|
849
|
+
const scanRoot = resolve5(options.scanRoot);
|
|
850
|
+
const workspaceRoot = resolve5(options.workspaceRoot ?? scanRoot);
|
|
851
|
+
const path = relative4(workspaceRoot, scanRoot);
|
|
852
|
+
if (path === ".." || path.startsWith(`..${sep3}`)) {
|
|
853
|
+
throw new Error("Scan root must be inside workspace root");
|
|
854
|
+
}
|
|
855
|
+
const apps = await discoverApps(scanRoot, workspaceRoot);
|
|
856
|
+
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
|
+
}
|
|
891
|
+
return {
|
|
892
|
+
appsChecked: apps.length,
|
|
893
|
+
findings,
|
|
894
|
+
policyVersion: options.policy.policyVersion,
|
|
895
|
+
policySource: options.policy.source,
|
|
896
|
+
policySha256: options.policy.sha256
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// src/governance/policy.ts
|
|
901
|
+
import { createHash } from "crypto";
|
|
902
|
+
import { readFile as readFile7 } from "fs/promises";
|
|
903
|
+
import { resolve as resolve6 } from "path";
|
|
904
|
+
import { valid as valid2 } from "semver";
|
|
905
|
+
var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
906
|
+
var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
907
|
+
function record3(value) {
|
|
908
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
909
|
+
}
|
|
910
|
+
function parsePolicy(raw, source) {
|
|
911
|
+
let parsed;
|
|
912
|
+
try {
|
|
913
|
+
parsed = JSON.parse(raw);
|
|
914
|
+
} catch {
|
|
915
|
+
throw new Error(`Policy at ${source} is not valid JSON`);
|
|
916
|
+
}
|
|
917
|
+
if (!record3(parsed) || parsed.schemaVersion !== 2) {
|
|
918
|
+
throw new Error("Policy schemaVersion must be 2");
|
|
919
|
+
}
|
|
920
|
+
if (typeof parsed.policyVersion !== "string" || !exactVersion.test(parsed.policyVersion) || valid2(parsed.policyVersion) === null) {
|
|
921
|
+
throw new Error("Policy policyVersion must be an exact version");
|
|
922
|
+
}
|
|
923
|
+
if (!record3(parsed.profiles) || Object.keys(parsed.profiles).length === 0) {
|
|
924
|
+
throw new Error("Policy profiles must be a nonempty object");
|
|
925
|
+
}
|
|
926
|
+
const profiles2 = {};
|
|
927
|
+
for (const [name, rules] of Object.entries(parsed.profiles)) {
|
|
928
|
+
if (!record3(rules) || Object.keys(rules).length === 0) {
|
|
929
|
+
throw new Error(`Policy profile ${name} must list packages`);
|
|
930
|
+
}
|
|
931
|
+
const versions = {};
|
|
932
|
+
for (const [packageName, version] of Object.entries(rules)) {
|
|
933
|
+
if (typeof version !== "string" || !minimumVersion.test(version) || valid2(version.slice(2)) === null) {
|
|
934
|
+
throw new Error(`Policy minimum version (>=X.Y.Z) required for ${packageName} in ${name}`);
|
|
935
|
+
}
|
|
936
|
+
versions[packageName] = version;
|
|
937
|
+
}
|
|
938
|
+
profiles2[name] = versions;
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
schemaVersion: 2,
|
|
942
|
+
policyVersion: parsed.policyVersion,
|
|
943
|
+
profiles: profiles2,
|
|
944
|
+
source,
|
|
945
|
+
sha256: createHash("sha256").update(raw).digest("hex")
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
async function loadPolicy(source) {
|
|
949
|
+
if (/^https?:\/\//i.test(source)) {
|
|
950
|
+
if (!source.startsWith("https://")) {
|
|
951
|
+
throw new Error("Remote policy requires HTTPS");
|
|
952
|
+
}
|
|
953
|
+
const response = await fetch(source);
|
|
954
|
+
if (response.url && !response.url.startsWith("https://")) {
|
|
955
|
+
throw new Error("Remote policy redirects must remain on HTTPS");
|
|
956
|
+
}
|
|
957
|
+
if (!response.ok) {
|
|
958
|
+
throw new Error(`Policy fetch failed with HTTP ${response.status}`);
|
|
959
|
+
}
|
|
960
|
+
return parsePolicy(await response.text(), source);
|
|
961
|
+
}
|
|
962
|
+
return parsePolicy(await readFile7(resolve6(source), "utf8"), source);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// src/runtime.ts
|
|
966
|
+
function isSupportedNodeVersion(version) {
|
|
967
|
+
const major = Number(version.split(".", 1)[0]);
|
|
968
|
+
return Number.isInteger(major) && major >= 24;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// src/cli.ts
|
|
972
|
+
var defaultOutput = {
|
|
973
|
+
out: (text) => process.stdout.write(text),
|
|
974
|
+
err: (text) => process.stderr.write(text)
|
|
975
|
+
};
|
|
976
|
+
async function runCli(arguments_, output = defaultOutput) {
|
|
977
|
+
if (!isSupportedNodeVersion(process.versions.node)) {
|
|
978
|
+
output.err(`NKLattice error: Node.js 24 or newer is required; found ${process.version}.
|
|
979
|
+
`);
|
|
980
|
+
return 3;
|
|
981
|
+
}
|
|
982
|
+
let exitCode = 0;
|
|
983
|
+
const program = new Command();
|
|
984
|
+
program.name("lattice").description("Architecture analysis and dependency governance for React teams").version("0.2.0");
|
|
985
|
+
program.configureOutput({ writeOut: output.out, writeErr: output.err });
|
|
986
|
+
program.exitOverride();
|
|
987
|
+
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"));
|
|
990
|
+
const report = await analyzeProject(root, { outputDirectory: destination });
|
|
991
|
+
output.out(`NKLattice analyzed ${report.graph.modules.length} modules. Score ${report.score.score} (${report.score.grade}); ${report.findings.length} findings.
|
|
992
|
+
`);
|
|
993
|
+
output.out(`Reports: ${join8(destination, "report.html")} and ${join8(destination, "analysis.json")}
|
|
994
|
+
`);
|
|
995
|
+
});
|
|
996
|
+
program.command("doctor [path]").description("Check whether a directory contains supported source files").action(async (path) => {
|
|
997
|
+
const modules = await scanProject(path ?? ".");
|
|
998
|
+
if (modules.length === 0) throw new Error("No supported source files found");
|
|
999
|
+
output.out(`NKLattice doctor: Node ${process.version}; ${modules.length} supported source file${modules.length === 1 ? "" : "s"} found.
|
|
1000
|
+
`);
|
|
1001
|
+
});
|
|
1002
|
+
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" });
|
|
1005
|
+
output.out(`Created ${destination}
|
|
1006
|
+
`);
|
|
1007
|
+
});
|
|
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) => {
|
|
1009
|
+
let expectedApps;
|
|
1010
|
+
if (flags.expectApps !== void 0) {
|
|
1011
|
+
if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
|
|
1012
|
+
expectedApps = Number(flags.expectApps);
|
|
1013
|
+
if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
|
|
1014
|
+
}
|
|
1015
|
+
const policyPath = flags.policy ?? resolve7(dirname2(fileURLToPath(import.meta.url)), "../governance/policy.json");
|
|
1016
|
+
const policy = await loadPolicy(policyPath);
|
|
1017
|
+
const result = await checkGovernance({
|
|
1018
|
+
scanRoot: path ?? ".",
|
|
1019
|
+
workspaceRoot: flags.workspaceRoot,
|
|
1020
|
+
policy
|
|
1021
|
+
});
|
|
1022
|
+
if (expectedApps !== void 0 && result.appsChecked !== expectedApps) {
|
|
1023
|
+
throw new Error(`Expected ${expectedApps} managed applications, found ${result.appsChecked}`);
|
|
1024
|
+
}
|
|
1025
|
+
if (flags.json) {
|
|
1026
|
+
output.out(`${JSON.stringify(result, null, 2)}
|
|
1027
|
+
`);
|
|
1028
|
+
} else if (result.findings.length === 0) {
|
|
1029
|
+
output.out(`${result.appsChecked} application${result.appsChecked === 1 ? "" : "s"} checked; governance passed. Policy ${result.policyVersion} (${result.policySource}).
|
|
1030
|
+
`);
|
|
1031
|
+
} else {
|
|
1032
|
+
output.out(`${result.appsChecked} application${result.appsChecked === 1 ? "" : "s"} checked; ${result.findings.length} dependency mismatch${result.findings.length === 1 ? "" : "es"}.
|
|
1033
|
+
`);
|
|
1034
|
+
for (const finding of result.findings) {
|
|
1035
|
+
output.out(`${finding.app}: ${finding.packageName} required ${finding.expected}; declared ${finding.declared ?? "missing"}; lockfile specifier ${finding.lockfileSpecifier ?? "missing"}; locked ${finding.locked ?? "missing"} (${finding.reason}).
|
|
1036
|
+
`);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
if (result.findings.length > 0) exitCode = 7;
|
|
1040
|
+
});
|
|
1041
|
+
try {
|
|
1042
|
+
await program.parseAsync(arguments_, { from: "user" });
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
if (error instanceof CommanderError && ["commander.version", "commander.helpDisplayed"].includes(error.code)) return 0;
|
|
1045
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1046
|
+
output.err(`NKLattice error: ${message}
|
|
1047
|
+
`);
|
|
1048
|
+
return 3;
|
|
1049
|
+
}
|
|
1050
|
+
return exitCode;
|
|
1051
|
+
}
|
|
1052
|
+
if (process.argv[1] && realpathSync(resolve7(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
1053
|
+
runCli(process.argv.slice(2)).then((code) => {
|
|
1054
|
+
process.exitCode = code;
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
export {
|
|
1058
|
+
runCli
|
|
1059
|
+
};
|