@anhcodo13/lattice 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,13 +1,151 @@
1
1
  // src/governance/policy.ts
2
2
  import { createHash } from "crypto";
3
- import { readFile } from "fs/promises";
3
+ import { readFile as readFile2 } from "fs/promises";
4
4
  import { resolve } from "path";
5
5
  import { valid } from "semver";
6
+
7
+ // src/analysis/config.ts
8
+ import { readFile } from "fs/promises";
9
+ import { join } from "path";
10
+ var defaultThresholds = {
11
+ maxFanIn: 8,
12
+ maxFanOut: 10,
13
+ maxChainDepth: 6,
14
+ maxComponentLines: 200,
15
+ maxJsxDepth: 6,
16
+ maxComplexity: 15
17
+ };
18
+ var defaultWeights = {
19
+ architecture: 40,
20
+ imports: 25,
21
+ components: 20,
22
+ complexity: 15
23
+ };
24
+ function record(value) {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+ function patterns(value, label) {
28
+ if (!Array.isArray(value) || value.length === 0 || !value.every((item) => typeof item === "string" && item.trim())) {
29
+ throw new Error(`${label} must be a nonempty string array`);
30
+ }
31
+ return value;
32
+ }
33
+ function parseAnalysisConfig(value) {
34
+ if (!record(value)) throw new Error("Analysis configuration must be an object");
35
+ const analysis = value.analysis === void 0 ? {} : value.analysis;
36
+ if (!record(analysis)) throw new Error("analysis must be an object");
37
+ const overrides = analysis.thresholds === void 0 ? {} : analysis.thresholds;
38
+ if (!record(overrides)) throw new Error("analysis.thresholds must be an object");
39
+ const thresholds = { ...defaultThresholds };
40
+ for (const [name, limit] of Object.entries(overrides)) {
41
+ if (!Object.hasOwn(thresholds, name) || !Number.isInteger(limit) || limit < 0) {
42
+ throw new Error(`Invalid analysis threshold ${name}`);
43
+ }
44
+ thresholds[name] = limit;
45
+ }
46
+ const rawWeights = analysis.weights === void 0 ? defaultWeights : analysis.weights;
47
+ if (!record(rawWeights) || Object.keys(rawWeights).length !== Object.keys(defaultWeights).length) {
48
+ throw new Error("analysis.weights must define every category and total 100");
49
+ }
50
+ const weights = { ...defaultWeights };
51
+ for (const [name, weight] of Object.entries(rawWeights)) {
52
+ if (!Object.hasOwn(weights, name) || typeof weight !== "number" || !Number.isFinite(weight) || weight < 0) {
53
+ throw new Error(`Invalid analysis weight ${name}`);
54
+ }
55
+ weights[name] = weight;
56
+ }
57
+ if (Object.values(weights).reduce((sum, weight) => sum + weight, 0) !== 100) {
58
+ throw new Error("analysis.weights must total 100");
59
+ }
60
+ const rawPolicies = value.policies ?? [];
61
+ if (!Array.isArray(rawPolicies)) throw new Error("policies must be an array");
62
+ const policies = [];
63
+ const ids = /* @__PURE__ */ new Set();
64
+ for (const raw of rawPolicies) {
65
+ if (!record(raw) || typeof raw.id !== "string" || !/^[a-z][a-z0-9-]*$/.test(raw.id) || typeof raw.description !== "string" || !raw.description.trim()) {
66
+ throw new Error("Invalid policy id or description");
67
+ }
68
+ if (ids.has(raw.id)) throw new Error(`Duplicate policy id ${raw.id}`);
69
+ ids.add(raw.id);
70
+ if (raw.enabled === false) continue;
71
+ if (raw.enabled !== void 0 && raw.enabled !== true) throw new Error(`Invalid enabled flag in ${raw.id}`);
72
+ const from = patterns(raw.from, `${raw.id}.from`);
73
+ const hasAllow = raw.allow !== void 0;
74
+ const hasDisallow = raw.disallow !== void 0;
75
+ if (hasAllow === hasDisallow) throw new Error(`Policy ${raw.id} must choose exactly one of allow or disallow`);
76
+ if (!["info", "warning", "error"].includes(String(raw.severity))) throw new Error(`Invalid severity in ${raw.id}`);
77
+ if (raw.recommendation !== void 0 && typeof raw.recommendation !== "string") {
78
+ throw new Error(`Invalid recommendation in ${raw.id}`);
79
+ }
80
+ policies.push({
81
+ id: raw.id,
82
+ description: raw.description,
83
+ from,
84
+ ...hasAllow ? { allow: patterns(raw.allow, `${raw.id}.allow`) } : { disallow: patterns(raw.disallow, `${raw.id}.disallow`) },
85
+ severity: raw.severity,
86
+ recommendation: raw.recommendation
87
+ });
88
+ }
89
+ const rawAliases = analysis.aliases ?? {};
90
+ if (!record(rawAliases)) throw new Error("analysis.aliases must be an object");
91
+ const aliases = {};
92
+ for (const [prefix, target] of Object.entries(rawAliases)) {
93
+ 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 === "..")) {
94
+ throw new Error(`Invalid analysis alias ${prefix}`);
95
+ }
96
+ aliases[prefix] = target;
97
+ }
98
+ return { thresholds, weights, policies, aliases };
99
+ }
100
+
101
+ // src/governance/policy.ts
6
102
  var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
7
103
  var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
