@anhcodo13/lattice 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { writeFile as writeFile3 } from "fs/promises";
4
+ import { writeFile as writeFile4 } from "fs/promises";
5
5
  import { realpathSync } from "fs";
6
6
  import { fileURLToPath } from "url";
7
- import { dirname as dirname3, join as join10, resolve as resolve9 } from "path";
7
+ import { dirname as dirname6, join as join13, resolve as resolve11 } from "path";
8
8
  import { Command, CommanderError } from "commander";
9
9
 
10
10
  // package.json
11
11
  var package_default = {
12
12
  name: "@anhcodo13/lattice",
13
- version: "0.3.0",
13
+ version: "0.5.0",
14
14
  private: false,
15
15
  packageManager: "pnpm@10.30.3",
16
16
  type: "module",
@@ -223,28 +223,28 @@ async function exists(path) {
223
223
  }
224
224
  }
225
225
  async function archivePrevious(outputDirectory) {
226
- const json = join2(outputDirectory, "analysis.json");
226
+ const json2 = join2(outputDirectory, "analysis.json");
227
227
  const html = join2(outputDirectory, "report.html");
228
- if (!await exists(json) && !await exists(html)) return;
228
+ if (!await exists(json2) && !await exists(html)) return;
229
229
  const name = `${(/* @__PURE__ */ new Date()).toISOString().replaceAll(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
230
230
  const destination = join2(outputDirectory, "history", name);
231
231
  await mkdir(destination, { recursive: true });
232
- if (await exists(json)) await rename(json, join2(destination, "analysis.json"));
232
+ if (await exists(json2)) await rename(json2, join2(destination, "analysis.json"));
233
233
  if (await exists(html)) await rename(html, join2(destination, "report.html"));
234
234
  }
235
235
 
236
236
  // src/analysis/recommend.ts
237
- function recommendActions(findings) {
237
+ function recommendActions(findings2) {
238
238
  const points = { error: 5, warning: 2, info: 1 };
239
239
  const grouped = /* @__PURE__ */ new Map();
240
- for (const finding of findings) grouped.set(finding.file, [...grouped.get(finding.file) ?? [], finding]);
240
+ for (const finding of findings2) grouped.set(finding.file, [...grouped.get(finding.file) ?? [], finding]);
241
241
  const ranked = [...grouped.entries()].map(([file, items]) => ({ file, items, priority: items.reduce((sum, item) => sum + points[item.severity], 0) })).sort((a, b) => b.priority - a.priority || a.file.localeCompare(b.file));
242
242
  const hotspots = ranked.slice(0, 8).map(({ file, items, priority }) => ({
243
243
  file,
244
244
  findingCount: items.length,
245
245
  estimatedRecovery: `Estimate: up to ${Math.min(15, priority)} points if these findings are resolved`
246
246
  }));
247
- const quickWins = findings.filter((finding) => finding.severity === "info" || finding.ruleId === "duplicate-import").slice(0, 8).map((finding) => ({ file: finding.file, ruleId: finding.ruleId, recommendation: finding.recommendation }));
247
+ const quickWins = findings2.filter((finding) => finding.severity === "info" || finding.ruleId === "duplicate-import").slice(0, 8).map((finding) => ({ file: finding.file, ruleId: finding.ruleId, recommendation: finding.recommendation }));
248
248
  const roadmap = ranked.slice(0, 8).map(({ file, items, priority }) => ({
249
249
  file,
250
250
  priority,
@@ -610,12 +610,12 @@ function renderHtml(report) {
610
610
  // src/analysis/rules.ts
611
611
  import { matchesGlob } from "path";
612
612
  function evaluateRules(graph, config) {
613
- const findings = [];
613
+ const findings2 = [];
614
614
  const outgoing = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly)]));
615
615
  const incoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path && !edge.isTypeOnly)]));
616
616
  const allIncoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path)]));
617
617
  for (const cycle of findCycles(graph)) {
618
- findings.push({
618
+ findings2.push({
619
619
  ruleId: "module-cycle",
620
620
  category: "architecture",
621
621
  severity: "error",
@@ -632,7 +632,7 @@ function evaluateRules(graph, config) {
632
632
  for (const imported of module.imports) {
633
633
  if (imported.isTypeOnly) continue;
634
634
  if (seen.has(imported.specifier)) {
635
- findings.push({
635
+ findings2.push({
636
636
  ruleId: "duplicate-import",
637
637
  category: "imports",
638
638
  severity: "warning",
@@ -645,7 +645,7 @@ function evaluateRules(graph, config) {
645
645
  seen.add(imported.specifier);
646
646
  }
647
647
  if (out.length > config.thresholds.maxFanOut) {
648
- findings.push({
648
+ findings2.push({
649
649
  ruleId: "high-fan-out",
650
650
  category: "architecture",
651
651
  severity: "warning",
@@ -656,7 +656,7 @@ function evaluateRules(graph, config) {
656
656
  });
657
657
  }
658
658
  if (into.length > config.thresholds.maxFanIn) {
659
- findings.push({
659
+ findings2.push({
660
660
  ruleId: "high-fan-in",
661
661
  category: "architecture",
662
662
  severity: "warning",
@@ -667,7 +667,7 @@ function evaluateRules(graph, config) {
667
667
  });
668
668
  }
669
669
  if (into.length === 0 && out.length === 0 && graph.modules.length > 1 && !module.isRoute) {
670
- findings.push({
670
+ findings2.push({
671
671
  ruleId: "orphan-module",
672
672
  category: "architecture",
673
673
  severity: "info",
@@ -683,7 +683,7 @@ function evaluateRules(graph, config) {
683
683
  for (const name of module.exports) {
684
684
  if (name === "*" || module.typeExports.includes(name)) continue;
685
685
  if (!usedNames.has(name)) {
686
- findings.push({
686
+ findings2.push({
687
687
  ruleId: "unused-export",
688
688
  category: "imports",
689
689
  severity: "info",
@@ -697,7 +697,7 @@ function evaluateRules(graph, config) {
697
697
  }
698
698
  }
699
699
  if (module.componentCount > 0 && module.lines > config.thresholds.maxComponentLines) {
700
- findings.push({
700
+ findings2.push({
701
701
  ruleId: "large-component",
702
702
  category: "components",
703
703
  severity: "warning",
@@ -708,7 +708,7 @@ function evaluateRules(graph, config) {
708
708
  });
709
709
  }
710
710
  if (module.jsxDepth > config.thresholds.maxJsxDepth) {
711
- findings.push({
711
+ findings2.push({
712
712
  ruleId: "deep-jsx",
713
713
  category: "components",
714
714
  severity: "warning",
@@ -719,7 +719,7 @@ function evaluateRules(graph, config) {
719
719
  });
720
720
  }
721
721
  if (module.complexity > config.thresholds.maxComplexity) {
722
- findings.push({
722
+ findings2.push({
723
723
  ruleId: "high-complexity",
724
724
  category: "complexity",
725
725
  severity: "warning",
@@ -735,7 +735,7 @@ function evaluateRules(graph, config) {
735
735
  const allowed = policy.allow?.some((pattern) => matchesGlob(edge.to, pattern));
736
736
  const disallowed = policy.disallow?.some((pattern) => matchesGlob(edge.to, pattern));
737
737
  if (policy.allow && !allowed || policy.disallow && disallowed) {
738
- findings.push({
738
+ findings2.push({
739
739
  ruleId: policy.id,
740
740
  category: "architecture",
741
741
  severity: policy.severity,
@@ -762,7 +762,7 @@ function evaluateRules(graph, config) {
762
762
  return false;
763
763
  };
764
764
  if (visit(module.path, /* @__PURE__ */ new Set([module.path]), 0)) {
765
- findings.push({
765
+ findings2.push({
766
766
  ruleId: "deep-dependency-chain",
767
767
  category: "architecture",
768
768
  severity: "warning",
@@ -773,7 +773,7 @@ function evaluateRules(graph, config) {
773
773
  });
774
774
  }
775
775
  }
776
- return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
776
+ return findings2.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
777
777
  }
778
778
 
779
779
  // src/analysis/scan.ts
@@ -929,9 +929,9 @@ async function compilerOptions(root) {
929
929
  throw error;
930
930
  }
931
931
  try {
932
- const json = ts2.parseConfigFileTextToJson(path, content);
933
- if (json.error) return { options: {}, diagnostics: [`tsconfig.json: ${ts2.flattenDiagnosticMessageText(json.error.messageText, " ")}`] };
934
- const parsed = ts2.parseJsonConfigFileContent(json.config, ts2.sys, root);
932
+ const json2 = ts2.parseConfigFileTextToJson(path, content);
933
+ if (json2.error) return { options: {}, diagnostics: [`tsconfig.json: ${ts2.flattenDiagnosticMessageText(json2.error.messageText, " ")}`] };
934
+ const parsed = ts2.parseJsonConfigFileContent(json2.config, ts2.sys, root);
935
935
  return { options: parsed.options, diagnostics: parsed.errors.map((error) => `tsconfig.json: ${ts2.flattenDiagnosticMessageText(error.messageText, " ")}`) };
936
936
  } catch (error) {
937
937
  return { options: {}, diagnostics: [`tsconfig.json: ${error instanceof Error ? error.message : String(error)}`] };
@@ -1105,10 +1105,10 @@ function grade(score) {
1105
1105
  if (score >= 55) return "D";
1106
1106
  return "F";
1107
1107
  }
1108
- function scoreFindings(findings, config) {
1108
+ function scoreFindings(findings2, config) {
1109
1109
  const penalties = { error: 18, warning: 7, info: 2 };
1110
1110
  const categories = Object.entries(config.weights).map(([id, weight]) => {
1111
- const categoryFindings = findings.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
1111
+ const categoryFindings = findings2.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
1112
1112
  const occurrences = /* @__PURE__ */ new Map();
1113
1113
  let deduction = 0;
1114
1114
  for (const finding of categoryFindings) {
@@ -1120,8 +1120,8 @@ function scoreFindings(findings, config) {
1120
1120
  return { id, weight, score: round(100 - deduction), deduction: round(deduction), findingCount: categoryFindings.length };
1121
1121
  });
1122
1122
  const weightedDeduction = round(categories.reduce((sum, category) => sum + category.deduction * category.weight / 100, 0));
1123
- const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings.length - 10) * 0.15));
1124
- const criticalAdjustment = round(Math.min(10, findings.filter((finding) => finding.severity === "error").length * 1.5));
1123
+ const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings2.length - 10) * 0.15));
1124
+ const criticalAdjustment = round(Math.min(10, findings2.filter((finding) => finding.severity === "error").length * 1.5));
1125
1125
  const score = round(Math.max(0, Math.min(100, 100 - weightedDeduction - maintenanceAdjustment - criticalAdjustment)));
1126
1126
  return { score, grade: grade(score), weightedDeduction, maintenanceAdjustment, criticalAdjustment, categories };
1127
1127
  }
@@ -1133,7 +1133,7 @@ async function analyzeProject(projectRoot, options = {}) {
1133
1133
  const modules = await scanProject(root, config.aliases);
1134
1134
  if (modules.length === 0) throw new Error("No supported source files found");
1135
1135
  const graph = buildGraph(modules);
1136
- const findings = evaluateRules(graph, config);
1136
+ const findings2 = evaluateRules(graph, config);
1137
1137
  let name = basename(root);
1138
1138
  try {
1139
1139
  const manifest = JSON.parse(await readFile4(join5(root, "package.json"), "utf8"));
@@ -1145,9 +1145,9 @@ async function analyzeProject(projectRoot, options = {}) {
1145
1145
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1146
1146
  project: { name },
1147
1147
  graph,
1148
- findings,
1149
- score: scoreFindings(findings, config),
1150
- actions: recommendActions(findings)
1148
+ findings: findings2,
1149
+ score: scoreFindings(findings2, config),
1150
+ actions: recommendActions(findings2)
1151
1151
  };
1152
1152
  const outputDirectory = resolve3(options.outputDirectory ?? join5(root, ".nklattice-report"));
1153
1153
  await mkdir2(outputDirectory, { recursive: true });
@@ -1159,7 +1159,9 @@ async function analyzeProject(projectRoot, options = {}) {
1159
1159
  }
1160
1160
 
1161
1161
  // src/governance/check.ts
1162
- import { resolve as resolve5, relative as relative4, sep as sep3 } from "path";
1162
+ import { readFile as readFile7 } from "fs/promises";
1163
+ import { resolve as resolve5, relative as relative4, sep as sep3, join as join8, dirname as dirname3 } from "path";
1164
+ import fastGlob3 from "fast-glob";
1163
1165
  import { satisfies, subset, valid, validRange } from "semver";
1164
1166
 
1165
1167
  // src/governance/discover.ts
@@ -1176,12 +1178,12 @@ function dependencyMap(manifest) {
1176
1178
  const entries = manifest[section2];
1177
1179
  if (entries === void 0) continue;
1178
1180
  if (!isRecord(entries)) throw new Error(`Invalid ${section2} in package.json`);
1179
- for (const [name, version] of Object.entries(entries)) {
1180
- if (typeof version !== "string") throw new Error(`Invalid version for ${name}`);
1181
- if (result[name] !== void 0 && result[name] !== version) {
1181
+ for (const [name, version2] of Object.entries(entries)) {
1182
+ if (typeof version2 !== "string") throw new Error(`Invalid version for ${name}`);
1183
+ if (result[name] !== void 0 && result[name] !== version2) {
1182
1184
  throw new Error(`Conflicting declarations for ${name}`);
1183
1185
  }
1184
- result[name] = version;
1186
+ result[name] = version2;
1185
1187
  }
1186
1188
  }
1187
1189
  return result;
@@ -1324,17 +1326,17 @@ function readPnpm(raw, root, app, name) {
1324
1326
  if (!record2(entry) || typeof entry.specifier !== "string" || typeof entry.version !== "string") {
1325
1327
  throw new Error(`Malformed pnpm lockfile dependency for ${name}`);
1326
1328
  }
1327
- const version = entry.version.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1];
1328
- if (!version) throw new Error(`Unsupported pnpm resolution for ${name}`);
1329
- const packageKeys = [`${name}@${version}`, `/${name}@${version}`];
1329
+ const version2 = entry.version.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1];
1330
+ if (!version2) throw new Error(`Unsupported pnpm resolution for ${name}`);
1331
+ const packageKeys = [`${name}@${version2}`, `/${name}@${version2}`];
1330
1332
  if (!packageKeys.some((key) => record2(lock.packages) && Object.hasOwn(lock.packages, key))) {
1331
- throw new Error(`Missing pnpm package entry for ${name}@${version}`);
1333
+ throw new Error(`Missing pnpm package entry for ${name}@${version2}`);
1332
1334
  }
1333
1335
  const snapshotKeys = [`${name}@${entry.version}`, `/${name}@${entry.version}`, ...packageKeys];
1334
1336
  if (!snapshotKeys.some((key) => Object.hasOwn(lock.snapshots, key))) {
1335
1337
  throw new Error(`Missing pnpm snapshot for ${name}@${entry.version}`);
1336
1338
  }
1337
- return { specifier: entry.specifier, version };
1339
+ return { specifier: entry.specifier, version: version2 };
1338
1340
  }
1339
1341
  async function readResolution(app, workspaceRoot, name) {
1340
1342
  const lockfile = await findLockfile(app.directory, workspaceRoot);
@@ -1343,11 +1345,36 @@ async function readResolution(app, workspaceRoot, name) {
1343
1345
  }
1344
1346
 
1345
1347
  // src/governance/check.ts
1348
+ var scanIgnore = ["**/node_modules/**", "**/.git/**", "**/.next/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**", "**/*.d.ts"];
1349
+ async function importedManagedPackages(app, managed) {
1350
+ if (Object.keys(managed).length === 0) return /* @__PURE__ */ new Set();
1351
+ const nestedManifests = await fastGlob3("**/package.json", { cwd: app.directory, onlyFiles: true, dot: true, followSymbolicLinks: false, ignore: scanIgnore });
1352
+ const nestedIgnore = nestedManifests.filter((path) => path !== "package.json").map((path) => `${dirname3(path)}/**`);
1353
+ const files = await fastGlob3("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
1354
+ cwd: app.directory,
1355
+ onlyFiles: true,
1356
+ dot: true,
1357
+ followSymbolicLinks: false,
1358
+ ignore: [...scanIgnore, ...nestedIgnore]
1359
+ });
1360
+ const found = /* @__PURE__ */ new Set();
1361
+ await Promise.all(files.map(async (path) => {
1362
+ const module = parseModule(path, await readFile7(join8(app.directory, path), "utf8"));
1363
+ for (const imported of module.imports) {
1364
+ for (const name of Object.keys(managed)) {
1365
+ if (imported.specifier === name || imported.specifier.startsWith(`${name}/`)) found.add(name);
1366
+ }
1367
+ }
1368
+ }));
1369
+ return found;
1370
+ }
1346
1371
  async function checkApp(app, workspaceRoot, policy) {
1347
1372
  const requirements = policy.profiles[app.profile];
1348
1373
  if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
1349
- const findings = [];
1350
- for (const [packageName, expected] of Object.entries(requirements)) {
1374
+ const findings2 = [];
1375
+ const imported = await importedManagedPackages(app, policy.managedPackages ?? {});
1376
+ const managed = Object.fromEntries(Object.entries(policy.managedPackages ?? {}).filter(([packageName]) => app.dependencies[packageName] !== void 0 || imported.has(packageName)));
1377
+ for (const [packageName, expected] of Object.entries({ ...requirements, ...managed })) {
1351
1378
  const declared = app.dependencies[packageName];
1352
1379
  const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
1353
1380
  const reasons = [];
@@ -1364,7 +1391,7 @@ async function checkApp(app, workspaceRoot, policy) {
1364
1391
  reasons.push("lockfile resolution outside manifest range");
1365
1392
  }
1366
1393
  }
1367
- if (reasons.length) findings.push({
1394
+ if (reasons.length) findings2.push({
1368
1395
  app: app.relativePath,
1369
1396
  packageName,
1370
1397
  expected,
@@ -1374,7 +1401,7 @@ async function checkApp(app, workspaceRoot, policy) {
1374
1401
  reason: reasons.join("; ")
1375
1402
  });
1376
1403
  }
1377
- return findings;
1404
+ return findings2;
1378
1405
  }
1379
1406
  async function checkGovernance(options) {
1380
1407
  const scanRoot = resolve5(options.scanRoot);
@@ -1385,10 +1412,10 @@ async function checkGovernance(options) {
1385
1412
  }
1386
1413
  const apps = await discoverApps(scanRoot, workspaceRoot);
1387
1414
  if (apps.length === 0) throw new Error("No managed applications found");
1388
- const findings = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
1415
+ const findings2 = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
1389
1416
  return {
1390
1417
  appsChecked: apps.length,
1391
- findings,
1418
+ findings: findings2,
1392
1419
  policyVersion: options.policy.policyVersion,
1393
1420
  policySource: options.policy.source,
1394
1421
  policySha256: options.policy.sha256
@@ -1397,12 +1424,12 @@ async function checkGovernance(options) {
1397
1424
 
1398
1425
  // src/governance/assess.ts
1399
1426
  import { mkdir as mkdir3, writeFile as writeFile2 } from "fs/promises";
1400
- import { join as join9, relative as relative6, resolve as resolve8, sep as sep5 } from "path";
1427
+ import { basename as basename2, join as join11, relative as relative7, resolve as resolve9, sep as sep6 } from "path";
1401
1428
 
1402
1429
  // src/governance/inventory.ts
1403
1430
  import { createHash } from "crypto";
1404
- import { readFile as readFile7, realpath } from "fs/promises";
1405
- import { isAbsolute, join as join8, relative as relative5, resolve as resolve6, sep as sep4 } from "path";
1431
+ import { readFile as readFile8, realpath } from "fs/promises";
1432
+ import { isAbsolute, join as join9, relative as relative5, resolve as resolve6, sep as sep4 } from "path";
1406
1433
  var profiles2 = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
1407
1434
  async function loadInventory(source, scanRoot) {
1408
1435
  const absolute = resolve6(source);
@@ -1410,7 +1437,7 @@ async function loadInventory(source, scanRoot) {
1410
1437
  const inventoryPath = await realpath(absolute);
1411
1438
  const inventoryRelative = relative5(root, inventoryPath);
1412
1439
  if (inventoryRelative === ".." || inventoryRelative.startsWith(`..${sep4}`)) throw new Error("Inventory must be inside scan root");
1413
- const raw = await readFile7(inventoryPath, "utf8");
1440
+ const raw = await readFile8(inventoryPath, "utf8");
1414
1441
  let value;
1415
1442
  try {
1416
1443
  value = JSON.parse(raw);
@@ -1435,7 +1462,7 @@ async function loadInventory(source, scanRoot) {
1435
1462
  if (seen.has(path)) throw new Error(`Duplicate inventory app ${path}`);
1436
1463
  seen.add(path);
1437
1464
  try {
1438
- const actual = await realpath(join8(root, path));
1465
+ const actual = await realpath(join9(root, path));
1439
1466
  const rel = relative5(root, actual);
1440
1467
  if (rel === ".." || rel.startsWith(`..${sep4}`)) throw new Error(`App path escapes scan root: ${path}`);
1441
1468
  } catch (error) {
@@ -1457,13 +1484,385 @@ function compareInventory(inventory, discovered, scanRoot) {
1457
1484
  return issues;
1458
1485
  }
1459
1486
 
1460
- // src/governance/policy.ts
1487
+ // src/governance/evidence.ts
1461
1488
  import { createHash as createHash2 } from "crypto";
1462
- import { readFile as readFile8 } from "fs/promises";
1463
- import { resolve as resolve7 } from "path";
1489
+ import { execFile } from "child_process";
1490
+ import { createReadStream } from "fs";
1491
+ import { lstat, readFile as readFile9, realpath as realpath2 } from "fs/promises";
1492
+ import { dirname as dirname4, join as join10, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
1493
+ import { promisify } from "util";
1494
+ import fg from "fast-glob";
1495
+ var execFileAsync = promisify(execFile);
1496
+ function object(value) {
1497
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1498
+ }
1499
+ function string(value) {
1500
+ return typeof value === "string" && value.length > 0;
1501
+ }
1502
+ function nonnegative(value) {
1503
+ return Number.isSafeInteger(value) && value >= 0;
1504
+ }
1505
+ function errors(value) {
1506
+ return Array.isArray(value) && value.every(string);
1507
+ }
1508
+ function findings(value) {
1509
+ return Array.isArray(value) && value.every((item) => object(item) && string(item.id) && string(item.severity) && string(item.subject) && string(item.message) && (item.remediation === void 0 || string(item.remediation)));
1510
+ }
1511
+ function base(value) {
1512
+ return object(value) && string(value.tool) && string(value.version) && typeof value.complete === "boolean" && errors(value.errors);
1513
+ }
1514
+ function validateApp(value) {
1515
+ if (!object(value) || !string(value.path) || !string(value.profile)) return false;
1516
+ const dependency = value.dependencySecurity;
1517
+ const quality = value.codeQuality;
1518
+ const security = value.codeSecurity;
1519
+ const compatibility2 = value.compatibility;
1520
+ if (!base(dependency) || !nonnegative(dependency.packagesScanned) || !findings(dependency.advisories)) return false;
1521
+ if (!object(quality) || !object(quality.lint) || !string(quality.lint.tool) || !string(quality.lint.version) || typeof quality.lint.complete !== "boolean" || !base(quality.typecheck) || !base(quality.tests) || !base(quality.coverage)) return false;
1522
+ if (!nonnegative(quality.lint.filesScanned) || !nonnegative(quality.lint.errorCount) || !nonnegative(quality.lint.warnings) || !errors(quality.lint.errorsDetail) || typeof quality.typecheck.passed !== "boolean" || quality.typecheck.applicable !== void 0 && typeof quality.typecheck.applicable !== "boolean" || !nonnegative(quality.tests.passed) || !nonnegative(quality.tests.failed) || typeof quality.coverage.linesPercent !== "number" || !Number.isFinite(quality.coverage.linesPercent) || quality.coverage.linesPercent < 0 || quality.coverage.linesPercent > 100) return false;
1523
+ if (!object(security) || !base(security.sast) || !base(security.secrets) || !nonnegative(security.sast.filesScanned) || !nonnegative(security.secrets.filesScanned) || !findings(security.sast.findings) || !findings(security.secrets.findings)) return false;
1524
+ if (!base(compatibility2) || !Array.isArray(compatibility2.projects) || !compatibility2.projects.every((project) => object(project) && string(project.name) && nonnegative(project.passed) && nonnegative(project.failed) && nonnegative(project.skipped) && (project.browserVersion === void 0 || string(project.browserVersion)) && (project.engine === void 0 || string(project.engine)) && (project.mobile === void 0 || typeof project.mobile === "boolean") && (project.device === void 0 || string(project.device)) && (project.emulatedOs === void 0 || string(project.emulatedOs)) && (project.emulatedOsVersion === void 0 || string(project.emulatedOsVersion)) && (project.viewportWidths === void 0 || Array.isArray(project.viewportWidths) && project.viewportWidths.every(nonnegative)))) return false;
1525
+ if (value.accessibility !== void 0) {
1526
+ const accessibility = value.accessibility;
1527
+ if (!base(accessibility) || !Array.isArray(accessibility.scans) || !accessibility.scans.every((scan) => object(scan) && string(scan.project) && string(scan.route) && /^\/[^?#]*$/.test(scan.route) && string(scan.axeVersion) && /^\d+\.\d+\.\d+$/.test(scan.axeVersion) && Array.isArray(scan.tags) && scan.tags.length > 0 && scan.tags.every(string) && new Set(scan.tags).size === scan.tags.length && nonnegative(scan.checks) && scan.checks > 0 && nonnegative(scan.needsReview) && findings(scan.violations))) return false;
1528
+ }
1529
+ return true;
1530
+ }
1531
+ async function hashFile(hash, path) {
1532
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
1533
+ }
1534
+ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
1535
+ const root = resolve7(scanRoot);
1536
+ const workspace = resolve7(workspaceRoot);
1537
+ const scanRelative = relative6(workspace, root);
1538
+ if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
1539
+ const paths = await fg("**/*", {
1540
+ cwd: root,
1541
+ dot: true,
1542
+ onlyFiles: true,
1543
+ followSymbolicLinks: false,
1544
+ ignore: [
1545
+ "**/.git/**",
1546
+ "**/node_modules/**",
1547
+ "**/dist/**",
1548
+ "**/build/**",
1549
+ "**/.next/**",
1550
+ "**/coverage/**",
1551
+ "**/playwright-report/**",
1552
+ "**/test-results/**",
1553
+ "**/.nklattice-governance/**",
1554
+ "**/.nklattice-report/**",
1555
+ "**/AGENTS.md",
1556
+ "**/*.tsbuildinfo",
1557
+ "**/next-env.d.ts"
1558
+ ]
1559
+ });
1560
+ paths.sort();
1561
+ const hash = createHash2("sha256");
1562
+ for (const path of paths) {
1563
+ const absolute = resolve7(root, path);
1564
+ const normalized = relative6(root, absolute);
1565
+ if (normalized === ".." || normalized.startsWith(`..${sep5}`)) throw new Error("Input path escapes scan root");
1566
+ if (!(await lstat(absolute)).isFile()) continue;
1567
+ hash.update(path.replaceAll("\\", "/"));
1568
+ hash.update("\0");
1569
+ await hashFile(hash, absolute);
1570
+ hash.update("\0");
1571
+ }
1572
+ let ancestor = root;
1573
+ while (ancestor === workspace || ancestor !== dirname4(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
1574
+ for (const name of [
1575
+ "package.json",
1576
+ "package-lock.json",
1577
+ "pnpm-lock.yaml",
1578
+ "pnpm-workspace.yaml",
1579
+ "tsconfig.json",
1580
+ "eslint.config.js",
1581
+ "eslint.config.mjs",
1582
+ "eslint.config.cjs",
1583
+ "eslint.config.ts",
1584
+ "eslint.config.mts",
1585
+ "vitest.config.js",
1586
+ "vitest.config.mjs",
1587
+ "vitest.config.ts",
1588
+ "vitest.config.mts",
1589
+ "playwright.config.js",
1590
+ "playwright.config.mjs",
1591
+ "playwright.config.ts",
1592
+ "playwright.config.mts",
1593
+ ".semgrep.yml",
1594
+ ".semgrep.yaml",
1595
+ "semgrep.yml",
1596
+ "semgrep.yaml"
1597
+ ]) {
1598
+ const path = join10(ancestor, name);
1599
+ try {
1600
+ const stat2 = await lstat(path);
1601
+ if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
1602
+ if (!stat2.isFile()) continue;
1603
+ hash.update(relative6(workspace, path).replaceAll("\\", "/"));
1604
+ hash.update("\0");
1605
+ await hashFile(hash, path);
1606
+ hash.update("\0");
1607
+ } catch (error) {
1608
+ if (error.code !== "ENOENT") throw error;
1609
+ }
1610
+ }
1611
+ if (ancestor === workspace) break;
1612
+ ancestor = dirname4(ancestor);
1613
+ }
1614
+ return hash.digest("hex");
1615
+ }
1616
+ async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
1617
+ const source = resolve7(path);
1618
+ const stat2 = await lstat(source);
1619
+ if (!stat2.isFile() || stat2.isSymbolicLink() || stat2.size > 1e7) throw new Error("Evidence must be a regular JSON file under 10 MB");
1620
+ const root = resolve7(scanRoot);
1621
+ const relativePath = relative6(root, source);
1622
+ if (relativePath !== ".." && !relativePath.startsWith(`..${sep5}`)) throw new Error("Evidence file must be outside the scan root");
1623
+ const actualRoot = await realpath2(root);
1624
+ const actualSource = await realpath2(source);
1625
+ const actualRelative = relative6(actualRoot, actualSource);
1626
+ if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
1627
+ let parsed;
1628
+ try {
1629
+ parsed = JSON.parse(await readFile9(source, "utf8"));
1630
+ } catch {
1631
+ throw new Error("Evidence is not valid JSON");
1632
+ }
1633
+ return validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours);
1634
+ }
1635
+ async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
1636
+ if (!object(parsed) || parsed.schemaVersion !== 1 || !string(parsed.generatedAt) || !string(parsed.policySha256) || !string(parsed.inputSha256) || !string(parsed.commit) || !/^[0-9a-f]{40}$/.test(parsed.commit) || !Array.isArray(parsed.apps) || !parsed.apps.every(validateApp)) throw new Error("Evidence schema is invalid or incomplete");
1637
+ if (parsed.policySha256 !== policySha256) throw new Error("Evidence was generated for another policy");
1638
+ const generatedAt = Date.parse(parsed.generatedAt);
1639
+ if (!Number.isFinite(generatedAt) || generatedAt > Date.now() + 6e4 || Date.now() - generatedAt > maximumAgeHours * 36e5) {
1640
+ throw new Error("Evidence is stale or has an invalid timestamp");
1641
+ }
1642
+ if (parsed.inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) throw new Error("Evidence inputs changed after scanning");
1643
+ const current = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot })).stdout.trim();
1644
+ if (!/^[0-9a-f]{40}$/.test(current) || parsed.commit !== current) throw new Error("Evidence commit differs from the checked-out commit");
1645
+ const appPaths = parsed.apps.map((app) => app.path);
1646
+ if (new Set(appPaths).size !== appPaths.length) throw new Error("Evidence contains duplicate app paths");
1647
+ return parsed;
1648
+ }
1649
+ var incomplete = (issues, tool, version2, scanned) => ({ status: "incomplete", issues, tool, version: version2, scanned });
1650
+ function numericVersion(value) {
1651
+ if (!value || !/^(0|[1-9]\d*)(?:\.(0|[1-9]\d*)){0,3}$/.test(value)) return void 0;
1652
+ const parts = value.split(".").map(Number);
1653
+ return parts.every(Number.isSafeInteger) ? parts : void 0;
1654
+ }
1655
+ function belowVersion(actual, minimum) {
1656
+ for (let index = 0; index < Math.max(actual.length, minimum.length); index++) {
1657
+ if ((actual[index] ?? 0) < (minimum[index] ?? 0)) return true;
1658
+ if ((actual[index] ?? 0) > (minimum[index] ?? 0)) return false;
1659
+ }
1660
+ return false;
1661
+ }
1662
+ function evaluateAppEvidence(app, policy) {
1663
+ if (!app) {
1664
+ const missing = incomplete(["No fresh scan evidence for this app"]);
1665
+ return {
1666
+ dependencySecurity: { ...missing },
1667
+ codeQuality: { ...missing },
1668
+ codeSecurity: { ...missing },
1669
+ compatibility: { ...missing },
1670
+ ...policy.accessibility ? { accessibility: { ...missing } } : {}
1671
+ };
1672
+ }
1673
+ const dependency = app.dependencySecurity;
1674
+ let dependencySecurity;
1675
+ if (!dependency.complete || dependency.packagesScanned === 0 || dependency.errors.length) {
1676
+ dependencySecurity = { ...incomplete(["Dependency advisory scan did not cover the lockfile", ...dependency.errors], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories };
1677
+ } else {
1678
+ const high2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "high").length;
1679
+ const critical2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "critical").length;
1680
+ const unknown = dependency.advisories.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
1681
+ dependencySecurity = unknown.length ? { ...incomplete([`${unknown.length} advisories have unknown severity`], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories } : {
1682
+ status: high2 > policy.dependencySecurity.maximumHigh || critical2 > policy.dependencySecurity.maximumCritical ? "fail" : "pass",
1683
+ issues: [
1684
+ ...high2 > policy.dependencySecurity.maximumHigh ? [`${high2} high advisories exceed ${policy.dependencySecurity.maximumHigh}`] : [],
1685
+ ...critical2 > policy.dependencySecurity.maximumCritical ? [`${critical2} critical advisories exceed ${policy.dependencySecurity.maximumCritical}`] : []
1686
+ ],
1687
+ tool: dependency.tool,
1688
+ version: dependency.version,
1689
+ scanned: dependency.packagesScanned,
1690
+ findings: dependency.advisories
1691
+ };
1692
+ }
1693
+ const quality = app.codeQuality;
1694
+ const qualityMissing = [
1695
+ ...!quality.lint.complete || quality.lint.filesScanned === 0 ? ["ESLint did not scan source files", ...quality.lint.errorsDetail] : [],
1696
+ ...policy.codeQuality.requireTypecheck && !quality.typecheck.complete ? ["TypeScript typecheck did not complete", ...quality.typecheck.errors] : [],
1697
+ ...policy.codeQuality.requireTests && (!quality.tests.complete || quality.tests.passed + quality.tests.failed === 0) ? ["Tests did not run", ...quality.tests.errors] : [],
1698
+ ...!quality.coverage.complete ? ["Line coverage is unavailable", ...quality.coverage.errors] : []
1699
+ ];
1700
+ const qualityFailures = [
1701
+ ...quality.lint.errorCount > policy.codeQuality.maximumLintErrors ? [`${quality.lint.errorCount} ESLint errors exceed ${policy.codeQuality.maximumLintErrors}`] : [],
1702
+ ...policy.codeQuality.requireTypecheck && !quality.typecheck.passed ? ["TypeScript typecheck failed"] : [],
1703
+ ...quality.tests.failed > 0 ? [`${quality.tests.failed} tests failed`] : [],
1704
+ ...quality.coverage.linesPercent < policy.codeQuality.minimumLineCoverage ? [`Line coverage ${quality.coverage.linesPercent}% is below ${policy.codeQuality.minimumLineCoverage}%`] : []
1705
+ ];
1706
+ const qualityVersion = `ESLint ${quality.lint.version} \xB7 TypeScript ${quality.typecheck.version} \xB7 Vitest ${quality.tests.version}`;
1707
+ const qualityMetrics = {
1708
+ lintErrors: quality.lint.errorCount,
1709
+ lintWarnings: quality.lint.warnings,
1710
+ typecheck: quality.typecheck.applicable === false ? "not applicable" : quality.typecheck.passed ? "pass" : "fail",
1711
+ testsPassed: quality.tests.passed,
1712
+ testsFailed: quality.tests.failed,
1713
+ lineCoveragePercent: quality.coverage.linesPercent
1714
+ };
1715
+ const codeQuality = qualityMissing.length ? { ...incomplete([...qualityMissing, ...qualityFailures], "ESLint / TypeScript / tests", qualityVersion, quality.lint.filesScanned), metrics: qualityMetrics } : {
1716
+ status: qualityFailures.length ? "fail" : "pass",
1717
+ issues: qualityFailures,
1718
+ tool: "ESLint / TypeScript / tests",
1719
+ version: qualityVersion,
1720
+ scanned: quality.lint.filesScanned,
1721
+ metrics: qualityMetrics
1722
+ };
1723
+ const security = app.codeSecurity;
1724
+ const securityMissing = [
1725
+ ...!security.sast.complete || security.sast.filesScanned === 0 ? ["SAST did not scan source files", ...security.sast.errors] : [],
1726
+ ...!security.secrets.complete || security.secrets.filesScanned === 0 ? ["Secret scanner did not scan source files", ...security.secrets.errors] : []
1727
+ ];
1728
+ const high = security.sast.findings.filter((item) => item.severity.toLowerCase() === "high").length;
1729
+ const critical = security.sast.findings.filter((item) => item.severity.toLowerCase() === "critical").length;
1730
+ const unknownSecurity = security.sast.findings.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
1731
+ if (unknownSecurity.length) securityMissing.push(`${unknownSecurity.length} SAST findings have unknown severity`);
1732
+ const securityFailures = [
1733
+ ...high > policy.codeSecurity.maximumHigh ? [`${high} high SAST findings exceed ${policy.codeSecurity.maximumHigh}`] : [],
1734
+ ...critical > policy.codeSecurity.maximumCritical ? [`${critical} critical SAST findings exceed ${policy.codeSecurity.maximumCritical}`] : [],
1735
+ ...security.secrets.findings.length > policy.codeSecurity.maximumSecrets ? [`${security.secrets.findings.length} detected secrets exceed ${policy.codeSecurity.maximumSecrets}`] : []
1736
+ ];
1737
+ const securityVersion = `Semgrep ${security.sast.version} \xB7 Gitleaks ${security.secrets.version}`;
1738
+ const codeSecurity = securityMissing.length ? { ...incomplete([...securityMissing, ...securityFailures], "Semgrep / Gitleaks", securityVersion, security.sast.filesScanned), findings: [...security.sast.findings, ...security.secrets.findings] } : {
1739
+ status: securityFailures.length ? "fail" : "pass",
1740
+ issues: securityFailures,
1741
+ tool: "Semgrep / Gitleaks",
1742
+ version: securityVersion,
1743
+ scanned: security.sast.filesScanned,
1744
+ findings: [...security.sast.findings, ...security.secrets.findings]
1745
+ };
1746
+ const compatibility2 = app.compatibility;
1747
+ const projectNames = new Set(compatibility2.projects.map((project) => project.name));
1748
+ const missingProjects = policy.compatibility.requiredProjects.filter((name) => !projectNames.has(name));
1749
+ const incompleteProjects = compatibility2.projects.filter((project) => project.passed + project.failed < policy.compatibility.minimumTestsPerProject);
1750
+ const missingBrowserVersions = compatibility2.projects.filter((project) => !project.browserVersion);
1751
+ const expectedEngines = {
1752
+ "chromium-desktop": "chromium",
1753
+ "firefox-desktop": "firefox",
1754
+ "webkit-desktop": "webkit",
1755
+ "android-chrome": "chromium",
1756
+ "iphone-safari": "webkit"
1757
+ };
1758
+ const invalidEngines = compatibility2.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
1759
+ const invalidDevices = compatibility2.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
1760
+ const missingViewports = compatibility2.projects.filter((project) => new Set(project.viewportWidths ?? []).size < 2);
1761
+ const versionEvidenceMissing = [];
1762
+ const versionFailures = [];
1763
+ for (const [name, minimum] of Object.entries(policy.compatibility.minimumTestedBrowserVersions ?? {})) {
1764
+ const project = compatibility2.projects.find((item) => item.name === name);
1765
+ if (!project) continue;
1766
+ const actual = numericVersion(project.browserVersion);
1767
+ if (!actual) versionEvidenceMissing.push(`${name} did not record a valid measured browser version`);
1768
+ else if (belowVersion(actual, numericVersion(minimum))) {
1769
+ versionFailures.push(`${name} measured browser version ${project.browserVersion} is below minimum tested browser version ${minimum}`);
1770
+ }
1771
+ }
1772
+ for (const [name, minimum] of Object.entries(policy.compatibility.minimumEmulatedOsVersions ?? {})) {
1773
+ const project = compatibility2.projects.find((item) => item.name === name);
1774
+ if (!project) continue;
1775
+ const expectedOs = name === "android-chrome" ? "Android" : "iOS";
1776
+ const actual = numericVersion(project.emulatedOsVersion);
1777
+ if (project.emulatedOs !== expectedOs || !actual) {
1778
+ versionEvidenceMissing.push(`${name} did not record a valid emulated ${expectedOs} version`);
1779
+ } else if (belowVersion(actual, numericVersion(minimum))) {
1780
+ versionFailures.push(`${name} emulated ${expectedOs} version ${project.emulatedOsVersion} is below minimum emulated ${expectedOs} version ${minimum}`);
1781
+ }
1782
+ }
1783
+ const compatibilityMissing = [
1784
+ ...!compatibility2.complete ? ["Compatibility test run did not complete", ...compatibility2.errors] : [],
1785
+ ...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
1786
+ ...incompleteProjects.map((project) => `${project.name} ran fewer than ${policy.compatibility.minimumTestsPerProject} tests`),
1787
+ ...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
1788
+ ...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
1789
+ ...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
1790
+ ...missingViewports.map((project) => `${project.name} did not test two viewport widths`),
1791
+ ...versionEvidenceMissing
1792
+ ];
1793
+ const failedProjects = compatibility2.projects.filter((project) => project.failed > 0);
1794
+ const testFailures = failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`);
1795
+ const matrix = compatibility2.projects.map((project) => ({
1796
+ ...project,
1797
+ minimumTestedBrowserVersion: policy.compatibility.minimumTestedBrowserVersions?.[project.name],
1798
+ minimumEmulatedOsVersion: policy.compatibility.minimumEmulatedOsVersions?.[project.name]
1799
+ }));
1800
+ const compatibilityDomain = compatibilityMissing.length ? { ...incomplete([...compatibilityMissing, ...testFailures, ...versionFailures], compatibility2.tool, compatibility2.version), matrix } : {
1801
+ status: testFailures.length || versionFailures.length ? "fail" : "pass",
1802
+ issues: [...testFailures, ...versionFailures],
1803
+ tool: compatibility2.tool,
1804
+ version: compatibility2.version,
1805
+ scanned: compatibility2.projects.reduce((count, project) => count + project.passed + project.failed, 0),
1806
+ matrix
1807
+ };
1808
+ let accessibilityDomain;
1809
+ if (policy.accessibility) {
1810
+ const rules = policy.accessibility;
1811
+ const result = app.accessibility;
1812
+ if (!result) accessibilityDomain = incomplete(["Accessibility scan evidence is missing"]);
1813
+ else {
1814
+ const missing = [...!result.complete || result.errors.length ? ["Accessibility scan did not complete", ...result.errors] : []];
1815
+ for (const project of rules.requiredProjects) {
1816
+ const scans = result.scans.filter((scan) => scan.project === project);
1817
+ if (scans.length < rules.minimumScansPerProject) missing.push(`${project} has fewer than ${rules.minimumScansPerProject} accessibility scans`);
1818
+ }
1819
+ for (const scan of result.scans) {
1820
+ if (scan.checks < rules.minimumChecksPerScan) missing.push(`${scan.project} ${scan.route} checked fewer than ${rules.minimumChecksPerScan} axe rules`);
1821
+ if (rules.requiredTags.some((tag) => !scan.tags.includes(tag))) missing.push(`${scan.project} ${scan.route} omitted required WCAG tags`);
1822
+ const measuredVersion2 = numericVersion(scan.axeVersion);
1823
+ if (!measuredVersion2) missing.push(`${scan.project} ${scan.route} did not record a valid axe-core version`);
1824
+ else if (belowVersion(measuredVersion2, numericVersion(rules.minimumAxeVersion))) {
1825
+ missing.push(`${scan.project} ${scan.route} used axe-core ${scan.axeVersion} below ${rules.minimumAxeVersion}`);
1826
+ }
1827
+ }
1828
+ const findings2 = result.scans.flatMap((scan) => scan.violations);
1829
+ const needsReview = result.scans.reduce((sum, scan) => sum + scan.needsReview, 0);
1830
+ if (needsReview > rules.maximumNeedsReview) missing.push(`${needsReview} axe checks need manual review (maximum ${rules.maximumNeedsReview})`);
1831
+ const failures = findings2.length > rules.maximumViolations ? [`${findings2.length} accessibility violations exceed ${rules.maximumViolations}`] : [];
1832
+ const details = {
1833
+ tool: result.tool,
1834
+ version: result.version,
1835
+ scanned: result.scans.length,
1836
+ findings: findings2,
1837
+ metrics: { scans: result.scans.length, axeChecks: result.scans.reduce((sum, scan) => sum + scan.checks, 0), needsReview },
1838
+ routes: result.scans.map((scan) => ({
1839
+ project: scan.project,
1840
+ route: scan.route,
1841
+ axeVersion: scan.axeVersion,
1842
+ checks: scan.checks,
1843
+ needsReview: scan.needsReview
1844
+ }))
1845
+ };
1846
+ accessibilityDomain = failures.length ? { status: "fail", issues: [...failures, ...missing], ...details } : missing.length ? { ...incomplete(missing, result.tool, result.version, result.scans.length), ...details } : { status: "pass", issues: [], ...details };
1847
+ }
1848
+ }
1849
+ return {
1850
+ dependencySecurity,
1851
+ codeQuality,
1852
+ codeSecurity,
1853
+ compatibility: compatibilityDomain,
1854
+ ...accessibilityDomain ? { accessibility: accessibilityDomain } : {}
1855
+ };
1856
+ }
1857
+
1858
+ // src/governance/policy.ts
1859
+ import { createHash as createHash3 } from "crypto";
1860
+ import { readFile as readFile10 } from "fs/promises";
1861
+ import { resolve as resolve8 } from "path";
1464
1862
  import { valid as valid2 } from "semver";
1465
1863
  var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
1466
1864
  var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
1865
+ var measuredVersion = /^(0|[1-9]\d*)(?:\.(0|[1-9]\d*)){0,3}$/;
1467
1866
  function record3(value) {
1468
1867
  return typeof value === "object" && value !== null && !Array.isArray(value);
1469
1868
  }
@@ -1502,12 +1901,106 @@ function parseAssessmentPolicy(value) {
1502
1901
  for (const id of architecture.blockedRuleIds) {
1503
1902
  if (!known.has(id)) throw new Error(`Unknown blocked rule ID ${id}`);
1504
1903
  }
1904
+ let evidence;
1905
+ if (value.evidence !== void 0) {
1906
+ if (!record3(value.evidence)) throw new Error("assessment.evidence must be an object");
1907
+ const gates = value.evidence;
1908
+ const nonnegative2 = (source, key, label) => {
1909
+ const result = source[key];
1910
+ if (!Number.isSafeInteger(result) || result < 0) throw new Error(`${label}.${key} must be a nonnegative integer`);
1911
+ return result;
1912
+ };
1913
+ if (!Number.isFinite(gates.maximumAgeHours) || gates.maximumAgeHours <= 0 || gates.maximumAgeHours > 168) {
1914
+ throw new Error("assessment.evidence.maximumAgeHours must be greater than 0 and at most 168");
1915
+ }
1916
+ for (const domain of ["dependencySecurity", "codeQuality", "codeSecurity", "compatibility"]) {
1917
+ if (!record3(gates[domain])) throw new Error(`assessment.evidence.${domain} is required`);
1918
+ }
1919
+ const dependencySecurity = gates.dependencySecurity;
1920
+ const codeQuality = gates.codeQuality;
1921
+ const codeSecurity = gates.codeSecurity;
1922
+ const compatibility2 = gates.compatibility;
1923
+ const minimumLineCoverage = codeQuality.minimumLineCoverage;
1924
+ if (typeof minimumLineCoverage !== "number" || !Number.isFinite(minimumLineCoverage) || minimumLineCoverage < 0 || minimumLineCoverage > 100) {
1925
+ throw new Error("assessment.evidence.codeQuality.minimumLineCoverage must be 0\u2013100");
1926
+ }
1927
+ if (typeof codeQuality.requireTypecheck !== "boolean" || typeof codeQuality.requireTests !== "boolean") {
1928
+ throw new Error("assessment.evidence.codeQuality requires boolean requireTypecheck and requireTests");
1929
+ }
1930
+ const projects = compatibility2.requiredProjects;
1931
+ if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
1932
+ throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
1933
+ }
1934
+ const versionFloors = (key) => {
1935
+ const raw = compatibility2[key];
1936
+ if (raw === void 0) return void 0;
1937
+ if (!record3(raw)) throw new Error(`assessment.evidence.compatibility.${key} must be an object`);
1938
+ for (const [project, version2] of Object.entries(raw)) {
1939
+ if (!projects.includes(project)) throw new Error(`${key} references unknown required project ${project}`);
1940
+ if (key === "minimumEmulatedOsVersions" && project !== "android-chrome" && project !== "iphone-safari") {
1941
+ throw new Error(`${key} requires an Android Chrome or iPhone Safari project`);
1942
+ }
1943
+ if (typeof version2 !== "string" || !measuredVersion.test(version2) || !version2.split(".").map(Number).every(Number.isSafeInteger)) {
1944
+ throw new Error(`${key}.${project} must be a numeric version with one to four components`);
1945
+ }
1946
+ }
1947
+ return raw;
1948
+ };
1949
+ const minimumTestedBrowserVersions = versionFloors("minimumTestedBrowserVersions");
1950
+ const minimumEmulatedOsVersions = versionFloors("minimumEmulatedOsVersions");
1951
+ let accessibility;
1952
+ if (gates.accessibility !== void 0) {
1953
+ if (!record3(gates.accessibility)) throw new Error("assessment.evidence.accessibility must be an object");
1954
+ const source = gates.accessibility;
1955
+ const requiredProjects = source.requiredProjects;
1956
+ if (!Array.isArray(requiredProjects) || requiredProjects.length === 0 || !requiredProjects.every((project) => typeof project === "string" && projects.includes(project)) || new Set(requiredProjects).size !== requiredProjects.length) {
1957
+ throw new Error("assessment.evidence.accessibility projects must be unique members of compatibility.requiredProjects");
1958
+ }
1959
+ const requiredTags = source.requiredTags;
1960
+ if (!Array.isArray(requiredTags) || requiredTags.length === 0 || !requiredTags.every((tag) => typeof tag === "string" && /^wcag(?:2|21|22)(?:a|aa)$/.test(tag)) || new Set(requiredTags).size !== requiredTags.length) {
1961
+ throw new Error("assessment.evidence.accessibility.requiredTags must list unique supported WCAG A/AA tags");
1962
+ }
1963
+ if (typeof source.minimumAxeVersion !== "string" || !minimumVersion.test(`>=${source.minimumAxeVersion}`) || valid2(source.minimumAxeVersion) === null) {
1964
+ throw new Error("assessment.evidence.accessibility.minimumAxeVersion must be an exact stable version");
1965
+ }
1966
+ accessibility = {
1967
+ requiredProjects,
1968
+ requiredTags,
1969
+ minimumScansPerProject: nonnegative2(source, "minimumScansPerProject", "assessment.evidence.accessibility"),
1970
+ minimumChecksPerScan: nonnegative2(source, "minimumChecksPerScan", "assessment.evidence.accessibility"),
1971
+ minimumAxeVersion: source.minimumAxeVersion,
1972
+ maximumViolations: nonnegative2(source, "maximumViolations", "assessment.evidence.accessibility"),
1973
+ maximumNeedsReview: nonnegative2(source, "maximumNeedsReview", "assessment.evidence.accessibility")
1974
+ };
1975
+ if (accessibility.minimumScansPerProject === 0 || accessibility.minimumChecksPerScan === 0) {
1976
+ throw new Error("assessment.evidence.accessibility minimum scans and checks must be positive");
1977
+ }
1978
+ }
1979
+ evidence = {
1980
+ maximumAgeHours: gates.maximumAgeHours,
1981
+ dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
1982
+ codeQuality: {
1983
+ maximumLintErrors: nonnegative2(codeQuality, "maximumLintErrors", "assessment.evidence.codeQuality"),
1984
+ minimumLineCoverage,
1985
+ requireTypecheck: codeQuality.requireTypecheck,
1986
+ requireTests: codeQuality.requireTests
1987
+ },
1988
+ codeSecurity: { maximumHigh: nonnegative2(codeSecurity, "maximumHigh", "assessment.evidence.codeSecurity"), maximumCritical: nonnegative2(codeSecurity, "maximumCritical", "assessment.evidence.codeSecurity"), maximumSecrets: nonnegative2(codeSecurity, "maximumSecrets", "assessment.evidence.codeSecurity") },
1989
+ compatibility: {
1990
+ requiredProjects: projects,
1991
+ minimumTestsPerProject: nonnegative2(compatibility2, "minimumTestsPerProject", "assessment.evidence.compatibility"),
1992
+ ...minimumTestedBrowserVersions ? { minimumTestedBrowserVersions } : {},
1993
+ ...minimumEmulatedOsVersions ? { minimumEmulatedOsVersions } : {}
1994
+ },
1995
+ ...accessibility ? { accessibility } : {}
1996
+ };
1997
+ }
1505
1998
  return { architecture: {
1506
1999
  minimumScore: architecture.minimumScore,
1507
2000
  maximumErrors: architecture.maximumErrors,
1508
2001
  blockedRuleIds: architecture.blockedRuleIds,
1509
2002
  analysis
1510
- } };
2003
+ }, ...evidence ? { evidence } : {} };
1511
2004
  }
1512
2005
  function parsePolicy(raw, source) {
1513
2006
  let parsed;
@@ -1531,21 +2024,36 @@ function parsePolicy(raw, source) {
1531
2024
  throw new Error(`Policy profile ${name} must list packages`);
1532
2025
  }
1533
2026
  const versions = {};
1534
- for (const [packageName, version] of Object.entries(rules)) {
1535
- if (typeof version !== "string" || !minimumVersion.test(version) || valid2(version.slice(2)) === null) {
2027
+ for (const [packageName, version2] of Object.entries(rules)) {
2028
+ if (typeof version2 !== "string" || !minimumVersion.test(version2) || valid2(version2.slice(2)) === null) {
1536
2029
  throw new Error(`Policy minimum version (>=X.Y.Z) required for ${packageName} in ${name}`);
1537
2030
  }
1538
- versions[packageName] = version;
2031
+ versions[packageName] = version2;
1539
2032
  }
1540
2033
  profiles3[name] = versions;
1541
2034
  }
2035
+ let managedPackages;
2036
+ if (parsed.managedPackages !== void 0) {
2037
+ if (!record3(parsed.managedPackages)) throw new Error("Policy managedPackages must be an object");
2038
+ managedPackages = {};
2039
+ for (const [packageName, version2] of Object.entries(parsed.managedPackages)) {
2040
+ if (typeof version2 !== "string" || !minimumVersion.test(version2) || valid2(version2.slice(2)) === null) {
2041
+ throw new Error(`Policy minimum version (>=X.Y.Z) required for managed package ${packageName}`);
2042
+ }
2043
+ if (Object.values(profiles3).some((profile) => packageName in profile)) {
2044
+ throw new Error(`Package ${packageName} appears in both profiles and managedPackages`);
2045
+ }
2046
+ managedPackages[packageName] = version2;
2047
+ }
2048
+ }
1542
2049
  return {
1543
2050
  schemaVersion: 2,
1544
2051
  policyVersion: parsed.policyVersion,
1545
2052
  profiles: profiles3,
2053
+ ...managedPackages ? { managedPackages } : {},
1546
2054
  ...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
1547
2055
  source,
1548
- sha256: createHash2("sha256").update(raw).digest("hex")
2056
+ sha256: createHash3("sha256").update(raw).digest("hex")
1549
2057
  };
1550
2058
  }
1551
2059
  async function loadPolicy(source) {
@@ -1562,7 +2070,7 @@ async function loadPolicy(source) {
1562
2070
  }
1563
2071
  return parsePolicy(await response.text(), source);
1564
2072
  }
1565
- return parsePolicy(await readFile8(resolve7(source), "utf8"), source);
2073
+ return parsePolicy(await readFile10(resolve8(source), "utf8"), source);
1566
2074
  }
1567
2075
 
1568
2076
  // src/governance/assess.ts
@@ -1585,27 +2093,52 @@ function escapeHtml2(value) {
1585
2093
  return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1586
2094
  }
1587
2095
  function renderAssessmentHtml(report) {
2096
+ const labels = {
2097
+ inventory: "App inventory",
2098
+ dependencies: "Dependency policy",
2099
+ architecture: "Architecture",
2100
+ dependencySecurity: "Dependency security",
2101
+ codeQuality: "Code quality",
2102
+ codeSecurity: "Code security",
2103
+ compatibility: "Browser compatibility",
2104
+ accessibility: "Accessibility"
2105
+ };
2106
+ const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
2107
+ const overview = domainNames.map((name) => {
2108
+ const statuses = report.apps.map((app) => app.domains[name]?.status ?? "incomplete");
2109
+ const status = statusOf(statuses);
2110
+ const passed = statuses.filter((item) => item === "pass").length;
2111
+ return `<li><span>${escapeHtml2(labels[name] ?? name)}</span><strong class="${status}">${escapeHtml2(status)}</strong><small>${passed}/${report.apps.length} apps passed</small></li>`;
2112
+ }).join("");
1588
2113
  const apps = report.apps.map((app) => {
1589
- const domains = Object.entries(app.domains).map(([name, domain]) => `<tr><th scope="row">${escapeHtml2(name)}</th><td class="${domain.status}">${escapeHtml2(domain.status)}</td><td>${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : "No issues"}</td></tr>`).join("");
2114
+ const domains = Object.entries(app.domains).map(([name, domain]) => {
2115
+ const evidence = domain;
2116
+ const identity = evidence.tool ? `<p><small>${escapeHtml2(evidence.tool)} ${escapeHtml2(evidence.version ?? "")} \xB7 scanned ${escapeHtml2(evidence.scanned ?? "unknown")}</small></p>` : "";
2117
+ const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml2(key)}: ${escapeHtml2(value)}`).join(" \xB7 ")}</small></p>` : "";
2118
+ const findings3 = evidence.findings?.length ? `<details><summary>${evidence.findings.length} finding(s)</summary><ul>${evidence.findings.map((item) => `<li><strong>${escapeHtml2(item.severity)}</strong> ${escapeHtml2(item.id)} \xB7 <code>${escapeHtml2(item.subject)}</code> \u2014 ${escapeHtml2(item.message)}${item.remediation ? ` \xB7 ${escapeHtml2(item.remediation)}` : ""}</li>`).join("")}</ul></details>` : "";
2119
+ const matrix = evidence.matrix?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>Measured browser / minimum test environment</th><th>Mobile UA emulation / minimum</th><th>Widths</th></tr></thead><tbody>${evidence.matrix.map((item) => `<tr><th scope="row">${escapeHtml2(item.name)}</th><td>${item.passed}</td><td>${item.failed}</td><td>${item.skipped}</td><td>${escapeHtml2(item.browserVersion ?? "Not recorded")} / ${escapeHtml2(item.minimumTestedBrowserVersion ?? "Not set")}</td><td>${escapeHtml2(item.device ?? "None")} / ${escapeHtml2(item.minimumEmulatedOsVersion ?? "Not set")}</td><td>${escapeHtml2(item.viewportWidths?.join(", ") ?? "Not recorded")}</td></tr>`).join("")}</tbody></table><p><small>Version floors check the tested environment. Mobile OS values come from user-agent emulation, not physical devices or a promise of support for older versions.</small></p></div>` : "";
2120
+ const routes = evidence.routes?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Scanned route</th><th>axe-core</th><th>Checks</th><th>Needs review</th></tr></thead><tbody>${evidence.routes.map((item) => `<tr><th scope="row">${escapeHtml2(item.project)}</th><td><code>${escapeHtml2(item.route)}</code></td><td>${escapeHtml2(item.axeVersion)}</td><td>${item.checks}</td><td>${item.needsReview}</td></tr>`).join("")}</tbody></table><p><small>Only these visited routes were scanned automatically. Manual accessibility review remains outside this result.</small></p></div>` : "";
2121
+ return `<tr><th scope="row">${escapeHtml2(labels[name] ?? name)}</th><td class="${domain.status}">${escapeHtml2(domain.status)}</td><td>${identity}${metrics}${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : "No issues"}${findings3}${matrix}${routes}</td></tr>`;
2122
+ }).join("");
1590
2123
  const architecture = app.domains.architecture;
1591
2124
  const categoryRows = architecture.scoreBreakdown?.categories.map((category) => `<tr><th scope="row">${escapeHtml2(category.id)}</th><td>${escapeHtml2(category.weight)}%</td><td>${escapeHtml2(category.score)}</td><td>${escapeHtml2(category.findingCount)}</td></tr>`).join("") ?? "";
1592
- const findings = architecture.findings.map((finding) => `<tr><td>${escapeHtml2(finding.severity)}</td><td>${escapeHtml2(finding.ruleId)}</td><td><code>${escapeHtml2(finding.file)}:${finding.line}</code></td><td>${escapeHtml2(finding.evidence)}</td></tr>`).join("");
1593
- return `<section class="app"><h2>${escapeHtml2(app.path)} <span class="${app.status}">${escapeHtml2(app.status)}</span></h2><p>Profile: ${escapeHtml2(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${findings ? `<details><summary>Architecture findings (${architecture.findings.length})</summary><div class="scroll"><table><thead><tr><th>Severity</th><th>Rule</th><th>Location</th><th>Evidence</th></tr></thead><tbody>${findings}</tbody></table></div></details>` : ""}</section>`;
2125
+ const findings2 = architecture.findings.map((finding) => `<tr><td>${escapeHtml2(finding.severity)}</td><td>${escapeHtml2(finding.ruleId)}</td><td><code>${escapeHtml2(finding.file)}:${finding.line}</code></td><td>${escapeHtml2(finding.evidence)}</td></tr>`).join("");
2126
+ return `<section class="app"><h2>${escapeHtml2(app.path)} <span class="${app.status}">${escapeHtml2(app.status)}</span></h2><p>Profile: ${escapeHtml2(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${findings2 ? `<details><summary>Architecture findings (${architecture.findings.length})</summary><div class="scroll"><table><thead><tr><th>Severity</th><th>Rule</th><th>Location</th><th>Evidence</th></tr></thead><tbody>${findings2}</tbody></table></div></details>` : ""}</section>`;
1594
2127
  }).join("");
1595
2128
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>NKLattice governance assessment</title><style>
1596
- :root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#f6f7f5;color:#1b2925}body{max-width:1100px;margin:auto;padding:2rem 1rem}header,.app,.meta{background:white;border:1px solid #d9e1dc;border-radius:12px;padding:1.4rem;margin:0 0 1.2rem}h1{font-size:clamp(2rem,5vw,3.5rem);line-height:1.08;margin:.3rem 0 1rem}h2{font-size:1.35rem;margin:0 0 .75rem}p{margin:.5rem 0}code{overflow-wrap:anywhere}.pass{color:#146c43;font-weight:700}.fail{color:#b3242b;font-weight:700}.incomplete{color:#8a5500;font-weight:700}.scroll{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d9e1dc;padding:.8rem}th:first-child{width:9rem}ul{padding-left:1.2rem;margin:.1rem 0}li+li{margin-top:.3rem}small{color:#50645c}details{border-top:1px solid #d9e1dc;margin-top:1rem;padding-top:1rem}summary{cursor:pointer;font-weight:650}details[open] summary{margin-bottom:.6rem}
1597
- </style></head><body><header><small>NKLATTICE \xB7 CONFIGURED POLICY ASSESSMENT</small><h1>Project governance <span class="${report.status}">${escapeHtml2(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s)</p></header><section class="meta"><h2>Evidence identity</h2><p>Policy ${escapeHtml2(report.policy.version)} \xB7 <code>${escapeHtml2(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml2(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml2(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml2(report.inventory.source)}</code></p>${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml2).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml2).join(", ")}.</p></section></body></html>
2129
+ :root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#f3f6f4;color:#1b2925}body{max-width:1160px;margin:auto;padding:2rem 1rem}header,.app,.meta,.overview{background:white;border:1px solid #d9e1dc;border-radius:12px;padding:1.4rem;margin:0 0 1.2rem}header{background:#13231d;color:#f5f8f4;padding:2rem}header small{color:#9de2bc;font-weight:700;letter-spacing:.12em}header p{color:#c8d6ce}h1{font-size:clamp(2rem,5vw,3.5rem);line-height:1.08;margin:.3rem 0 1rem}h2{font-size:1.35rem;margin:0 0 .75rem}p{margin:.5rem 0}code{overflow-wrap:anywhere}.pass{color:#146c43;font-weight:700}.fail{color:#b3242b;font-weight:700}.incomplete{color:#8a5500;font-weight:700}header .pass{color:#9de2bc}.scroll{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d9e1dc;padding:.8rem}th:first-child{width:12rem}ul{padding-left:1.2rem;margin:.1rem 0}li+li{margin-top:.3rem}small{color:#50645c}details{border-top:1px solid #d9e1dc;margin-top:1rem;padding-top:1rem}summary{cursor:pointer;font-weight:650}details[open] summary{margin-bottom:.6rem}.domain-overview{list-style:none;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.65rem;padding:0;margin:1rem 0 0}.domain-overview li{border:1px solid #d9e1dc;background:#f8faf8;border-radius:8px;padding:.9rem;display:grid;grid-template-columns:1fr auto;gap:.35rem}.domain-overview li small{grid-column:1/-1}.domain-overview li+li{margin:0}
2130
+ @media(max-width:800px){.domain-overview{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:480px){.domain-overview{grid-template-columns:1fr}}</style></head><body><header><small>NKLATTICE \xB7 CONFIGURED POLICY ASSESSMENT</small><h1>Project governance <span class="${report.status}">${escapeHtml2(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s) \xB7 policy ${escapeHtml2(report.policy.version)}</p></header><section class="overview"><h2>Assessment by domain</h2><p>Each status is based on the checked apps and fresh scanner evidence. Open an app below for findings and browser versions.</p><ul class="domain-overview">${overview}</ul></section><section class="meta"><h2>Evidence identity</h2><p>Policy ${escapeHtml2(report.policy.version)} \xB7 <code>${escapeHtml2(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml2(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml2(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml2(basename2(report.inventory.source))}</code></p>${report.evidence ? `<p>Scanners: ${escapeHtml2(report.evidence.generatedAt)} \xB7 commit <code>${escapeHtml2(report.evidence.commit)}</code> \xB7 input <code>${escapeHtml2(report.evidence.inputSha256)}</code></p>` : "<p>Scanner evidence unavailable.</p>"}${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml2).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml2).join(", ")}.</p></section></body></html>
1598
2131
  `;
1599
2132
  }
1600
2133
  async function assessGovernance(options) {
1601
- const scanRoot = resolve8(options.scanRoot);
1602
- const workspaceRoot = resolve8(options.workspaceRoot ?? scanRoot);
1603
- const scanRelative = relative6(workspaceRoot, scanRoot);
1604
- if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
2134
+ const scanRoot = resolve9(options.scanRoot);
2135
+ const workspaceRoot = resolve9(options.workspaceRoot ?? scanRoot);
2136
+ const scanRelative = relative7(workspaceRoot, scanRoot);
2137
+ if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
1605
2138
  if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
1606
2139
  const assessment = parseAssessmentPolicy(options.policy.assessment);
1607
- const outputDirectory = resolve8(options.outputDirectory ?? join9(scanRoot, ".nklattice-governance"));
1608
- const inventorySource = resolve8(options.inventory ?? join9(scanRoot, ".nklattice-apps.json"));
2140
+ const outputDirectory = resolve9(options.outputDirectory ?? join11(scanRoot, ".nklattice-governance"));
2141
+ const inventorySource = resolve9(options.inventory ?? join11(scanRoot, ".nklattice-apps.json"));
1609
2142
  let inventory;
1610
2143
  const issues = [];
1611
2144
  try {
@@ -1624,9 +2157,22 @@ async function assessGovernance(options) {
1624
2157
  if (options.expectedApps !== void 0 && apps.length !== options.expectedApps) {
1625
2158
  issues.push(`Expected ${options.expectedApps} managed apps; discovered ${apps.length}`);
1626
2159
  }
2160
+ let evidence;
2161
+ if (assessment.evidence) {
2162
+ if (!options.evidence && !options.trustedEvidence) issues.push("Scan evidence is required by the policy; run governance collect and pass --evidence");
2163
+ else try {
2164
+ evidence = options.trustedEvidence ? await validateScanEvidence(options.trustedEvidence, scanRoot, workspaceRoot, options.policy.sha256, assessment.evidence.maximumAgeHours) : await loadScanEvidence(options.evidence, scanRoot, workspaceRoot, options.policy.sha256, assessment.evidence.maximumAgeHours);
2165
+ } catch (error) {
2166
+ issues.push(`Scan evidence: ${error instanceof Error ? error.message : String(error)}`);
2167
+ }
2168
+ if (evidence) {
2169
+ const discovered = new Set(apps.map((app) => relative7(scanRoot, app.directory).replaceAll("\\", "/") || "."));
2170
+ for (const item of evidence.apps) if (!discovered.has(item.path)) issues.push(`Scan evidence contains unrecognized app ${item.path}`);
2171
+ }
2172
+ }
1627
2173
  const results = [];
1628
2174
  for (const app of apps) {
1629
- const appPath = relative6(scanRoot, app.directory).replaceAll("\\", "/") || ".";
2175
+ const appPath = relative7(scanRoot, app.directory).replaceAll("\\", "/") || ".";
1630
2176
  const inventoryIssues = issues.filter((issue) => issue.includes(appPath) || issue.startsWith("Inventory:") || issue.startsWith("Discovery:"));
1631
2177
  const inventoryDomain = { status: inventoryIssues.length ? "incomplete" : "pass", issues: inventoryIssues };
1632
2178
  const dependencyDomain = { status: "pass", issues: [], findings: [] };
@@ -1679,7 +2225,8 @@ async function assessGovernance(options) {
1679
2225
  architectureDomain.status = "incomplete";
1680
2226
  architectureDomain.issues.push(error instanceof Error ? error.message : String(error));
1681
2227
  }
1682
- const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain };
2228
+ const extra = assessment.evidence ? evaluateAppEvidence(evidence?.apps.find((item) => item.path === appPath && item.profile === app.profile), assessment.evidence) : {};
2229
+ const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain, ...extra };
1683
2230
  results.push({ path: appPath, profile: app.profile, status: statusOf(Object.values(domains).map((domain) => domain.status)), domains });
1684
2231
  }
1685
2232
  const report = {
@@ -1687,21 +2234,612 @@ async function assessGovernance(options) {
1687
2234
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1688
2235
  status: issues.length ? "incomplete" : statusOf(results.map((app) => app.status)),
1689
2236
  policy: { version: options.policy.policyVersion, source: options.policy.source, sha256: options.policy.sha256 },
2237
+ ...evidence ? { evidence: { source: options.evidence ? resolve9(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
1690
2238
  inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
1691
- scope: { domains: ["explicit app inventory", "dependency minimums", "source architecture"], notEvaluated },
2239
+ scope: {
2240
+ domains: [
2241
+ "explicit app inventory",
2242
+ "dependency minimums",
2243
+ ...options.policy.managedPackages ? ["conditional managed-library minimums"] : [],
2244
+ "source architecture",
2245
+ ...assessment.evidence ? [
2246
+ "known dependency advisories",
2247
+ "source quality",
2248
+ "source security",
2249
+ "browser and mobile emulation",
2250
+ "tested browser and emulated OS version floors",
2251
+ ...assessment.evidence.accessibility ? ["automated accessibility"] : []
2252
+ ] : []
2253
+ ],
2254
+ notEvaluated: assessment.evidence ? [
2255
+ "licenses",
2256
+ "physical Android/iOS devices",
2257
+ "older browser-version support",
2258
+ "runtime performance",
2259
+ ...assessment.evidence.accessibility ? ["manual accessibility review and unvisited routes"] : ["accessibility"],
2260
+ "cryptographic evidence attestation",
2261
+ "branch protection",
2262
+ "repository access controls",
2263
+ "cross-repository rollout status"
2264
+ ] : notEvaluated
2265
+ },
1692
2266
  issues,
1693
2267
  apps: results
1694
2268
  };
1695
2269
  await mkdir3(outputDirectory, { recursive: true });
1696
- await writeFile2(join9(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
2270
+ await writeFile2(join11(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
1697
2271
  `);
1698
- await writeFile2(join9(outputDirectory, "report.html"), renderAssessmentHtml(report));
2272
+ await writeFile2(join11(outputDirectory, "report.html"), renderAssessmentHtml(report));
2273
+ return report;
2274
+ }
2275
+
2276
+ // src/governance/collect.ts
2277
+ import { execFile as execFile2 } from "child_process";
2278
+ import { createHash as createHash4 } from "crypto";
2279
+ import { mkdtemp, mkdir as mkdir4, readFile as readFile11, rm, writeFile as writeFile3 } from "fs/promises";
2280
+ import { tmpdir } from "os";
2281
+ import { dirname as dirname5, join as join12, relative as relative8, resolve as resolve10, sep as sep7 } from "path";
2282
+ import { promisify as promisify2 } from "util";
2283
+ import fg2 from "fast-glob";
2284
+ import YAML2 from "yaml";
2285
+ var execFileAsync2 = promisify2(execFile2);
2286
+ var cap = 16 * 1024 * 1024;
2287
+ async function run(file, args, cwd, env) {
2288
+ try {
2289
+ const result = await execFileAsync2(file, args, { cwd, env, timeout: 3e5, maxBuffer: cap });
2290
+ return { code: 0, stdout: result.stdout, stderr: result.stderr };
2291
+ } catch (error) {
2292
+ const failure = error;
2293
+ return {
2294
+ code: typeof failure.code === "number" ? failure.code : -1,
2295
+ stdout: failure.stdout ?? "",
2296
+ stderr: failure.stderr ?? "",
2297
+ ...typeof failure.code === "number" ? {} : { error: failure.killed ? "Scanner timed out" : failure.message }
2298
+ };
2299
+ }
2300
+ }
2301
+ function inside(parent, child) {
2302
+ const path = relative8(parent, child);
2303
+ return path === "" || path !== ".." && !path.startsWith(`..${sep7}`);
2304
+ }
2305
+ async function binary(name, app, workspaceRoot) {
2306
+ let directory = app;
2307
+ while (inside(workspaceRoot, directory)) {
2308
+ const candidate = join12(directory, "node_modules", ".bin", name);
2309
+ try {
2310
+ await readFile11(candidate);
2311
+ return candidate;
2312
+ } catch {
2313
+ }
2314
+ if (directory === workspaceRoot) break;
2315
+ directory = dirname5(directory);
2316
+ }
2317
+ return name;
2318
+ }
2319
+ async function version(file, cwd) {
2320
+ const result = await run(file, ["--version"], cwd);
2321
+ if (result.code !== 0) return "unavailable";
2322
+ const reported = result.stdout.trim().slice(0, 120) || "unknown";
2323
+ if (file === "gitleaks" && reported.includes("version is set by build process")) {
2324
+ const located = await run("which", [file], cwd);
2325
+ if (located.code === 0) {
2326
+ const build = await run("go", ["version", "-m", located.stdout.trim()], cwd);
2327
+ const match = build.stdout.match(/^\s*mod\s+\S+\/gitleaks\/v8\s+(v\d+\.\d+\.\d+)/m);
2328
+ if (match) return match[1];
2329
+ }
2330
+ }
2331
+ return reported;
2332
+ }
2333
+ function json(text) {
2334
+ try {
2335
+ return JSON.parse(text);
2336
+ } catch {
2337
+ return void 0;
2338
+ }
2339
+ }
2340
+ function obj(value) {
2341
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2342
+ }
2343
+ function str(value) {
2344
+ return typeof value === "string" ? value : "";
2345
+ }
2346
+ function num(value) {
2347
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
2348
+ }
2349
+ async function sourcePaths(app) {
2350
+ return fg2("**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}", {
2351
+ cwd: app,
2352
+ dot: true,
2353
+ onlyFiles: true,
2354
+ followSymbolicLinks: false,
2355
+ ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/build/**", "**/coverage/**", "**/*.d.ts"]
2356
+ });
2357
+ }
2358
+ async function sourceCount(app) {
2359
+ return (await sourcePaths(app)).length;
2360
+ }
2361
+ async function productionSources(app) {
2362
+ return (await sourcePaths(app)).filter((path) => !/(^|\/)(e2e|tests?|__tests__|fixtures)\//.test(path) && !/\.(test|spec|config)\.[cm]?[jt]sx?$/.test(path));
2363
+ }
2364
+ async function dependencyAudit(app, workspaceRoot, cache) {
2365
+ const failure = (errors2, tool = "npm/pnpm audit") => ({ tool, version: "unavailable", complete: false, packagesScanned: 0, advisories: [], errors: errors2 });
2366
+ let lock;
2367
+ try {
2368
+ lock = await findLockfile(app.directory, workspaceRoot);
2369
+ } catch (error) {
2370
+ return failure([error instanceof Error ? error.message : String(error)]);
2371
+ }
2372
+ const cached = cache.get(lock.path);
2373
+ if (cached) return cached;
2374
+ const raw = await readFile11(lock.path, "utf8");
2375
+ const parsedLock = lock.kind === "npm" ? json(raw) : YAML2.parse(raw);
2376
+ const packages = obj(parsedLock) && obj(parsedLock.packages) ? Object.keys(parsedLock.packages).filter((key) => key !== "").length : 0;
2377
+ const executable = lock.kind === "npm" ? "npm" : "pnpm";
2378
+ const toolVersion = await version(executable, lock.root);
2379
+ const result = await run(executable, ["audit", "--json"], lock.root);
2380
+ const report = json(result.stdout);
2381
+ if (!obj(report)) {
2382
+ const evidence2 = { ...failure([result.error ?? "Audit JSON output is unavailable"], `${executable} audit`), version: toolVersion, packagesScanned: packages };
2383
+ cache.set(lock.path, evidence2);
2384
+ return evidence2;
2385
+ }
2386
+ const auditMetadata = obj(report.metadata) ? report.metadata : void 0;
2387
+ const auditedPackages = lock.kind === "npm" && auditMetadata && obj(auditMetadata.dependencies) ? num(auditMetadata.dependencies.total) : lock.kind === "pnpm" && auditMetadata ? num(auditMetadata.totalDependencies) : 0;
2388
+ const supportedShape = lock.kind === "npm" ? report.auditReportVersion === 2 && obj(report.vulnerabilities) : obj(report.advisories);
2389
+ if (!supportedShape || packages === 0 || auditedPackages < packages || result.error || result.code > 1) {
2390
+ const evidence2 = { ...failure([result.error ?? "Audit output was unavailable or lockfile package inventory was empty"], `${executable} audit`), version: toolVersion, packagesScanned: packages };
2391
+ evidence2.errors = [result.error ?? `Audit schema or package coverage is incomplete (${auditedPackages}/${packages})`];
2392
+ cache.set(lock.path, evidence2);
2393
+ return evidence2;
2394
+ }
2395
+ const advisories = [];
2396
+ if (lock.kind === "npm" && obj(report.vulnerabilities)) {
2397
+ for (const [name, value] of Object.entries(report.vulnerabilities)) {
2398
+ if (!obj(value)) continue;
2399
+ const via = Array.isArray(value.via) ? value.via.find(obj) : void 0;
2400
+ advisories.push({
2401
+ id: obj(via) ? str(via.url) || str(via.title) || `npm:${name}` : `npm:${name}`,
2402
+ severity: str(value.severity) || "unknown",
2403
+ subject: name,
2404
+ message: "Known advisory affects a dependency",
2405
+ remediation: "Upgrade the affected package or apply a reviewed exception"
2406
+ });
2407
+ }
2408
+ } else if (lock.kind === "pnpm" && obj(report.advisories)) {
2409
+ for (const [key, value] of Object.entries(report.advisories)) {
2410
+ if (!obj(value)) continue;
2411
+ advisories.push({
2412
+ id: str(value.url) || str(value.id) || key,
2413
+ severity: str(value.severity) || "unknown",
2414
+ subject: str(value.module_name) || "workspace dependency",
2415
+ message: "Known advisory affects a workspace dependency",
2416
+ remediation: "Upgrade the affected package or apply a reviewed exception"
2417
+ });
2418
+ }
2419
+ } else if (result.code !== 0) {
2420
+ const evidence2 = { tool: `${executable} audit`, version: toolVersion, complete: false, packagesScanned: packages, advisories: [], errors: ["Audit returned an unsupported JSON shape"] };
2421
+ cache.set(lock.path, evidence2);
2422
+ return evidence2;
2423
+ }
2424
+ const registryErrors = [report.error, report.errors].filter(Boolean);
2425
+ const evidence = {
2426
+ tool: `${executable} audit${lock.root !== app.directory ? " (workspace-wide)" : ""}`,
2427
+ version: toolVersion,
2428
+ complete: registryErrors.length === 0,
2429
+ packagesScanned: packages,
2430
+ advisories,
2431
+ errors: registryErrors.length ? ["Advisory service returned an error"] : []
2432
+ };
2433
+ cache.set(lock.path, evidence);
2434
+ return evidence;
2435
+ }
2436
+ async function lint(app, workspaceRoot) {
2437
+ const file = await binary("eslint", app.directory, workspaceRoot);
2438
+ const toolVersion = await version(file, app.directory);
2439
+ const result = await run(file, [".", "--format", "json"], app.directory);
2440
+ const parsed = json(result.stdout);
2441
+ if (!Array.isArray(parsed) || result.error || !parsed.every(obj)) {
2442
+ return {
2443
+ tool: "ESLint",
2444
+ version: toolVersion,
2445
+ complete: false,
2446
+ filesScanned: 0,
2447
+ errorCount: 0,
2448
+ warnings: 0,
2449
+ errorsDetail: [result.error ?? "ESLint JSON output is unavailable; install and configure ESLint"]
2450
+ };
2451
+ }
2452
+ const files = parsed;
2453
+ if (!files.every((item) => str(item.filePath) && Number.isSafeInteger(item.errorCount) && Number.isSafeInteger(item.warningCount))) {
2454
+ return {
2455
+ tool: "ESLint",
2456
+ version: toolVersion,
2457
+ complete: false,
2458
+ filesScanned: 0,
2459
+ errorCount: 0,
2460
+ warnings: 0,
2461
+ errorsDetail: ["ESLint JSON result did not contain reliable file paths or diagnostic counts"]
2462
+ };
2463
+ }
2464
+ const expected = await sourcePaths(app.directory);
2465
+ const analyzed = new Set(files.map((item) => str(item.filePath).replaceAll("\\", "/")));
2466
+ const missing = expected.filter((path) => ![...analyzed].some((file2) => file2.endsWith(`/${path}`) || file2 === path));
2467
+ return {
2468
+ tool: "ESLint",
2469
+ version: toolVersion,
2470
+ complete: missing.length === 0,
2471
+ filesScanned: expected.length - missing.length,
2472
+ errorCount: files.reduce((sum, file2) => sum + num(file2.errorCount), 0),
2473
+ warnings: files.reduce((sum, file2) => sum + num(file2.warningCount), 0),
2474
+ errorsDetail: missing.length ? [`ESLint skipped ${missing.length} eligible source files`] : []
2475
+ };
2476
+ }
2477
+ async function typecheck(app, workspaceRoot) {
2478
+ const production = await productionSources(app.directory);
2479
+ const applicable = production.some((path) => /\.(ts|tsx|mts|cts)$/.test(path));
2480
+ const file = await binary("tsc", app.directory, workspaceRoot);
2481
+ const toolVersion = await version(file, app.directory);
2482
+ if (!applicable) return { tool: "TypeScript", version: toolVersion, applicable: false, complete: true, passed: true, errors: [] };
2483
+ const config = await readFile11(join12(app.directory, "tsconfig.json"), "utf8").catch(() => "");
2484
+ if (!config) return {
2485
+ tool: "TypeScript",
2486
+ version: toolVersion,
2487
+ applicable: true,
2488
+ complete: false,
2489
+ passed: false,
2490
+ errors: ["TypeScript source has no readable tsconfig.json"]
2491
+ };
2492
+ const result = await run(file, ["--project", app.directory, "--noEmit", "--pretty", "false"], app.directory);
2493
+ return {
2494
+ tool: "TypeScript",
2495
+ version: toolVersion,
2496
+ applicable: true,
2497
+ complete: !result.error && toolVersion !== "unavailable",
2498
+ passed: result.code === 0,
2499
+ errors: result.error ? [result.error] : result.code === 0 ? [] : [result.stdout.trim().slice(0, 500) || "TypeScript typecheck failed"]
2500
+ };
2501
+ }
2502
+ async function testAndCoverage(app, workspaceRoot, temp) {
2503
+ const file = await binary("vitest", app.directory, workspaceRoot);
2504
+ const toolVersion = await version(file, app.directory);
2505
+ const resultPath = join12(temp, "vitest.json");
2506
+ const coverageDirectory = join12(temp, "coverage");
2507
+ const result = await run(file, [
2508
+ "run",
2509
+ "--reporter=json",
2510
+ "--outputFile",
2511
+ resultPath,
2512
+ "--coverage",
2513
+ "--coverage.reporter=json-summary",
2514
+ "--coverage.reportsDirectory",
2515
+ coverageDirectory
2516
+ ], app.directory);
2517
+ const output = await readFile11(resultPath, "utf8").catch(() => "");
2518
+ const parsed = json(output);
2519
+ const summary = json(await readFile11(join12(coverageDirectory, "coverage-summary.json"), "utf8").catch(() => ""));
2520
+ const tests = obj(parsed) && typeof parsed.numPassedTests === "number" && typeof parsed.numFailedTests === "number";
2521
+ const lineCoverage = obj(summary) && obj(summary.total) && obj(summary.total.lines) ? summary.total.lines.pct : void 0;
2522
+ const expectedFiles = await productionSources(app.directory);
2523
+ const coveredFiles = obj(summary) ? Object.keys(summary).filter((key) => key !== "total") : [];
2524
+ const coveredPaths = new Set(coveredFiles.map((file2) => resolve10(app.directory, file2)));
2525
+ const uncovered = expectedFiles.filter((path) => !coveredPaths.has(resolve10(app.directory, path)));
2526
+ const coverage = typeof lineCoverage === "number" && expectedFiles.length > 0 && uncovered.length === 0;
2527
+ const unhandled = obj(parsed) && Array.isArray(parsed.unhandledErrors) && parsed.unhandledErrors.length > 0;
2528
+ const reporterFailedWithoutTests = tests && result.code !== 0 && num(parsed.numFailedTests) === 0;
2529
+ return {
2530
+ tests: {
2531
+ tool: "Vitest",
2532
+ version: toolVersion,
2533
+ complete: tests && !result.error && !unhandled && !reporterFailedWithoutTests,
2534
+ passed: tests ? num(parsed.numPassedTests) : 0,
2535
+ failed: tests ? num(parsed.numFailedTests) : 0,
2536
+ errors: tests && !unhandled && !reporterFailedWithoutTests ? [] : [result.error ?? "Vitest reporter recorded an unsuccessful run or unhandled error"]
2537
+ },
2538
+ coverage: {
2539
+ tool: "Vitest coverage",
2540
+ version: toolVersion,
2541
+ complete: coverage,
2542
+ linesPercent: coverage ? lineCoverage : 0,
2543
+ errors: coverage ? [] : [uncovered.length ? `Coverage omitted ${uncovered.length} eligible source files` : "Vitest line coverage JSON summary is unavailable"]
2544
+ }
2545
+ };
2546
+ }
2547
+ async function sast(app, temp) {
2548
+ const file = "semgrep";
2549
+ const toolVersion = await version(file, app.directory);
2550
+ const outputPath = join12(temp, "semgrep.json");
2551
+ const result = await run(file, [
2552
+ "scan",
2553
+ "--config",
2554
+ "p/ci",
2555
+ "--no-git-ignore",
2556
+ "--exclude",
2557
+ "**/node_modules/**",
2558
+ "--exclude",
2559
+ "**/.next/**",
2560
+ "--exclude",
2561
+ "**/coverage/**",
2562
+ "--exclude",
2563
+ "**/dist/**",
2564
+ "--json",
2565
+ "--output",
2566
+ outputPath,
2567
+ app.directory
2568
+ ], app.directory);
2569
+ const parsed = json(await readFile11(outputPath, "utf8").catch(() => result.stdout));
2570
+ const expectedSources = await sourcePaths(app.directory);
2571
+ if (!obj(parsed) || !Array.isArray(parsed.results) || result.error) {
2572
+ return {
2573
+ tool: "Semgrep",
2574
+ version: toolVersion,
2575
+ complete: false,
2576
+ filesScanned: 0,
2577
+ findings: [],
2578
+ errors: [result.error ?? "Semgrep JSON result is unavailable; install Semgrep and enable rule download"]
2579
+ };
2580
+ }
2581
+ const findings2 = parsed.results.filter(obj).map((item) => {
2582
+ const extra = obj(item.extra) ? item.extra : {};
2583
+ const metadata = obj(extra.metadata) ? extra.metadata : {};
2584
+ const impact = str(metadata.impact).toLowerCase();
2585
+ const severity = impact === "high" || impact === "critical" ? impact : str(extra.severity).toLowerCase() === "error" ? "high" : "moderate";
2586
+ return {
2587
+ id: str(item.check_id) || "semgrep:unknown",
2588
+ severity,
2589
+ subject: `${relative8(app.directory, str(item.path) || app.directory)}:${obj(item.start) ? num(item.start.line) : 0}`,
2590
+ message: "Static security rule matched; inspect the source location",
2591
+ remediation: "Review and fix the matched rule"
2592
+ };
2593
+ });
2594
+ const scannedPaths = obj(parsed.paths) && Array.isArray(parsed.paths.scanned) ? parsed.paths.scanned.filter((item) => typeof item === "string") : [];
2595
+ const missing = expectedSources.filter((path) => !scannedPaths.some((scanned) => scanned.replaceAll("\\", "/").endsWith(`/${path}`) || scanned === path));
2596
+ const scanErrors = [
2597
+ ...Array.isArray(parsed.errors) && parsed.errors.length ? ["Semgrep reported scanner errors"] : [],
2598
+ ...missing.length ? [`Semgrep skipped ${missing.length} eligible source files`] : []
2599
+ ];
2600
+ return { tool: "Semgrep", version: toolVersion, complete: scanErrors.length === 0, filesScanned: expectedSources.length - missing.length, findings: findings2, errors: scanErrors };
2601
+ }
2602
+ async function secrets(app, temp) {
2603
+ const file = "gitleaks";
2604
+ const toolVersion = await version(file, app.directory);
2605
+ const outputPath = join12(temp, "gitleaks.json");
2606
+ const configPath = join12(temp, "gitleaks.toml");
2607
+ await writeFile3(configPath, '[extend]\nuseDefault = true\n\n[allowlist]\npaths = ["(^|/)(node_modules|\\\\.next|dist|build|coverage|test-results|playwright-report)/"]\n');
2608
+ const result = await run(file, [
2609
+ "dir",
2610
+ app.directory,
2611
+ "--config",
2612
+ configPath,
2613
+ "--report-format",
2614
+ "json",
2615
+ "--report-path",
2616
+ outputPath,
2617
+ "--redact",
2618
+ "--no-banner"
2619
+ ], app.directory);
2620
+ const parsed = json(await readFile11(outputPath, "utf8").catch(() => ""));
2621
+ const count = await sourceCount(app.directory);
2622
+ if (!Array.isArray(parsed) || result.error) {
2623
+ return {
2624
+ tool: "Gitleaks",
2625
+ version: toolVersion,
2626
+ complete: false,
2627
+ filesScanned: 0,
2628
+ findings: [],
2629
+ errors: [result.error ?? "Gitleaks JSON result is unavailable; install Gitleaks"]
2630
+ };
2631
+ }
2632
+ const findings2 = parsed.filter(obj).map((item) => ({
2633
+ id: str(item.RuleID) || "gitleaks:secret",
2634
+ severity: "critical",
2635
+ subject: `${str(item.File) ? relative8(app.directory, resolve10(app.directory, str(item.File))).replaceAll("\\", "/") : "source"}:${num(item.StartLine)}`,
2636
+ message: `Potential secret detected (fingerprint ${createHash4("sha256").update(str(item.Fingerprint)).digest("hex").slice(0, 16)})`,
2637
+ remediation: "Revoke the credential and remove it from source/history"
2638
+ }));
2639
+ return {
2640
+ tool: "Gitleaks",
2641
+ version: toolVersion,
2642
+ complete: result.code === 0 || result.code === 1,
2643
+ filesScanned: count,
2644
+ findings: findings2,
2645
+ errors: result.code > 1 ? ["Gitleaks scanner failed"] : []
2646
+ };
2647
+ }
2648
+ function playwrightProjects(value) {
2649
+ if (!obj(value) || !Array.isArray(value.suites)) return [];
2650
+ const totals = /* @__PURE__ */ new Map();
2651
+ const visit = (suite) => {
2652
+ if (!obj(suite)) return;
2653
+ for (const spec of Array.isArray(suite.specs) ? suite.specs : []) {
2654
+ if (!obj(spec) || !Array.isArray(spec.tests)) continue;
2655
+ for (const test of spec.tests) {
2656
+ if (!obj(test) || !str(test.projectName)) continue;
2657
+ const projectName = str(test.projectName);
2658
+ const project = totals.get(projectName) ?? {
2659
+ name: projectName,
2660
+ passed: 0,
2661
+ failed: 0,
2662
+ skipped: 0,
2663
+ viewportWidths: /* @__PURE__ */ new Set()
2664
+ };
2665
+ for (const annotation of Array.isArray(test.annotations) ? test.annotations : []) {
2666
+ if (!obj(annotation)) continue;
2667
+ if (annotation.type === "browser-version" && str(annotation.description)) project.browserVersion = str(annotation.description);
2668
+ if (annotation.type === "viewport-width" && /^\d+$/.test(str(annotation.description))) project.viewportWidths.add(Number(annotation.description));
2669
+ }
2670
+ const status = str(test.status);
2671
+ if (status === "expected") project.passed++;
2672
+ else if (status === "skipped") project.skipped++;
2673
+ else project.failed++;
2674
+ totals.set(project.name, project);
2675
+ }
2676
+ }
2677
+ for (const child of Array.isArray(suite.suites) ? suite.suites : []) visit(child);
2678
+ };
2679
+ for (const suite of value.suites) visit(suite);
2680
+ return [...totals.values()].map((project) => ({ ...project, viewportWidths: [...project.viewportWidths].sort((a, b) => a - b) }));
2681
+ }
2682
+ function parsePlaywrightAccessibility(value) {
2683
+ const scans = [];
2684
+ const errors2 = [];
2685
+ const visit = (suite) => {
2686
+ if (!obj(suite)) return;
2687
+ for (const spec of Array.isArray(suite.specs) ? suite.specs : []) {
2688
+ if (!obj(spec) || !Array.isArray(spec.tests)) continue;
2689
+ for (const test of spec.tests) {
2690
+ if (!obj(test) || !str(test.projectName)) continue;
2691
+ for (const annotation of Array.isArray(test.annotations) ? test.annotations : []) {
2692
+ if (!obj(annotation) || annotation.type !== "lattice-axe") continue;
2693
+ const measured = json(str(annotation.description));
2694
+ const validFindings = obj(measured) && Array.isArray(measured.violations) && measured.violations.every((item) => obj(item) && str(item.id) && str(item.help) && str(item.selector) && ["minor", "moderate", "serious", "critical"].includes(str(item.impact)));
2695
+ if (!obj(measured) || !/^\/[^?#]*$/.test(str(measured.route)) || !/^\d+\.\d+\.\d+$/.test(str(measured.axeVersion)) || !Array.isArray(measured.tags) || measured.tags.length === 0 || !measured.tags.every((item) => typeof item === "string" && item.length > 0) || new Set(measured.tags).size !== measured.tags.length || !Number.isSafeInteger(measured.checks) || measured.checks <= 0 || !Number.isSafeInteger(measured.needsReview) || measured.needsReview < 0 || !validFindings) {
2696
+ errors2.push(`Malformed axe accessibility annotation for ${test.projectName}`);
2697
+ continue;
2698
+ }
2699
+ scans.push({
2700
+ project: str(test.projectName),
2701
+ route: str(measured.route),
2702
+ axeVersion: str(measured.axeVersion),
2703
+ tags: measured.tags,
2704
+ checks: measured.checks,
2705
+ needsReview: measured.needsReview,
2706
+ violations: measured.violations.map((item) => ({
2707
+ id: str(item.id),
2708
+ severity: str(item.impact),
2709
+ subject: `${measured.route} ${item.selector}`,
2710
+ message: str(item.help)
2711
+ }))
2712
+ });
2713
+ }
2714
+ }
2715
+ }
2716
+ for (const child of Array.isArray(suite.suites) ? suite.suites : []) visit(child);
2717
+ };
2718
+ if (obj(value) && Array.isArray(value.suites)) for (const suite of value.suites) visit(suite);
2719
+ if (scans.length === 0 && errors2.length === 0) errors2.push("No axe accessibility annotations were recorded");
2720
+ const versions = new Set(scans.map((scan) => scan.axeVersion));
2721
+ return {
2722
+ tool: "axe-core via Playwright",
2723
+ version: versions.size === 1 ? scans[0].axeVersion : "mixed or unavailable",
2724
+ complete: errors2.length === 0 && scans.length > 0,
2725
+ scans,
2726
+ errors: errors2
2727
+ };
2728
+ }
2729
+ async function compatibility(app, workspaceRoot, temp) {
2730
+ const file = await binary("playwright", app.directory, workspaceRoot);
2731
+ const toolVersion = await version(file, app.directory);
2732
+ const outputPath = join12(temp, "playwright.json");
2733
+ const configPath = join12(temp, "playwright-projects.json");
2734
+ const reporterPath = join12(temp, "lattice-playwright-reporter.cjs");
2735
+ await writeFile3(reporterPath, 'module.exports=class { onBegin(config) { require("node:fs").writeFileSync(process.env.LATTICE_PLAYWRIGHT_CONFIG_OUT, JSON.stringify(config.projects.map(p => ({ name: p.name, engine: p.use.browserName || p.use.defaultBrowserType, mobile: p.use.isMobile === true, touch: p.use.hasTouch === true, userAgent: p.use.userAgent || "" })))); } };\n');
2736
+ const result = await run(
2737
+ file,
2738
+ ["test", `--reporter=json,${reporterPath}`],
2739
+ app.directory,
2740
+ { ...process.env, PLAYWRIGHT_JSON_OUTPUT_FILE: outputPath, LATTICE_PLAYWRIGHT_CONFIG_OUT: configPath }
2741
+ );
2742
+ const parsed = json(await readFile11(outputPath, "utf8").catch(() => result.stdout));
2743
+ const projects = playwrightProjects(parsed);
2744
+ const config = json(await readFile11(configPath, "utf8").catch(() => ""));
2745
+ const configurations = Array.isArray(config) && config.every(obj) ? config : [];
2746
+ for (const project of projects) {
2747
+ const configured = configurations.find((item) => item.name === project.name);
2748
+ if (!configured) continue;
2749
+ project.engine = str(configured.engine);
2750
+ project.mobile = configured.mobile === true && configured.touch === true;
2751
+ const agent = str(configured.userAgent);
2752
+ const android = agent.match(/Android\s+([\d.]+)/i);
2753
+ const iphone = agent.match(/iPhone OS\s+([\d_]+)/i);
2754
+ if (project.name === "android-chrome" && android && project.mobile) {
2755
+ project.emulatedOs = "Android";
2756
+ project.emulatedOsVersion = android[1];
2757
+ project.device = `Android ${android[1]} UA emulation`;
2758
+ }
2759
+ if (project.name === "iphone-safari" && iphone && project.mobile) {
2760
+ project.emulatedOs = "iOS";
2761
+ project.emulatedOsVersion = iphone[1].replaceAll("_", ".");
2762
+ project.device = `iOS ${project.emulatedOsVersion} UA emulation`;
2763
+ }
2764
+ }
2765
+ const reportedErrors = obj(parsed) && Array.isArray(parsed.errors) ? parsed.errors : [];
2766
+ const infrastructureFailure = reportedErrors.length > 0 || configurations.length === 0 || typeof result.stderr === "string" && /browser executable.*doesn't exist|failed to launch browser|install playwright browsers/i.test(result.stderr);
2767
+ return {
2768
+ compatibility: {
2769
+ tool: "Playwright",
2770
+ version: toolVersion,
2771
+ complete: Boolean(parsed) && !result.error && !infrastructureFailure && projects.length > 0,
2772
+ projects,
2773
+ errors: infrastructureFailure ? ["Playwright browser infrastructure or configuration failed"] : projects.length ? [] : [result.error ?? "Playwright JSON results are unavailable; configure required browser and mobile projects"]
2774
+ },
2775
+ accessibility: parsePlaywrightAccessibility(parsed)
2776
+ };
2777
+ }
2778
+ async function collectGovernanceEvidence(options) {
2779
+ const scanRoot = resolve10(options.scanRoot);
2780
+ const workspaceRoot = resolve10(options.workspaceRoot ?? scanRoot);
2781
+ const destination = resolve10(options.evidence);
2782
+ if (!inside(workspaceRoot, scanRoot)) throw new Error("Scan root must be inside workspace root");
2783
+ if (inside(scanRoot, destination)) throw new Error("Evidence file must be outside the scan root");
2784
+ if (!options.policy.assessment?.evidence) throw new Error("Policy assessment.evidence is required for governance collect");
2785
+ const inputSha256 = await hashAssessmentInputs(scanRoot, workspaceRoot);
2786
+ const assertStableInputs = async (stage) => {
2787
+ if (inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) {
2788
+ throw new Error(`Assessment inputs changed ${stage}; rerun on a stable checkout`);
2789
+ }
2790
+ };
2791
+ const git = await run("git", ["rev-parse", "HEAD"], workspaceRoot);
2792
+ const commit = git.code === 0 && /^[0-9a-f]{40}$/.test(git.stdout.trim()) ? git.stdout.trim() : "unversioned";
2793
+ const apps = await discoverApps(scanRoot, workspaceRoot);
2794
+ const auditCache = /* @__PURE__ */ new Map();
2795
+ const scans = [];
2796
+ for (const app of apps) {
2797
+ const temp = await mkdtemp(join12(tmpdir(), "lattice-scanners-"));
2798
+ try {
2799
+ const dependencySecurity = await dependencyAudit(app, workspaceRoot, auditCache);
2800
+ await assertStableInputs("after dependency audit");
2801
+ const qualityLint = await lint(app, workspaceRoot);
2802
+ await assertStableInputs("after ESLint");
2803
+ const qualityTypecheck = await typecheck(app, workspaceRoot);
2804
+ await assertStableInputs("after TypeScript");
2805
+ const qualityTests = await testAndCoverage(app, workspaceRoot, temp);
2806
+ await assertStableInputs("after Vitest");
2807
+ const securitySast = await sast(app, temp);
2808
+ await assertStableInputs("after Semgrep");
2809
+ const securitySecrets = await secrets(app, temp);
2810
+ await assertStableInputs("after Gitleaks");
2811
+ const compatibilityResult = await compatibility(app, workspaceRoot, temp);
2812
+ await assertStableInputs("after Playwright");
2813
+ scans.push({
2814
+ path: relative8(scanRoot, app.directory).replaceAll("\\", "/") || ".",
2815
+ profile: app.profile,
2816
+ dependencySecurity,
2817
+ codeQuality: { lint: qualityLint, typecheck: qualityTypecheck, ...qualityTests },
2818
+ codeSecurity: { sast: securitySast, secrets: securitySecrets },
2819
+ ...compatibilityResult
2820
+ });
2821
+ } finally {
2822
+ await rm(temp, { recursive: true, force: true });
2823
+ }
2824
+ }
2825
+ await assertStableInputs("before evidence write");
2826
+ const report = {
2827
+ schemaVersion: 1,
2828
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2829
+ policySha256: options.policy.sha256,
2830
+ inputSha256,
2831
+ commit,
2832
+ apps: scans
2833
+ };
2834
+ await mkdir4(dirname5(destination), { recursive: true });
2835
+ await writeFile3(destination, `${JSON.stringify(report, null, 2)}
2836
+ `, { flag: "w" });
1699
2837
  return report;
1700
2838
  }
1701
2839
 
1702
2840
  // src/runtime.ts
1703
- function isSupportedNodeVersion(version) {
1704
- const major = Number(version.split(".", 1)[0]);
2841
+ function isSupportedNodeVersion(version2) {
2842
+ const major = Number(version2.split(".", 1)[0]);
1705
2843
  return Number.isInteger(major) && major >= 24;
1706
2844
  }
1707
2845
 
@@ -1722,12 +2860,12 @@ async function runCli(arguments_, output = defaultOutput) {
1722
2860
  program.configureOutput({ writeOut: output.out, writeErr: output.err });
1723
2861
  program.exitOverride();
1724
2862
  program.command("analyze [path]").description("Analyze local architecture and write offline reports").option("--output <directory>", "Output directory").action(async (path, flags) => {
1725
- const root = resolve9(path ?? ".");
1726
- const destination = resolve9(flags.output ?? join10(root, ".nklattice-report"));
2863
+ const root = resolve11(path ?? ".");
2864
+ const destination = resolve11(flags.output ?? join13(root, ".nklattice-report"));
1727
2865
  const report = await analyzeProject(root, { outputDirectory: destination });
1728
2866
  output.out(`NKLattice analyzed ${report.graph.modules.length} modules. Score ${report.score.score} (${report.score.grade}); ${report.findings.length} findings.
1729
2867
  `);
1730
- output.out(`Reports: ${join10(destination, "report.html")} and ${join10(destination, "analysis.json")}
2868
+ output.out(`Reports: ${join13(destination, "report.html")} and ${join13(destination, "analysis.json")}
1731
2869
  `);
1732
2870
  });
1733
2871
  program.command("doctor [path]").description("Check whether a directory contains supported source files").action(async (path) => {
@@ -1737,8 +2875,8 @@ async function runCli(arguments_, output = defaultOutput) {
1737
2875
  `);
1738
2876
  });
1739
2877
  program.command("init [path]").description("Create a project configuration file").action(async (path) => {
1740
- const destination = join10(resolve9(path ?? "."), ".nklattice.json");
1741
- await writeFile3(destination, '{\n "analysis": {},\n "policies": []\n}\n', { flag: "wx" });
2878
+ const destination = join13(resolve11(path ?? "."), ".nklattice.json");
2879
+ await writeFile4(destination, '{\n "analysis": {},\n "policies": []\n}\n', { flag: "wx" });
1742
2880
  output.out(`Created ${destination}
1743
2881
  `);
1744
2882
  });
@@ -1750,7 +2888,7 @@ async function runCli(arguments_, output = defaultOutput) {
1750
2888
  expectedApps = Number(flags.expectApps);
1751
2889
  if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
1752
2890
  }
1753
- const policyPath = flags.policy ?? resolve9(dirname3(fileURLToPath(import.meta.url)), "../governance/policy.json");
2891
+ const policyPath = flags.policy ?? resolve11(dirname6(fileURLToPath(import.meta.url)), "../governance/policy.json");
1754
2892
  const policy = await loadPolicy(policyPath);
1755
2893
  const result = await checkGovernance({
1756
2894
  scanRoot: path ?? ".",
@@ -1776,21 +2914,22 @@ async function runCli(arguments_, output = defaultOutput) {
1776
2914
  }
1777
2915
  if (result.findings.length > 0) exitCode = 7;
1778
2916
  });
1779
- governance.command("assess [path]").description("Assess app inventory, dependencies, and source architecture").option("--policy <source>", "Local file or HTTPS policy URL").option("--inventory <path>", "Reviewed app inventory JSON").option("--workspace-root <path>", "Repository root containing a shared lockfile").option("--expect-apps <count>", "Additional expected managed application count").option("--output <directory>", "Assessment report directory").option("--json", "Print machine-readable assessment").action(async (path, flags) => {
2917
+ governance.command("assess [path]").description("Assess app inventory, dependency, architecture, security, quality, and compatibility policy").option("--policy <source>", "Local file or HTTPS policy URL").option("--inventory <path>", "Reviewed app inventory JSON").option("--workspace-root <path>", "Repository root containing a shared lockfile").option("--expect-apps <count>", "Additional expected managed application count").option("--output <directory>", "Assessment report directory").option("--evidence <file>", "Fresh governance collect JSON outside the scan root").option("--json", "Print machine-readable assessment").action(async (path, flags) => {
1780
2918
  let expectedApps;
1781
2919
  if (flags.expectApps !== void 0) {
1782
2920
  if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
1783
2921
  expectedApps = Number(flags.expectApps);
1784
2922
  if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
1785
2923
  }
1786
- const scanRoot = resolve9(path ?? ".");
1787
- const policyPath = flags.policy ?? resolve9(dirname3(fileURLToPath(import.meta.url)), "../governance/policy.json");
2924
+ const scanRoot = resolve11(path ?? ".");
2925
+ const policyPath = flags.policy ?? resolve11(dirname6(fileURLToPath(import.meta.url)), "../governance/policy.json");
1788
2926
  const report = await assessGovernance({
1789
2927
  scanRoot,
1790
2928
  workspaceRoot: flags.workspaceRoot,
1791
2929
  inventory: flags.inventory,
1792
2930
  expectedApps,
1793
2931
  outputDirectory: flags.output,
2932
+ evidence: flags.evidence,
1794
2933
  policy: await loadPolicy(policyPath)
1795
2934
  });
1796
2935
  if (flags.json) output.out(`${JSON.stringify(report, null, 2)}
@@ -1801,12 +2940,58 @@ async function runCli(arguments_, output = defaultOutput) {
1801
2940
  for (const issue of report.issues) output.out(`Inventory: ${issue}
1802
2941
  `);
1803
2942
  for (const app of report.apps) {
1804
- output.out(`${app.path} (${app.profile}): ${app.status}; dependencies ${app.domains.dependencies.status}; architecture ${app.domains.architecture.status} (${app.domains.architecture.score ?? "unavailable"}).
2943
+ output.out(`${app.path} (${app.profile}): ${app.status}; ${Object.entries(app.domains).map(([name, domain]) => `${name} ${domain.status}`).join("; ")}; architecture score ${app.domains.architecture.score ?? "unavailable"}.
2944
+ `);
2945
+ for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
2946
+ `);
2947
+ }
2948
+ output.out(`Reports: ${resolve11(flags.output ?? join13(scanRoot, ".nklattice-governance"))}
2949
+ `);
2950
+ }
2951
+ exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
2952
+ });
2953
+ governance.command("collect [path]").description("Run project scanners and write fresh machine-readable evidence").requiredOption("--evidence <file>", "Evidence JSON outside the scan root").option("--policy <source>", "Local file or HTTPS policy URL").option("--workspace-root <path>", "Repository root containing a shared lockfile").action(async (path, flags) => {
2954
+ const scanRoot = resolve11(path ?? ".");
2955
+ const policyPath = flags.policy ?? resolve11(dirname6(fileURLToPath(import.meta.url)), "../governance/policy.json");
2956
+ const result = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy: await loadPolicy(policyPath) });
2957
+ output.out(`Collected scanner evidence for ${result.apps.length} app(s) at ${resolve11(flags.evidence)}.
2958
+ `);
2959
+ });
2960
+ governance.command("run [path]").description("Collect and assess all governance domains in one trusted process").requiredOption("--evidence <file>", "Evidence artifact outside the scan root").option("--policy <source>", "Local file or HTTPS policy URL").option("--inventory <path>", "Reviewed app inventory JSON").option("--workspace-root <path>", "Repository root containing a shared lockfile").option("--expect-apps <count>", "Additional expected managed application count").option("--output <directory>", "Assessment report directory").option("--json", "Print machine-readable assessment").action(async (path, flags) => {
2961
+ let expectedApps;
2962
+ if (flags.expectApps !== void 0) {
2963
+ if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
2964
+ expectedApps = Number(flags.expectApps);
2965
+ if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
2966
+ }
2967
+ const scanRoot = resolve11(path ?? ".");
2968
+ const policyPath = flags.policy ?? resolve11(dirname6(fileURLToPath(import.meta.url)), "../governance/policy.json");
2969
+ const policy = await loadPolicy(policyPath);
2970
+ const collected = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy });
2971
+ const report = await assessGovernance({
2972
+ scanRoot,
2973
+ workspaceRoot: flags.workspaceRoot,
2974
+ inventory: flags.inventory,
2975
+ expectedApps,
2976
+ outputDirectory: flags.output,
2977
+ evidence: flags.evidence,
2978
+ trustedEvidence: collected,
2979
+ policy
2980
+ });
2981
+ if (flags.json) output.out(`${JSON.stringify(report, null, 2)}
2982
+ `);
2983
+ else {
2984
+ output.out(`Governance ${report.status}: ${report.apps.length} app(s); policy ${report.policy.version} (${report.policy.sha256}).
2985
+ `);
2986
+ for (const issue of report.issues) output.out(`Assessment: ${issue}
2987
+ `);
2988
+ for (const app of report.apps) {
2989
+ output.out(`${app.path} (${app.profile}): ${app.status}; ${Object.entries(app.domains).map(([name, domain]) => `${name} ${domain.status}`).join("; ")}; architecture score ${app.domains.architecture.score ?? "unavailable"}.
1805
2990
  `);
1806
2991
  for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
1807
2992
  `);
1808
2993
  }
1809
- output.out(`Reports: ${resolve9(flags.output ?? join10(scanRoot, ".nklattice-governance"))}
2994
+ output.out(`Reports: ${resolve11(flags.output ?? join13(scanRoot, ".nklattice-governance"))}
1810
2995
  `);
1811
2996
  }
1812
2997
  exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
@@ -1822,7 +3007,7 @@ async function runCli(arguments_, output = defaultOutput) {
1822
3007
  }
1823
3008
  return exitCode;
1824
3009
  }
1825
- if (process.argv[1] && realpathSync(resolve9(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
3010
+ if (process.argv[1] && realpathSync(resolve11(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
1826
3011
  runCli(process.argv.slice(2)).then((code) => {
1827
3012
  process.exitCode = code;
1828
3013
  });