8
- function record(value) {
104
+ function record2(value) {
9
105
  return typeof value === "object" && value !== null && !Array.isArray(value);
10
106
  }
107
+ var builtInRuleIds = /* @__PURE__ */ new Set([
108
+ "module-cycle",
109
+ "duplicate-import",
110
+ "high-fan-out",
111
+ "high-fan-in",
112
+ "orphan-module",
113
+ "unused-export",
114
+ "large-component",
115
+ "deep-jsx",
116
+ "high-complexity",
117
+ "deep-dependency-chain"
118
+ ]);
119
+ function parseAssessmentPolicy(value) {
120
+ if (!record2(value) || !record2(value.architecture)) throw new Error("assessment.architecture is required");
121
+ const architecture = value.architecture;
122
+ 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");
123
+ if (!Number.isSafeInteger(architecture.maximumErrors) || architecture.maximumErrors < 0) {
124
+ throw new Error("assessment.architecture.maximumErrors must be a nonnegative integer");
125
+ }
126
+ if (!Array.isArray(architecture.blockedRuleIds) || !architecture.blockedRuleIds.every((item) => typeof item === "string") || new Set(architecture.blockedRuleIds).size !== architecture.blockedRuleIds.length) {
127
+ throw new Error("assessment.architecture.blockedRuleIds must be a unique string array");
128
+ }
129
+ const rawAnalysis = architecture.analysis ?? {};
130
+ if (!record2(rawAnalysis)) throw new Error("assessment.architecture.analysis must be an object");
131
+ if (Array.isArray(rawAnalysis.policies) && rawAnalysis.policies.some((item) => record2(item) && item.enabled === false)) {
132
+ throw new Error("Central architecture policies cannot be disabled");
133
+ }
134
+ const analysis = parseAnalysisConfig({ analysis: rawAnalysis, policies: rawAnalysis.policies });
135
+ for (const policy of analysis.policies) {
136
+ if (builtInRuleIds.has(policy.id)) throw new Error(`Architecture boundary ID ${policy.id} conflicts with a built-in rule`);
137
+ }
138
+ const known = /* @__PURE__ */ new Set([...builtInRuleIds, ...analysis.policies.map((item) => item.id)]);
139
+ for (const id of architecture.blockedRuleIds) {
140
+ if (!known.has(id)) throw new Error(`Unknown blocked rule ID ${id}`);
141
+ }
142
+ return { architecture: {
143
+ minimumScore: architecture.minimumScore,
144
+ maximumErrors: architecture.maximumErrors,
145
+ blockedRuleIds: architecture.blockedRuleIds,
146
+ analysis
147
+ } };
148
+ }
11
149
  function parsePolicy(raw, source) {
12
150
  let parsed;
13
151
  try {
@@ -15,18 +153,18 @@ function parsePolicy(raw, source) {
15
153
  } catch {
16
154
  throw new Error(`Policy at ${source} is not valid JSON`);
17
155
  }
18
- if (!record(parsed) || parsed.schemaVersion !== 2) {
156
+ if (!record2(parsed) || parsed.schemaVersion !== 2) {
19
157
  throw new Error("Policy schemaVersion must be 2");
20
158
  }
21
159
  if (typeof parsed.policyVersion !== "string" || !exactVersion.test(parsed.policyVersion) || valid(parsed.policyVersion) === null) {
22
160
  throw new Error("Policy policyVersion must be an exact version");
23
161
  }
24
- if (!record(parsed.profiles) || Object.keys(parsed.profiles).length === 0) {
162
+ if (!record2(parsed.profiles) || Object.keys(parsed.profiles).length === 0) {
25
163
  throw new Error("Policy profiles must be a nonempty object");
26
164
  }
27
- const profiles2 = {};
165
+ const profiles3 = {};
28
166
  for (const [name, rules] of Object.entries(parsed.profiles)) {
29
- if (!record(rules) || Object.keys(rules).length === 0) {
167
+ if (!record2(rules) || Object.keys(rules).length === 0) {
30
168
  throw new Error(`Policy profile ${name} must list packages`);
31
169
  }
32
170
  const versions = {};
@@ -36,12 +174,13 @@ function parsePolicy(raw, source) {
36
174
  }
37
175
  versions[packageName] = version;
38
176
  }
39
- profiles2[name] = versions;
177
+ profiles3[name] = versions;
40
178
  }
41
179
  return {
42
180
  schemaVersion: 2,
43
181
  policyVersion: parsed.policyVersion,
44
- profiles: profiles2,
182
+ profiles: profiles3,
183
+ ...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
45
184
  source,
46
185
  sha256: createHash("sha256").update(raw).digest("hex")
47
186
  };
@@ -60,7 +199,7 @@ async function loadPolicy(source) {
60
199
  }
61
200
  return parsePolicy(await response.text(), source);
62
201
  }
63
- return parsePolicy(await readFile(resolve(source), "utf8"), source);
202
+ return parsePolicy(await readFile2(resolve(source), "utf8"), source);
64
203
  }
65
204
 
66
205
  // src/governance/check.ts
@@ -68,8 +207,8 @@ import { resolve as resolve3, relative as relative3, sep as sep2 } from "path";
68
207
  import { satisfies, subset, valid as valid2, validRange } from "semver";
69
208
 
70
209
  // src/governance/discover.ts
71
- import { readFile as readFile2 } from "fs/promises";
72
- import { join, relative } from "path";
210
+ import { readFile as readFile3 } from "fs/promises";
211
+ import { join as join2, relative } from "path";
73
212
  import fastGlob from "fast-glob";
74
213
  var profiles = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
75
214
  function isRecord(value) {
@@ -109,15 +248,16 @@ async function discoverApps(scanRoot, workspaceRoot) {
109
248
  cwd: scanRoot,
110
249
  onlyFiles: true,
111
250
  unique: true,
251
+ dot: true,
112
252
  followSymbolicLinks: false,
113
- ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/.next/**", "**/coverage/**"]
253
+ ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/out/**", "**/.next/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**"]
114
254
  });
115
255
  const apps = [];
116
256
  for (const path of manifests.sort()) {
117
- const directory = join(scanRoot, path, "..");
257
+ const directory = join2(scanRoot, path, "..");
118
258
  let parsed;
119
259
  try {
120
- parsed = JSON.parse(await readFile2(join(scanRoot, path), "utf8"));
260
+ parsed = JSON.parse(await readFile3(join2(scanRoot, path), "utf8"));
121
261
  } catch {
122
262
  throw new Error(`Invalid package.json: ${path}`);
123
263
  }
@@ -136,10 +276,10 @@ async function discoverApps(scanRoot, workspaceRoot) {
136
276
  }
137
277
 
138
278
  // src/governance/lockfiles.ts
139
- import { access, readFile as readFile3 } from "fs/promises";
140
- import { dirname, join as join2, relative as relative2, resolve as resolve2, sep } from "path";
279
+ import { access, readFile as readFile4 } from "fs/promises";
280
+ import { dirname, join as join3, relative as relative2, resolve as resolve2, sep } from "path";
141
281
  import YAML from "yaml";
142
- function record2(value) {
282
+ function record3(value) {
143
283
  return typeof value === "object" && value !== null && !Array.isArray(value);
144
284
  }
145
285
  async function exists(path) {
@@ -159,8 +299,8 @@ async function findLockfile(appDirectory, workspaceRoot) {
159
299
  const boundary = resolve2(workspaceRoot);
160
300
  if (!within(boundary, directory)) throw new Error("Application is outside workspace root");
161
301
  while (within(boundary, directory)) {
162
- const npm = join2(directory, "package-lock.json");
163
- const pnpm = join2(directory, "pnpm-lock.yaml");
302
+ const npm = join3(directory, "package-lock.json");
303
+ const pnpm = join3(directory, "pnpm-lock.yaml");
164
304
  const [hasNpm, hasPnpm] = await Promise.all([exists(npm), exists(pnpm)]);
165
305
  if (hasNpm && hasPnpm) throw new Error(`Ambiguous lockfiles in ${directory}`);
166
306
  if (hasNpm) return { path: npm, kind: "npm", root: directory };
@@ -173,7 +313,7 @@ async function findLockfile(appDirectory, workspaceRoot) {
173
313
  function section(importer, name) {
174
314
  for (const key of ["dependencies", "devDependencies", "optionalDependencies"]) {
175
315
  const entries = importer[key];
176
- if (record2(entries) && Object.hasOwn(entries, name)) return entries[name];
316
+ if (record3(entries) && Object.hasOwn(entries, name)) return entries[name];
177
317
  }
178
318
  return void 0;
179
319
  }
@@ -184,12 +324,12 @@ function readNpm(raw, root, app, name) {
184
324
  } catch {
185
325
  throw new Error("Malformed npm lockfile JSON");
186
326
  }
187
- if (!record2(lock) || lock.lockfileVersion !== 3 || !record2(lock.packages)) {
327
+ if (!record3(lock) || lock.lockfileVersion !== 3 || !record3(lock.packages)) {
188
328
  throw new Error("Unsupported or malformed npm lockfile");
189
329
  }
190
330
  const importerKey = relative2(root, app.directory).replaceAll("\\", "/");
191
331
  const importer = lock.packages[importerKey];
192
- if (!record2(importer)) throw new Error(`Missing npm lockfile importer ${importerKey || "."}`);
332
+ if (!record3(importer)) throw new Error(`Missing npm lockfile importer ${importerKey || "."}`);
193
333
  const specifier = section(importer, name);
194
334
  if (specifier !== void 0 && typeof specifier !== "string") {
195
335
  throw new Error(`Malformed npm lockfile specifier for ${name}`);
@@ -202,13 +342,13 @@ function readNpm(raw, root, app, name) {
202
342
  if (resolved !== void 0 || segments.length === 0) break;
203
343
  segments.pop();
204
344
  }
205
- if (specifier !== void 0 && !record2(resolved)) {
345
+ if (specifier !== void 0 && !record3(resolved)) {
206
346
  throw new Error(`Missing npm lockfile package resolution for ${name}`);
207
347
  }
208
- if (record2(resolved) && typeof resolved.version !== "string") {
348
+ if (record3(resolved) && typeof resolved.version !== "string") {
209
349
  throw new Error(`Malformed npm lockfile package version for ${name}`);
210
350
  }
211
- return { specifier, version: record2(resolved) ? resolved.version : void 0 };
351
+ return { specifier, version: record3(resolved) ? resolved.version : void 0 };
212
352
  }
213
353
  function readPnpm(raw, root, app, name) {
214
354
  let lock;
@@ -217,21 +357,21 @@ function readPnpm(raw, root, app, name) {
217
357
  } catch {
218
358
  throw new Error("Malformed pnpm lockfile YAML");
219
359
  }
220
- if (!record2(lock) || String(lock.lockfileVersion) !== "9.0" || !record2(lock.importers) || !record2(lock.packages) || !record2(lock.snapshots)) {
360
+ if (!record3(lock) || String(lock.lockfileVersion) !== "9.0" || !record3(lock.importers) || !record3(lock.packages) || !record3(lock.snapshots)) {
221
361
  throw new Error("Unsupported or malformed pnpm lockfile");
222
362
  }
223
363
  const importerKey = relative2(root, app.directory).replaceAll("\\", "/") || ".";
224
364
  const importer = lock.importers[importerKey];
225
- if (!record2(importer)) throw new Error(`Missing pnpm lockfile importer ${importerKey}`);
365
+ if (!record3(importer)) throw new Error(`Missing pnpm lockfile importer ${importerKey}`);
226
366
  const entry = section(importer, name);
227
367
  if (entry === void 0) return {};
228
- if (!record2(entry) || typeof entry.specifier !== "string" || typeof entry.version !== "string") {
368
+ if (!record3(entry) || typeof entry.specifier !== "string" || typeof entry.version !== "string") {
229
369
  throw new Error(`Malformed pnpm lockfile dependency for ${name}`);
230
370
  }
231
371
  const version = entry.version.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1];
232
372
  if (!version) throw new Error(`Unsupported pnpm resolution for ${name}`);
233
373
  const packageKeys = [`${name}@${version}`, `/${name}@${version}`];
234
- if (!packageKeys.some((key) => record2(lock.packages) && Object.hasOwn(lock.packages, key))) {
374
+ if (!packageKeys.some((key) => record3(lock.packages) && Object.hasOwn(lock.packages, key))) {
235
375
  throw new Error(`Missing pnpm package entry for ${name}@${version}`);
236
376
  }
237
377
  const snapshotKeys = [`${name}@${entry.version}`, `/${name}@${entry.version}`, ...packageKeys];
@@ -242,11 +382,44 @@ function readPnpm(raw, root, app, name) {
242
382
  }
243
383
  async function readResolution(app, workspaceRoot, name) {
244
384
  const lockfile = await findLockfile(app.directory, workspaceRoot);
245
- const raw = await readFile3(lockfile.path, "utf8");
385
+ const raw = await readFile4(lockfile.path, "utf8");
246
386
  return lockfile.kind === "npm" ? readNpm(raw, lockfile.root, app, name) : readPnpm(raw, lockfile.root, app, name);
247
387
  }
248
388
 
249
389
  // src/governance/check.ts
390
+ async function checkApp(app, workspaceRoot, policy) {
391
+ const requirements = policy.profiles[app.profile];
392
+ if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
393
+ const findings = [];
394
+ for (const [packageName, expected] of Object.entries(requirements)) {
395
+ const declared = app.dependencies[packageName];
396
+ const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
397
+ const reasons = [];
398
+ const declaredRange = declared === void 0 ? null : validRange(declared);
399
+ if (declared === void 0) reasons.push("required manifest declaration missing");
400
+ else if (declaredRange === null) reasons.push("unsupported manifest range");
401
+ else if (!subset(declaredRange, expected)) reasons.push("manifest range allows versions below minimum");
402
+ if (resolution.specifier !== declared) reasons.push("lockfile specifier differs");
403
+ if (resolution.version === void 0) reasons.push("lockfile resolution missing");
404
+ else if (!valid2(resolution.version)) reasons.push("invalid lockfile resolution");
405
+ else {
406
+ if (!satisfies(resolution.version, expected)) reasons.push("lockfile resolution does not satisfy minimum");
407
+ if (declaredRange !== null && !satisfies(resolution.version, declaredRange)) {
408
+ reasons.push("lockfile resolution outside manifest range");
409
+ }
410
+ }
411
+ if (reasons.length) findings.push({
412
+ app: app.relativePath,
413
+ packageName,
414
+ expected,
415
+ declared,
416
+ lockfileSpecifier: resolution.specifier,
417
+ locked: resolution.version,
418
+ reason: reasons.join("; ")
419
+ });
420
+ }
421
+ return findings;
422
+ }
250
423
  async function checkGovernance(options) {
251
424
  const scanRoot = resolve3(options.scanRoot);
252
425
  const workspaceRoot = resolve3(options.workspaceRoot ?? scanRoot);
@@ -256,49 +429,778 @@ async function checkGovernance(options) {
256
429
  }
257
430
  const apps = await discoverApps(scanRoot, workspaceRoot);
258
431
  if (apps.length === 0) throw new Error("No managed applications found");
432
+ const findings = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
433
+ return {
434
+ appsChecked: apps.length,
435
+ findings,
436
+ policyVersion: options.policy.policyVersion,
437
+ policySource: options.policy.source,
438
+ policySha256: options.policy.sha256
439
+ };
440
+ }
441
+
442
+ // src/governance/assess.ts
443
+ import { mkdir, writeFile } from "fs/promises";
444
+ import { join as join7, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
445
+
446
+ // src/analysis/graph.ts
447
+ function buildGraph(_modules) {
448
+ const modules = [..._modules].sort((a, b) => a.path.localeCompare(b.path));
449
+ const edges = modules.flatMap((module) => module.imports.filter((imported) => imported.resolved).map((imported) => ({
450
+ from: module.path,
451
+ to: imported.resolved,
452
+ line: imported.line,
453
+ specifier: imported.specifier,
454
+ importedNames: imported.importedNames,
455
+ isTypeOnly: imported.isTypeOnly
456
+ })));
457
+ return { modules, edges };
458
+ }
459
+ function findCycles(graph) {
460
+ const adjacent = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly).map((edge) => edge.to)]));
461
+ const index = /* @__PURE__ */ new Map();
462
+ const low = /* @__PURE__ */ new Map();
463
+ const stack = [];
464
+ const inStack = /* @__PURE__ */ new Set();
465
+ const cycles = [];
466
+ let counter = 0;
467
+ function visit(path) {
468
+ index.set(path, counter);
469
+ low.set(path, counter++);
470
+ stack.push(path);
471
+ inStack.add(path);
472
+ for (const target of adjacent.get(path) ?? []) {
473
+ if (!index.has(target)) {
474
+ visit(target);
475
+ low.set(path, Math.min(low.get(path), low.get(target)));
476
+ } else if (inStack.has(target)) {
477
+ low.set(path, Math.min(low.get(path), index.get(target)));
478
+ }
479
+ }
480
+ if (low.get(path) !== index.get(path)) return;
481
+ const component = [];
482
+ while (stack.length) {
483
+ const current = stack.pop();
484
+ inStack.delete(current);
485
+ component.push(current);
486
+ if (current === path) break;
487
+ }
488
+ if (component.length > 1 || (adjacent.get(path) ?? []).includes(path)) cycles.push(component.sort());
489
+ }
490
+ for (const module of graph.modules) if (!index.has(module.path)) visit(module.path);
491
+ return cycles.sort((a, b) => a[0].localeCompare(b[0]));
492
+ }
493
+
494
+ // src/analysis/rules.ts
495
+ import { matchesGlob } from "path";
496
+ function evaluateRules(graph, config) {
259
497
  const findings = [];
260
- for (const app of apps) {
261
- const requirements = options.policy.profiles[app.profile];
262
- if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
263
- for (const [packageName, expected] of Object.entries(requirements)) {
264
- const declared = app.dependencies[packageName];
265
- const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
266
- const reasons = [];
267
- const declaredRange = declared === void 0 ? null : validRange(declared);
268
- if (declared === void 0) reasons.push("required manifest declaration missing");
269
- else if (declaredRange === null) reasons.push("unsupported manifest range");
270
- else if (!subset(declaredRange, expected)) reasons.push("manifest range allows versions below minimum");
271
- if (resolution.specifier !== declared) reasons.push("lockfile specifier differs");
272
- if (resolution.version === void 0) reasons.push("lockfile resolution missing");
273
- else if (!valid2(resolution.version)) reasons.push("invalid lockfile resolution");
274
- else {
275
- if (!satisfies(resolution.version, expected)) reasons.push("lockfile resolution does not satisfy minimum");
276
- if (declaredRange !== null && !satisfies(resolution.version, declaredRange)) {
277
- reasons.push("lockfile resolution outside manifest range");
498
+ const outgoing = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly)]));
499
+ const incoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path && !edge.isTypeOnly)]));
500
+ const allIncoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path)]));
501
+ for (const cycle of findCycles(graph)) {
502
+ findings.push({
503
+ ruleId: "module-cycle",
504
+ category: "architecture",
505
+ severity: "error",
506
+ file: cycle[0],
507
+ line: 1,
508
+ evidence: `Circular imports connect ${cycle.join(" \u2192 ")} \u2192 ${cycle[0]}.`,
509
+ recommendation: "Move shared contracts into a lower-level module and remove one import direction."
510
+ });
511
+ }
512
+ for (const module of graph.modules) {
513
+ const out = outgoing.get(module.path) ?? [];
514
+ const into = incoming.get(module.path) ?? [];
515
+ const seen = /* @__PURE__ */ new Set();
516
+ for (const imported of module.imports) {
517
+ if (imported.isTypeOnly) continue;
518
+ if (seen.has(imported.specifier)) {
519
+ findings.push({
520
+ ruleId: "duplicate-import",
521
+ category: "imports",
522
+ severity: "warning",
523
+ file: module.path,
524
+ line: imported.line,
525
+ evidence: `Repeated import from ${imported.specifier}.`,
526
+ recommendation: "Combine imports from the same module."
527
+ });
528
+ }
529
+ seen.add(imported.specifier);
530
+ }
531
+ if (out.length > config.thresholds.maxFanOut) {
532
+ findings.push({
533
+ ruleId: "high-fan-out",
534
+ category: "architecture",
535
+ severity: "warning",
536
+ file: module.path,
537
+ line: 1,
538
+ evidence: `${out.length} outgoing module dependencies exceed ${config.thresholds.maxFanOut}.`,
539
+ recommendation: "Separate unrelated responsibilities and narrow direct dependencies."
540
+ });
541
+ }
542
+ if (into.length > config.thresholds.maxFanIn) {
543
+ findings.push({
544
+ ruleId: "high-fan-in",
545
+ category: "architecture",
546
+ severity: "warning",
547
+ file: module.path,
548
+ line: 1,
549
+ evidence: `${into.length} incoming module dependencies exceed ${config.thresholds.maxFanIn}.`,
550
+ recommendation: "Stabilize this shared interface before making breaking changes."
551
+ });
552
+ }
553
+ if (into.length === 0 && out.length === 0 && graph.modules.length > 1 && !module.isRoute) {
554
+ findings.push({
555
+ ruleId: "orphan-module",
556
+ category: "architecture",
557
+ severity: "info",
558
+ file: module.path,
559
+ line: 1,
560
+ evidence: "No local module imports to or from this file were resolved.",
561
+ recommendation: "Confirm whether the file is an entry point or unused code."
562
+ });
563
+ }
564
+ if (into.length > 0 && !module.isRoute) {
565
+ const usedNames = new Set((allIncoming.get(module.path) ?? []).flatMap((edge) => edge.importedNames));
566
+ if (!usedNames.has("*")) {
567
+ for (const name of module.exports) {
568
+ if (name === "*" || module.typeExports.includes(name)) continue;
569
+ if (!usedNames.has(name)) {
570
+ findings.push({
571
+ ruleId: "unused-export",
572
+ category: "imports",
573
+ severity: "info",
574
+ file: module.path,
575
+ line: 1,
576
+ evidence: `Export ${name} has no resolved local importer.`,
577
+ recommendation: "Remove the export or document its external entry-point usage."
578
+ });
579
+ }
278
580
  }
279
581
  }
280
- if (reasons.length) {
281
- findings.push({
282
- app: app.relativePath,
283
- packageName,
284
- expected,
285
- declared,
286
- lockfileSpecifier: resolution.specifier,
287
- locked: resolution.version,
288
- reason: reasons.join("; ")
582
+ }
583
+ if (module.componentCount > 0 && module.lines > config.thresholds.maxComponentLines) {
584
+ findings.push({
585
+ ruleId: "large-component",
586
+ category: "components",
587
+ severity: "warning",
588
+ file: module.path,
589
+ line: 1,
590
+ evidence: `A component file has ${module.lines} lines; limit is ${config.thresholds.maxComponentLines}.`,
591
+ recommendation: "Extract cohesive child components or hooks."
592
+ });
593
+ }
594
+ if (module.jsxDepth > config.thresholds.maxJsxDepth) {
595
+ findings.push({
596
+ ruleId: "deep-jsx",
597
+ category: "components",
598
+ severity: "warning",
599
+ file: module.path,
600
+ line: 1,
601
+ evidence: `JSX nesting depth ${module.jsxDepth} exceeds ${config.thresholds.maxJsxDepth}.`,
602
+ recommendation: "Split deeply nested JSX into named components."
603
+ });
604
+ }
605
+ if (module.complexity > config.thresholds.maxComplexity) {
606
+ findings.push({
607
+ ruleId: "high-complexity",
608
+ category: "complexity",
609
+ severity: "warning",
610
+ file: module.path,
611
+ line: 1,
612
+ evidence: `Approximate branch complexity ${module.complexity} exceeds ${config.thresholds.maxComplexity}.`,
613
+ recommendation: "Simplify branches and extract focused functions."
614
+ });
615
+ }
616
+ for (const policy of config.policies) {
617
+ if (!policy.from.some((pattern) => matchesGlob(module.path, pattern))) continue;
618
+ for (const edge of out) {
619
+ const allowed = policy.allow?.some((pattern) => matchesGlob(edge.to, pattern));
620
+ const disallowed = policy.disallow?.some((pattern) => matchesGlob(edge.to, pattern));
621
+ if (policy.allow && !allowed || policy.disallow && disallowed) {
622
+ findings.push({
623
+ ruleId: policy.id,
624
+ category: "architecture",
625
+ severity: policy.severity,
626
+ file: module.path,
627
+ line: edge.line,
628
+ evidence: `${module.path} imports ${edge.to}, violating ${policy.description}.`,
629
+ recommendation: policy.recommendation ?? "Refactor the import to respect this module boundary."
630
+ });
631
+ }
632
+ }
633
+ }
634
+ }
635
+ const maxDepth = config.thresholds.maxChainDepth;
636
+ for (const module of graph.modules) {
637
+ if (maxDepth <= 0) continue;
638
+ const visit = (path, visited, depth) => {
639
+ if (depth > maxDepth) return true;
640
+ for (const edge of outgoing.get(path) ?? []) {
641
+ if (visited.has(edge.to)) continue;
642
+ const next = new Set(visited);
643
+ next.add(edge.to);
644
+ if (visit(edge.to, next, depth + 1)) return true;
645
+ }
646
+ return false;
647
+ };
648
+ if (visit(module.path, /* @__PURE__ */ new Set([module.path]), 0)) {
649
+ findings.push({
650
+ ruleId: "deep-dependency-chain",
651
+ category: "architecture",
652
+ severity: "warning",
653
+ file: module.path,
654
+ line: 1,
655
+ evidence: `A dependency chain exceeds ${maxDepth} local hops.`,
656
+ recommendation: "Introduce a stable boundary or flatten unnecessary layers."
657
+ });
658
+ }
659
+ }
660
+ return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
661
+ }
662
+
663
+ // src/analysis/scan.ts
664
+ import { readFile as readFile6 } from "fs/promises";
665
+ import { join as join5, resolve as resolve5 } from "path";
666
+ import fastGlob2 from "fast-glob";
667
+
668
+ // src/analysis/parse.ts
669
+ import ts from "typescript";
670
+ function hasModifier(node, kind) {
671
+ return ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) === true;
672
+ }
673
+ function containsJsx(node) {
674
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true;
675
+ return ts.forEachChild(node, containsJsx) === true;
676
+ }
677
+ function importNames(clause) {
678
+ if (!clause) return [];
679
+ const names = [];
680
+ if (clause.name) names.push("default");
681
+ if (clause.namedBindings) {
682
+ if (ts.isNamespaceImport(clause.namedBindings)) names.push("*");
683
+ else for (const item of clause.namedBindings.elements) names.push(item.propertyName?.text ?? item.name.text);
684
+ }
685
+ return names;
686
+ }
687
+ function sourceKind(path) {
688
+ if (path.endsWith(".tsx")) return ts.ScriptKind.TSX;
689
+ if (path.endsWith(".jsx")) return ts.ScriptKind.JSX;
690
+ if (path.endsWith(".js") || path.endsWith(".mjs") || path.endsWith(".cjs")) return ts.ScriptKind.JS;
691
+ return ts.ScriptKind.TS;
692
+ }
693
+ function parseModule(path, contents) {
694
+ const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
695
+ const imports = [];
696
+ const exports = [];
697
+ const typeExports = [];
698
+ let jsxDepth = 0;
699
+ let complexity = 1;
700
+ let componentCount = 0;
701
+ for (const node of source.statements) {
702
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
703
+ imports.push({
704
+ specifier: node.moduleSpecifier.text,
705
+ line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
706
+ importedNames: importNames(node.importClause),
707
+ isTypeOnly: node.importClause?.isTypeOnly ?? false
708
+ });
709
+ }
710
+ if (ts.isExportDeclaration(node)) {
711
+ if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
712
+ imports.push({
713
+ specifier: node.moduleSpecifier.text,
714
+ line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
715
+ importedNames: node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements.map((item) => item.propertyName?.text ?? item.name.text) : ["*"],
716
+ isTypeOnly: node.isTypeOnly
289
717
  });
290
718
  }
719
+ if (node.exportClause && ts.isNamedExports(node.exportClause)) {
720
+ exports.push(...node.exportClause.elements.map((item) => item.name.text));
721
+ if (node.isTypeOnly) typeExports.push(...node.exportClause.elements.map((item) => item.name.text));
722
+ }
723
+ }
724
+ if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) {
725
+ if (hasModifier(node, ts.SyntaxKind.DefaultKeyword)) exports.push("default");
726
+ else if (ts.isVariableStatement(node)) {
727
+ for (const declaration of node.declarationList.declarations) {
728
+ if (ts.isIdentifier(declaration.name)) exports.push(declaration.name.text);
729
+ }
730
+ } else if ("name" in node && node.name && ts.isIdentifier(node.name)) {
731
+ const name = node.name.text;
732
+ exports.push(name);
733
+ if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) typeExports.push(name);
734
+ }
291
735
  }
736
+ if (ts.isFunctionDeclaration(node) && node.name && /^[A-Z]/.test(node.name.text) && containsJsx(node)) {
737
+ componentCount++;
738
+ }
739
+ if (ts.isVariableStatement(node)) {
740
+ for (const declaration of node.declarationList.declarations) {
741
+ if (ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text) && declaration.initializer && containsJsx(declaration.initializer)) {
742
+ componentCount++;
743
+ }
744
+ }
745
+ }
746
+ }
747
+ function visit(node, depth) {
748
+ if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
749
+ const argument = node.arguments[0];
750
+ if (argument && ts.isStringLiteralLike(argument)) {
751
+ imports.push({
752
+ specifier: argument.text,
753
+ line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
754
+ importedNames: ["*"],
755
+ isTypeOnly: false
756
+ });
757
+ }
758
+ }
759
+ const isJsx = ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
760
+ const nextDepth = depth + (isJsx ? 1 : 0);
761
+ jsxDepth = Math.max(jsxDepth, nextDepth);
762
+ 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++;
763
+ if (ts.isBinaryExpression(node) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind)) {
764
+ complexity++;
765
+ }
766
+ ts.forEachChild(node, (child) => visit(child, nextDepth));
292
767
  }
768
+ visit(source, 0);
293
769
  return {
294
- appsChecked: apps.length,
295
- findings,
296
- policyVersion: options.policy.policyVersion,
297
- policySource: options.policy.source,
298
- policySha256: options.policy.sha256
770
+ path,
771
+ imports,
772
+ exports: [...new Set(exports)].sort(),
773
+ typeExports: [...new Set(typeExports)].sort(),
774
+ lines: contents.split(/\r?\n/).length,
775
+ jsxDepth,
776
+ componentCount,
777
+ complexity,
778
+ isRoute: /(^|\/)(app|pages)\/.+(page|layout|route|index)\.[cm]?[jt]sx?$/.test(path) || /(^|\/)app\/(page|layout|route)\.[cm]?[jt]sx?$/.test(path)
779
+ };
780
+ }
781
+ function moduleParseDiagnostics(path, contents) {
782
+ const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
783
+ const diagnostics = source.parseDiagnostics ?? [];
784
+ const messages = diagnostics.map((diagnostic) => {
785
+ const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1;
786
+ return `${path}:${line}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`;
787
+ });
788
+ function visit(node) {
789
+ if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
790
+ const argument = node.arguments[0];
791
+ if (!argument || !ts.isStringLiteralLike(argument)) {
792
+ const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
793
+ messages.push(`${path}:${line}: nonliteral dynamic import or require cannot be analyzed`);
794
+ }
795
+ }
796
+ ts.forEachChild(node, visit);
797
+ }
798
+ visit(source);
799
+ return messages;
800
+ }
801
+
802
+ // src/analysis/resolve.ts
803
+ import { readFile as readFile5, stat } from "fs/promises";
804
+ import { dirname as dirname2, extname, join as join4, relative as relative4, resolve as resolve4, sep as sep3 } from "path";
805
+ import ts2 from "typescript";
806
+ async function compilerOptions(root) {
807
+ const path = join4(root, "tsconfig.json");
808
+ let content;
809
+ try {
810
+ content = await readFile5(path, "utf8");
811
+ } catch (error) {
812
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return { options: {}, diagnostics: [] };
813
+ throw error;
814
+ }
815
+ try {
816
+ const json = ts2.parseConfigFileTextToJson(path, content);
817
+ if (json.error) return { options: {}, diagnostics: [`tsconfig.json: ${ts2.flattenDiagnosticMessageText(json.error.messageText, " ")}`] };
818
+ const parsed = ts2.parseJsonConfigFileContent(json.config, ts2.sys, root);
819
+ return { options: parsed.options, diagnostics: parsed.errors.map((error) => `tsconfig.json: ${ts2.flattenDiagnosticMessageText(error.messageText, " ")}`) };
820
+ } catch (error) {
821
+ return { options: {}, diagnostics: [`tsconfig.json: ${error instanceof Error ? error.message : String(error)}`] };
822
+ }
823
+ }
824
+ function propertyName(name) {
825
+ if (ts2.isIdentifier(name) || ts2.isStringLiteral(name)) return name.text;
826
+ return void 0;
827
+ }
828
+ function staticAliasTarget(node) {
829
+ if (ts2.isStringLiteralLike(node)) return node.text;
830
+ if (ts2.isCallExpression(node) || ts2.isNewExpression(node)) {
831
+ for (const argument of node.arguments ?? []) {
832
+ const found = staticAliasTarget(argument);
833
+ if (found?.startsWith("./")) return found;
834
+ }
835
+ }
836
+ return void 0;
837
+ }
838
+ async function viteAliasDiagnostics(root, options, aliases) {
839
+ const diagnostics = [];
840
+ for (const file of ["vite.config.ts", "vite.config.js", "vite.config.mts", "vite.config.mjs", "vite.config.cts", "vite.config.cjs"]) {
841
+ let visit2 = function(node) {
842
+ if (ts2.isPropertyAssignment(node) && propertyName(node.name) === "resolve") {
843
+ if (!ts2.isObjectLiteralExpression(node.initializer)) {
844
+ diagnostics.push(`${file}: dynamic Vite resolve configuration cannot be verified`);
845
+ ts2.forEachChild(node, visit2);
846
+ return;
847
+ }
848
+ for (const property of node.initializer.properties) {
849
+ if (!ts2.isPropertyAssignment(property) || !propertyName(property.name)) {
850
+ diagnostics.push(`${file}: dynamic Vite resolve entry cannot be verified`);
851
+ continue;
852
+ }
853
+ if (propertyName(property.name) !== "alias") continue;
854
+ if (!ts2.isObjectLiteralExpression(property.initializer)) {
855
+ diagnostics.push(`${file}: dynamic Vite alias configuration cannot be verified`);
856
+ continue;
857
+ }
858
+ for (const alias of property.initializer.properties) {
859
+ if (!ts2.isPropertyAssignment(alias) || !propertyName(alias.name)) {
860
+ diagnostics.push(`${file}: dynamic Vite alias entry cannot be verified`);
861
+ continue;
862
+ }
863
+ const key = propertyName(alias.name);
864
+ const prefix = key.endsWith("/") ? key : `${key}/`;
865
+ const centralTarget = aliases[prefix];
866
+ const tsTarget = options.paths?.[`${key}/*`]?.[0];
867
+ const mapped = centralTarget ?? (tsTarget?.endsWith("/*") ? tsTarget.slice(0, -1).replace(/^\.\//, "") : void 0);
868
+ if (!mapped) {
869
+ diagnostics.push(`${file}: Vite alias ${key} needs a central analysis.aliases or matching tsconfig paths mapping`);
870
+ continue;
871
+ }
872
+ const target = staticAliasTarget(alias.initializer);
873
+ if (!target?.startsWith("./")) {
874
+ diagnostics.push(`${file}: Vite alias ${key} target cannot be verified statically`);
875
+ continue;
876
+ }
877
+ const expected = `${target.slice(2).replace(/\/$/, "")}/`;
878
+ if (mapped !== expected) diagnostics.push(`${file}: Vite alias ${key} maps to ${expected}, but assessment uses ${mapped}`);
879
+ }
880
+ }
881
+ }
882
+ ts2.forEachChild(node, visit2);
883
+ };
884
+ var visit = visit2;
885
+ let sourceText;
886
+ try {
887
+ sourceText = await readFile5(join4(root, file), "utf8");
888
+ } catch (error) {
889
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue;
890
+ throw error;
891
+ }
892
+ const source = ts2.createSourceFile(file, sourceText, ts2.ScriptTarget.Latest, true);
893
+ const defaultExport = source.statements.find((statement) => ts2.isExportAssignment(statement) && !statement.isExportEquals);
894
+ if (!defaultExport || !ts2.isExportAssignment(defaultExport)) {
895
+ diagnostics.push(`${file}: Vite configuration export cannot be verified statically`);
896
+ } else {
897
+ const expression = defaultExport.expression;
898
+ const config = ts2.isCallExpression(expression) && expression.expression.getText(source) === "defineConfig" ? expression.arguments[0] : expression;
899
+ if (!config || !ts2.isObjectLiteralExpression(config)) {
900
+ diagnostics.push(`${file}: Vite configuration export cannot be verified statically`);
901
+ } else if (config.properties.some((property) => !ts2.isPropertyAssignment(property) || !propertyName(property.name))) {
902
+ diagnostics.push(`${file}: dynamic Vite configuration entry cannot be verified`);
903
+ }
904
+ }
905
+ visit2(source);
906
+ }
907
+ return diagnostics;
908
+ }
909
+ async function existingNonSourceAsset(path) {
910
+ if (/\.[cm]?[jt]sx?$/.test(extname(path)) || path.endsWith(".d.ts")) return false;
911
+ return (await stat(path).catch(() => null))?.isFile() === true;
912
+ }
913
+ async function resolveImportsDetailed(root, modules, aliases = {}) {
914
+ const { options, diagnostics } = await compilerOptions(root);
915
+ diagnostics.push(...await viteAliasDiagnostics(root, options, aliases));
916
+ const files = new Set(modules.map((module) => module.path));
917
+ for (const module of modules) {
918
+ const from = join4(root, module.path);
919
+ for (const imported of module.imports) {
920
+ const specifier = imported.specifier.split("?")[0];
921
+ const matchingAlias = Object.keys(aliases).sort((a, b) => b.length - a.length).find((prefix) => specifier.startsWith(prefix));
922
+ const aliasTarget = matchingAlias ? resolve4(root, aliases[matchingAlias], specifier.slice(matchingAlias.length)) : void 0;
923
+ const candidate = aliasTarget ? relative4(dirname2(from), aliasTarget).replaceAll("\\", "/") : specifier;
924
+ const lookup = aliasTarget && !candidate.startsWith(".") ? `./${candidate}` : candidate;
925
+ const result = ts2.resolveModuleName(lookup, from, options, ts2.sys).resolvedModule;
926
+ const localAlias = Object.keys(options.paths ?? {}).some((pattern) => {
927
+ const [prefix, suffix = ""] = pattern.split("*");
928
+ return specifier.startsWith(prefix) && specifier.endsWith(suffix);
929
+ });
930
+ const isLocal = specifier.startsWith(".") || localAlias || aliasTarget !== void 0 || /^[~@#]\//.test(specifier);
931
+ if (!result) {
932
+ const assetPath = aliasTarget ?? (specifier.startsWith(".") ? resolve4(dirname2(from), specifier) : void 0);
933
+ const assetRelative = assetPath ? relative4(root, assetPath) : void 0;
934
+ if (assetRelative && (assetRelative === ".." || assetRelative.startsWith(`..${sep3}`))) {
935
+ diagnostics.push(`${module.path}:${imported.line}: local import escapes app root ${imported.specifier}`);
936
+ continue;
937
+ }
938
+ if (assetPath && await existingNonSourceAsset(assetPath)) continue;
939
+ if (isLocal) diagnostics.push(`${module.path}:${imported.line}: unresolved local import ${imported.specifier}`);
940
+ continue;
941
+ }
942
+ const path = relative4(root, resolve4(result.resolvedFileName));
943
+ if (path === ".." || path.startsWith(`..${sep3}`)) {
944
+ if (isLocal) diagnostics.push(`${module.path}:${imported.line}: local import escapes app root ${imported.specifier}`);
945
+ continue;
946
+ }
947
+ const normalized = path.replaceAll("\\", "/");
948
+ if (files.has(normalized)) imported.resolved = normalized;
949
+ else if (isLocal && !normalized.endsWith(".d.ts")) diagnostics.push(`${module.path}:${imported.line}: local import outside analyzed source ${imported.specifier}`);
950
+ }
951
+ }
952
+ return diagnostics;
953
+ }
954
+
955
+ // src/analysis/scan.ts
956
+ async function scanProjectDetailed(projectRoot, aliases = {}) {
957
+ const root = resolve5(projectRoot);
958
+ const files = await fastGlob2("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
959
+ cwd: root,
960
+ onlyFiles: true,
961
+ unique: true,
962
+ dot: true,
963
+ followSymbolicLinks: false,
964
+ ignore: ["**/node_modules/**", "**/.git/**", "**/.next/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**", "**/*.d.ts"]
965
+ });
966
+ const parsed = await Promise.all(files.sort().map(async (path) => {
967
+ const normalized = path.replaceAll("\\", "/");
968
+ const contents = await readFile6(join5(root, path), "utf8");
969
+ return { module: parseModule(normalized, contents), diagnostics: moduleParseDiagnostics(normalized, contents) };
970
+ }));
971
+ const modules = parsed.map((item) => item.module);
972
+ const diagnostics = parsed.flatMap((item) => item.diagnostics);
973
+ diagnostics.push(...await resolveImportsDetailed(root, modules, aliases));
974
+ return { modules, diagnostics };
975
+ }
976
+
977
+ // src/analysis/score.ts
978
+ function round(value) {
979
+ return Math.round(value * 100) / 100;
980
+ }
981
+ function grade(score) {
982
+ if (score >= 95) return "A+";
983
+ if (score >= 90) return "A";
984
+ if (score >= 80) return "B";
985
+ if (score >= 70) return "C";
986
+ if (score >= 55) return "D";
987
+ return "F";
988
+ }
989
+ function scoreFindings(findings, config) {
990
+ const penalties = { error: 18, warning: 7, info: 2 };
991
+ const categories = Object.entries(config.weights).map(([id, weight]) => {
992
+ const categoryFindings = findings.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
993
+ const occurrences = /* @__PURE__ */ new Map();
994
+ let deduction = 0;
995
+ for (const finding of categoryFindings) {
996
+ const occurrence = (occurrences.get(finding.ruleId) ?? 0) + 1;
997
+ occurrences.set(finding.ruleId, occurrence);
998
+ deduction += penalties[finding.severity] / Math.sqrt(occurrence);
999
+ }
1000
+ deduction = Math.min(100, deduction);
1001
+ return { id, weight, score: round(100 - deduction), deduction: round(deduction), findingCount: categoryFindings.length };
1002
+ });
1003
+ const weightedDeduction = round(categories.reduce((sum, category) => sum + category.deduction * category.weight / 100, 0));
1004
+ const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings.length - 10) * 0.15));
1005
+ const criticalAdjustment = round(Math.min(10, findings.filter((finding) => finding.severity === "error").length * 1.5));
1006
+ const score = round(Math.max(0, Math.min(100, 100 - weightedDeduction - maintenanceAdjustment - criticalAdjustment)));
1007
+ return { score, grade: grade(score), weightedDeduction, maintenanceAdjustment, criticalAdjustment, categories };
1008
+ }
1009
+
1010
+ // src/governance/inventory.ts
1011
+ import { createHash as createHash2 } from "crypto";
1012
+ import { readFile as readFile7, realpath } from "fs/promises";
1013
+ import { isAbsolute, join as join6, relative as relative5, resolve as resolve6, sep as sep4 } from "path";
1014
+ var profiles2 = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
1015
+ async function loadInventory(source, scanRoot) {
1016
+ const absolute = resolve6(source);
1017
+ const root = await realpath(scanRoot);
1018
+ const inventoryPath = await realpath(absolute);
1019
+ const inventoryRelative = relative5(root, inventoryPath);
1020
+ if (inventoryRelative === ".." || inventoryRelative.startsWith(`..${sep4}`)) throw new Error("Inventory must be inside scan root");
1021
+ const raw = await readFile7(inventoryPath, "utf8");
1022
+ let value;
1023
+ try {
1024
+ value = JSON.parse(raw);
1025
+ } catch {
1026
+ throw new Error("Invalid app inventory JSON");
1027
+ }
1028
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Invalid app inventory object");
1029
+ const parsed = value;
1030
+ if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.apps) || parsed.apps.length === 0) {
1031
+ throw new Error("Inventory schemaVersion 1 and nonempty apps required");
1032
+ }
1033
+ const apps = [];
1034
+ const seen = /* @__PURE__ */ new Set();
1035
+ for (const entry of parsed.apps) {
1036
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error("Invalid app inventory entry");
1037
+ const app = entry;
1038
+ const path = app.path;
1039
+ if (typeof path !== "string" || path !== "." && (path === "" || path.startsWith("./") || path.includes("\\") || path.split("/").some((segment) => !segment || segment === "." || segment === "..")) || isAbsolute(path)) {
1040
+ throw new Error(`Invalid app inventory path ${String(path)}`);
1041
+ }
1042
+ if (typeof app.profile !== "string" || !profiles2.has(app.profile)) throw new Error(`Invalid profile for ${path}`);
1043
+ if (seen.has(path)) throw new Error(`Duplicate inventory app ${path}`);
1044
+ seen.add(path);
1045
+ try {
1046
+ const actual = await realpath(join6(root, path));
1047
+ const rel = relative5(root, actual);
1048
+ if (rel === ".." || rel.startsWith(`..${sep4}`)) throw new Error(`App path escapes scan root: ${path}`);
1049
+ } catch (error) {
1050
+ if (error instanceof Error && error.message.includes("escapes scan root")) throw error;
1051
+ }
1052
+ apps.push({ path, profile: app.profile });
1053
+ }
1054
+ return { source: absolute, sha256: createHash2("sha256").update(raw).digest("hex"), apps };
1055
+ }
1056
+ function compareInventory(inventory, discovered, scanRoot) {
1057
+ const expected = new Map(inventory.apps.map((app) => [app.path, app.profile]));
1058
+ const actual = new Map(discovered.map((app) => [relative5(scanRoot, app.directory).replaceAll("\\", "/") || ".", app.profile]));
1059
+ const issues = [];
1060
+ for (const [path, profile] of expected) {
1061
+ if (!actual.has(path)) issues.push(`Expected app ${path} was not discovered`);
1062
+ else if (actual.get(path) !== profile) issues.push(`App ${path} has profile ${actual.get(path)}; inventory requires ${profile}`);
1063
+ }
1064
+ for (const path of actual.keys()) if (!expected.has(path)) issues.push(`Discovered app ${path} is absent from inventory`);
1065
+ return issues;
1066
+ }
1067
+
1068
+ // src/governance/assess.ts
1069
+ var notEvaluated = [
1070
+ "vulnerability databases",
1071
+ "licenses",
1072
+ "secret detection",
1073
+ "test execution",
1074
+ "runtime behavior",
1075
+ "branch protection",
1076
+ "repository access controls",
1077
+ "cross-repository rollout status"
1078
+ ];
1079
+ function statusOf(statuses) {
1080
+ if (statuses.includes("incomplete")) return "incomplete";
1081
+ if (statuses.includes("fail")) return "fail";
1082
+ return "pass";
1083
+ }
1084
+ function escapeHtml(value) {
1085
+ return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1086
+ }
1087
+ function renderAssessmentHtml(report) {
1088
+ const apps = report.apps.map((app) => {
1089
+ const domains = Object.entries(app.domains).map(([name, domain]) => `<tr><th scope="row">${escapeHtml(name)}</th><td class="${domain.status}">${escapeHtml(domain.status)}</td><td>${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : "No issues"}</td></tr>`).join("");
1090
+ const architecture = app.domains.architecture;
1091
+ const categoryRows = architecture.scoreBreakdown?.categories.map((category) => `<tr><th scope="row">${escapeHtml(category.id)}</th><td>${escapeHtml(category.weight)}%</td><td>${escapeHtml(category.score)}</td><td>${escapeHtml(category.findingCount)}</td></tr>`).join("") ?? "";
1092
+ const findings = architecture.findings.map((finding) => `<tr><td>${escapeHtml(finding.severity)}</td><td>${escapeHtml(finding.ruleId)}</td><td><code>${escapeHtml(finding.file)}:${finding.line}</code></td><td>${escapeHtml(finding.evidence)}</td></tr>`).join("");
1093
+ return `<section class="app"><h2>${escapeHtml(app.path)} <span class="${app.status}">${escapeHtml(app.status)}</span></h2><p>Profile: ${escapeHtml(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${findings ? `<details><summary>Architecture findings (${architecture.findings.length})</summary><div class="scroll"><table><thead><tr><th>Severity</th><th>Rule</th><th>Location</th><th>Evidence</th></tr></thead><tbody>${findings}</tbody></table></div></details>` : ""}</section>`;
1094
+ }).join("");
1095
+ 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>
1096
+ :root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#f6f7f5;color:#1b2925}body{max-width:1100px;margin:auto;padding:2rem 1rem}header,.app,.meta{background:white;border:1px solid #d9e1dc;border-radius:12px;padding:1.4rem;margin:0 0 1.2rem}h1{font-size:clamp(2rem,5vw,3.5rem);line-height:1.08;margin:.3rem 0 1rem}h2{font-size:1.35rem;margin:0 0 .75rem}p{margin:.5rem 0}code{overflow-wrap:anywhere}.pass{color:#146c43;font-weight:700}.fail{color:#b3242b;font-weight:700}.incomplete{color:#8a5500;font-weight:700}.scroll{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d9e1dc;padding:.8rem}th:first-child{width:9rem}ul{padding-left:1.2rem;margin:.1rem 0}li+li{margin-top:.3rem}small{color:#50645c}details{border-top:1px solid #d9e1dc;margin-top:1rem;padding-top:1rem}summary{cursor:pointer;font-weight:650}details[open] summary{margin-bottom:.6rem}
1097
+ </style></head><body><header><small>NKLATTICE \xB7 CONFIGURED POLICY ASSESSMENT</small><h1>Project governance <span class="${report.status}">${escapeHtml(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s)</p></header><section class="meta"><h2>Evidence identity</h2><p>Policy ${escapeHtml(report.policy.version)} \xB7 <code>${escapeHtml(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml(report.inventory.source)}</code></p>${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml).join(", ")}.</p></section></body></html>
1098
+ `;
1099
+ }
1100
+ async function assessGovernance(options) {
1101
+ const scanRoot = resolve7(options.scanRoot);
1102
+ const workspaceRoot = resolve7(options.workspaceRoot ?? scanRoot);
1103
+ const scanRelative = relative6(workspaceRoot, scanRoot);
1104
+ if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
1105
+ if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
1106
+ const assessment = parseAssessmentPolicy(options.policy.assessment);
1107
+ const outputDirectory = resolve7(options.outputDirectory ?? join7(scanRoot, ".nklattice-governance"));
1108
+ const inventorySource = resolve7(options.inventory ?? join7(scanRoot, ".nklattice-apps.json"));
1109
+ let inventory;
1110
+ const issues = [];
1111
+ try {
1112
+ inventory = await loadInventory(inventorySource, scanRoot);
1113
+ } catch (error) {
1114
+ issues.push(`Inventory: ${error instanceof Error ? error.message : String(error)}`);
1115
+ }
1116
+ let apps = [];
1117
+ try {
1118
+ apps = await discoverApps(scanRoot, workspaceRoot);
1119
+ } catch (error) {
1120
+ issues.push(`Discovery: ${error instanceof Error ? error.message : String(error)}`);
1121
+ }
1122
+ if (apps.length === 0) issues.push("No managed applications discovered");
1123
+ if (inventory) issues.push(...compareInventory(inventory, apps, scanRoot));
1124
+ if (options.expectedApps !== void 0 && apps.length !== options.expectedApps) {
1125
+ issues.push(`Expected ${options.expectedApps} managed apps; discovered ${apps.length}`);
1126
+ }
1127
+ const results = [];
1128
+ for (const app of apps) {
1129
+ const appPath = relative6(scanRoot, app.directory).replaceAll("\\", "/") || ".";
1130
+ const inventoryIssues = issues.filter((issue) => issue.includes(appPath) || issue.startsWith("Inventory:") || issue.startsWith("Discovery:"));
1131
+ const inventoryDomain = { status: inventoryIssues.length ? "incomplete" : "pass", issues: inventoryIssues };
1132
+ const dependencyDomain = { status: "pass", issues: [], findings: [] };
1133
+ try {
1134
+ dependencyDomain.findings = await checkApp(app, workspaceRoot, options.policy);
1135
+ if (dependencyDomain.findings.length) {
1136
+ dependencyDomain.status = "fail";
1137
+ dependencyDomain.issues = dependencyDomain.findings.map((item) => `${item.packageName}: ${item.reason}; required ${item.expected}, declared ${item.declared ?? "missing"}, locked ${item.locked ?? "missing"}`);
1138
+ }
1139
+ } catch (error) {
1140
+ dependencyDomain.status = "incomplete";
1141
+ dependencyDomain.issues = [error instanceof Error ? error.message : String(error)];
1142
+ }
1143
+ const architectureDomain = {
1144
+ status: "pass",
1145
+ issues: [],
1146
+ filesScanned: 0,
1147
+ localEdges: 0,
1148
+ errors: 0,
1149
+ findings: [],
1150
+ diagnostics: []
1151
+ };
1152
+ try {
1153
+ const scan = await scanProjectDetailed(app.directory, assessment.architecture.analysis.aliases);
1154
+ architectureDomain.filesScanned = scan.modules.length;
1155
+ architectureDomain.diagnostics = scan.diagnostics;
1156
+ const graph = buildGraph(scan.modules);
1157
+ architectureDomain.localEdges = graph.edges.length;
1158
+ architectureDomain.findings = evaluateRules(graph, assessment.architecture.analysis);
1159
+ const score = scoreFindings(architectureDomain.findings, assessment.architecture.analysis);
1160
+ architectureDomain.score = score.score;
1161
+ architectureDomain.grade = score.grade;
1162
+ architectureDomain.scoreBreakdown = score;
1163
+ architectureDomain.errors = architectureDomain.findings.filter((item) => item.severity === "error").length;
1164
+ if (scan.modules.length === 0) architectureDomain.diagnostics.push("No supported source files found");
1165
+ if (architectureDomain.diagnostics.length) {
1166
+ architectureDomain.status = "incomplete";
1167
+ architectureDomain.issues.push(...architectureDomain.diagnostics);
1168
+ }
1169
+ if (score.score < assessment.architecture.minimumScore) {
1170
+ architectureDomain.issues.push(`Score ${score.score} is below minimum ${assessment.architecture.minimumScore}`);
1171
+ }
1172
+ if (architectureDomain.errors > assessment.architecture.maximumErrors) {
1173
+ architectureDomain.issues.push(`${architectureDomain.errors} error findings exceed maximum ${assessment.architecture.maximumErrors}`);
1174
+ }
1175
+ const blocked = architectureDomain.findings.filter((item) => assessment.architecture.blockedRuleIds.includes(item.ruleId));
1176
+ for (const finding of blocked) architectureDomain.issues.push(`Blocked ${finding.ruleId} at ${finding.file}:${finding.line}: ${finding.evidence}`);
1177
+ if (architectureDomain.status !== "incomplete" && architectureDomain.issues.length) architectureDomain.status = "fail";
1178
+ } catch (error) {
1179
+ architectureDomain.status = "incomplete";
1180
+ architectureDomain.issues.push(error instanceof Error ? error.message : String(error));
1181
+ }
1182
+ const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain };
1183
+ results.push({ path: appPath, profile: app.profile, status: statusOf(Object.values(domains).map((domain) => domain.status)), domains });
1184
+ }
1185
+ const report = {
1186
+ schema: "urn:nklattice:governance-assessment:1",
1187
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1188
+ status: issues.length ? "incomplete" : statusOf(results.map((app) => app.status)),
1189
+ policy: { version: options.policy.policyVersion, source: options.policy.source, sha256: options.policy.sha256 },
1190
+ inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
1191
+ scope: { domains: ["explicit app inventory", "dependency minimums", "source architecture"], notEvaluated },
1192
+ issues,
1193
+ apps: results
299
1194
  };
1195
+ await mkdir(outputDirectory, { recursive: true });
1196
+ await writeFile(join7(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
1197
+ `);
1198
+ await writeFile(join7(outputDirectory, "report.html"), renderAssessmentHtml(report));
1199
+ return report;
300
1200
  }
301
1201
  export {
1202
+ assessGovernance,
302
1203
  checkGovernance,
303
- loadPolicy
1204
+ loadPolicy,
1205
+ renderAssessmentHtml
304
1206
  };