@anhcodo13/lattice 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,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 dirname5, join as join12, 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.4.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 });
@@ -1176,12 +1176,12 @@ function dependencyMap(manifest) {
1176
1176
  const entries = manifest[section2];
1177
1177
  if (entries === void 0) continue;
1178
1178
  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) {
1179
+ for (const [name, version2] of Object.entries(entries)) {
1180
+ if (typeof version2 !== "string") throw new Error(`Invalid version for ${name}`);
1181
+ if (result[name] !== void 0 && result[name] !== version2) {
1182
1182
  throw new Error(`Conflicting declarations for ${name}`);
1183
1183
  }
1184
- result[name] = version;
1184
+ result[name] = version2;
1185
1185
  }
1186
1186
  }
1187
1187
  return result;
@@ -1324,17 +1324,17 @@ function readPnpm(raw, root, app, name) {
1324
1324
  if (!record2(entry) || typeof entry.specifier !== "string" || typeof entry.version !== "string") {
1325
1325
  throw new Error(`Malformed pnpm lockfile dependency for ${name}`);
1326
1326
  }
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}`];
1327
+ const version2 = entry.version.match(/^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)?.[1];
1328
+ if (!version2) throw new Error(`Unsupported pnpm resolution for ${name}`);
1329
+ const packageKeys = [`${name}@${version2}`, `/${name}@${version2}`];
1330
1330
  if (!packageKeys.some((key) => record2(lock.packages) && Object.hasOwn(lock.packages, key))) {
1331
- throw new Error(`Missing pnpm package entry for ${name}@${version}`);
1331
+ throw new Error(`Missing pnpm package entry for ${name}@${version2}`);
1332
1332
  }
1333
1333
  const snapshotKeys = [`${name}@${entry.version}`, `/${name}@${entry.version}`, ...packageKeys];
1334
1334
  if (!snapshotKeys.some((key) => Object.hasOwn(lock.snapshots, key))) {
1335
1335
  throw new Error(`Missing pnpm snapshot for ${name}@${entry.version}`);
1336
1336
  }
1337
- return { specifier: entry.specifier, version };
1337
+ return { specifier: entry.specifier, version: version2 };
1338
1338
  }
1339
1339
  async function readResolution(app, workspaceRoot, name) {
1340
1340
  const lockfile = await findLockfile(app.directory, workspaceRoot);
@@ -1346,7 +1346,7 @@ async function readResolution(app, workspaceRoot, name) {
1346
1346
  async function checkApp(app, workspaceRoot, policy) {
1347
1347
  const requirements = policy.profiles[app.profile];
1348
1348
  if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
1349
- const findings = [];
1349
+ const findings2 = [];
1350
1350
  for (const [packageName, expected] of Object.entries(requirements)) {
1351
1351
  const declared = app.dependencies[packageName];
1352
1352
  const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
@@ -1364,7 +1364,7 @@ async function checkApp(app, workspaceRoot, policy) {
1364
1364
  reasons.push("lockfile resolution outside manifest range");
1365
1365
  }
1366
1366
  }
1367
- if (reasons.length) findings.push({
1367
+ if (reasons.length) findings2.push({
1368
1368
  app: app.relativePath,
1369
1369
  packageName,
1370
1370
  expected,
@@ -1374,7 +1374,7 @@ async function checkApp(app, workspaceRoot, policy) {
1374
1374
  reason: reasons.join("; ")
1375
1375
  });
1376
1376
  }
1377
- return findings;
1377
+ return findings2;
1378
1378
  }
1379
1379
  async function checkGovernance(options) {
1380
1380
  const scanRoot = resolve5(options.scanRoot);
@@ -1385,10 +1385,10 @@ async function checkGovernance(options) {
1385
1385
  }
1386
1386
  const apps = await discoverApps(scanRoot, workspaceRoot);
1387
1387
  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();
1388
+ const findings2 = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
1389
1389
  return {
1390
1390
  appsChecked: apps.length,
1391
- findings,
1391
+ findings: findings2,
1392
1392
  policyVersion: options.policy.policyVersion,
1393
1393
  policySource: options.policy.source,
1394
1394
  policySha256: options.policy.sha256
@@ -1397,7 +1397,7 @@ async function checkGovernance(options) {
1397
1397
 
1398
1398
  // src/governance/assess.ts
1399
1399
  import { mkdir as mkdir3, writeFile as writeFile2 } from "fs/promises";
1400
- import { join as join9, relative as relative6, resolve as resolve8, sep as sep5 } from "path";
1400
+ import { basename as basename2, join as join10, relative as relative7, resolve as resolve9, sep as sep6 } from "path";
1401
1401
 
1402
1402
  // src/governance/inventory.ts
1403
1403
  import { createHash } from "crypto";
@@ -1457,10 +1457,283 @@ function compareInventory(inventory, discovered, scanRoot) {
1457
1457
  return issues;
1458
1458
  }
1459
1459
 
1460
- // src/governance/policy.ts
1460
+ // src/governance/evidence.ts
1461
1461
  import { createHash as createHash2 } from "crypto";
1462
- import { readFile as readFile8 } from "fs/promises";
1463
- import { resolve as resolve7 } from "path";
1462
+ import { execFile } from "child_process";
1463
+ import { createReadStream } from "fs";
1464
+ import { lstat, readFile as readFile8, realpath as realpath2 } from "fs/promises";
1465
+ import { dirname as dirname3, join as join9, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
1466
+ import { promisify } from "util";
1467
+ import fg from "fast-glob";
1468
+ var execFileAsync = promisify(execFile);
1469
+ function object(value) {
1470
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1471
+ }
1472
+ function string(value) {
1473
+ return typeof value === "string" && value.length > 0;
1474
+ }
1475
+ function nonnegative(value) {
1476
+ return Number.isSafeInteger(value) && value >= 0;
1477
+ }
1478
+ function errors(value) {
1479
+ return Array.isArray(value) && value.every(string);
1480
+ }
1481
+ function findings(value) {
1482
+ return Array.isArray(value) && value.every((item) => object(item) && string(item.id) && string(item.severity) && string(item.subject) && string(item.message) && (item.remediation === void 0 || string(item.remediation)));
1483
+ }
1484
+ function base(value) {
1485
+ return object(value) && string(value.tool) && string(value.version) && typeof value.complete === "boolean" && errors(value.errors);
1486
+ }
1487
+ function validateApp(value) {
1488
+ if (!object(value) || !string(value.path) || !string(value.profile)) return false;
1489
+ const dependency = value.dependencySecurity;
1490
+ const quality = value.codeQuality;
1491
+ const security = value.codeSecurity;
1492
+ const compatibility2 = value.compatibility;
1493
+ if (!base(dependency) || !nonnegative(dependency.packagesScanned) || !findings(dependency.advisories)) return false;
1494
+ if (!object(quality) || !object(quality.lint) || !string(quality.lint.tool) || !string(quality.lint.version) || typeof quality.lint.complete !== "boolean" || !base(quality.typecheck) || !base(quality.tests) || !base(quality.coverage)) return false;
1495
+ if (!nonnegative(quality.lint.filesScanned) || !nonnegative(quality.lint.errorCount) || !nonnegative(quality.lint.warnings) || !errors(quality.lint.errorsDetail) || typeof quality.typecheck.passed !== "boolean" || quality.typecheck.applicable !== void 0 && typeof quality.typecheck.applicable !== "boolean" || !nonnegative(quality.tests.passed) || !nonnegative(quality.tests.failed) || typeof quality.coverage.linesPercent !== "number" || !Number.isFinite(quality.coverage.linesPercent) || quality.coverage.linesPercent < 0 || quality.coverage.linesPercent > 100) return false;
1496
+ if (!object(security) || !base(security.sast) || !base(security.secrets) || !nonnegative(security.sast.filesScanned) || !nonnegative(security.secrets.filesScanned) || !findings(security.sast.findings) || !findings(security.secrets.findings)) return false;
1497
+ if (!base(compatibility2) || !Array.isArray(compatibility2.projects) || !compatibility2.projects.every((project) => object(project) && string(project.name) && nonnegative(project.passed) && nonnegative(project.failed) && nonnegative(project.skipped) && (project.browserVersion === void 0 || string(project.browserVersion)) && (project.engine === void 0 || string(project.engine)) && (project.mobile === void 0 || typeof project.mobile === "boolean") && (project.device === void 0 || string(project.device)) && (project.viewportWidths === void 0 || Array.isArray(project.viewportWidths) && project.viewportWidths.every(nonnegative)))) return false;
1498
+ return true;
1499
+ }
1500
+ async function hashFile(hash, path) {
1501
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
1502
+ }
1503
+ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
1504
+ const root = resolve7(scanRoot);
1505
+ const workspace = resolve7(workspaceRoot);
1506
+ const scanRelative = relative6(workspace, root);
1507
+ if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
1508
+ const paths = await fg("**/*", {
1509
+ cwd: root,
1510
+ dot: true,
1511
+ onlyFiles: true,
1512
+ followSymbolicLinks: false,
1513
+ ignore: [
1514
+ "**/.git/**",
1515
+ "**/node_modules/**",
1516
+ "**/dist/**",
1517
+ "**/build/**",
1518
+ "**/.next/**",
1519
+ "**/coverage/**",
1520
+ "**/playwright-report/**",
1521
+ "**/test-results/**",
1522
+ "**/.nklattice-governance/**",
1523
+ "**/.nklattice-report/**",
1524
+ "**/AGENTS.md",
1525
+ "**/*.tsbuildinfo",
1526
+ "**/next-env.d.ts"
1527
+ ]
1528
+ });
1529
+ paths.sort();
1530
+ const hash = createHash2("sha256");
1531
+ for (const path of paths) {
1532
+ const absolute = resolve7(root, path);
1533
+ const normalized = relative6(root, absolute);
1534
+ if (normalized === ".." || normalized.startsWith(`..${sep5}`)) throw new Error("Input path escapes scan root");
1535
+ if (!(await lstat(absolute)).isFile()) continue;
1536
+ hash.update(path.replaceAll("\\", "/"));
1537
+ hash.update("\0");
1538
+ await hashFile(hash, absolute);
1539
+ hash.update("\0");
1540
+ }
1541
+ let ancestor = root;
1542
+ while (ancestor === workspace || ancestor !== dirname3(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
1543
+ for (const name of [
1544
+ "package.json",
1545
+ "package-lock.json",
1546
+ "pnpm-lock.yaml",
1547
+ "pnpm-workspace.yaml",
1548
+ "tsconfig.json",
1549
+ "eslint.config.js",
1550
+ "eslint.config.mjs",
1551
+ "eslint.config.cjs",
1552
+ "eslint.config.ts",
1553
+ "eslint.config.mts",
1554
+ "vitest.config.js",
1555
+ "vitest.config.mjs",
1556
+ "vitest.config.ts",
1557
+ "vitest.config.mts",
1558
+ "playwright.config.js",
1559
+ "playwright.config.mjs",
1560
+ "playwright.config.ts",
1561
+ "playwright.config.mts",
1562
+ ".semgrep.yml",
1563
+ ".semgrep.yaml",
1564
+ "semgrep.yml",
1565
+ "semgrep.yaml"
1566
+ ]) {
1567
+ const path = join9(ancestor, name);
1568
+ try {
1569
+ const stat2 = await lstat(path);
1570
+ if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
1571
+ if (!stat2.isFile()) continue;
1572
+ hash.update(relative6(workspace, path).replaceAll("\\", "/"));
1573
+ hash.update("\0");
1574
+ await hashFile(hash, path);
1575
+ hash.update("\0");
1576
+ } catch (error) {
1577
+ if (error.code !== "ENOENT") throw error;
1578
+ }
1579
+ }
1580
+ if (ancestor === workspace) break;
1581
+ ancestor = dirname3(ancestor);
1582
+ }
1583
+ return hash.digest("hex");
1584
+ }
1585
+ async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
1586
+ const source = resolve7(path);
1587
+ const stat2 = await lstat(source);
1588
+ if (!stat2.isFile() || stat2.isSymbolicLink() || stat2.size > 1e7) throw new Error("Evidence must be a regular JSON file under 10 MB");
1589
+ const root = resolve7(scanRoot);
1590
+ const relativePath = relative6(root, source);
1591
+ if (relativePath !== ".." && !relativePath.startsWith(`..${sep5}`)) throw new Error("Evidence file must be outside the scan root");
1592
+ const actualRoot = await realpath2(root);
1593
+ const actualSource = await realpath2(source);
1594
+ const actualRelative = relative6(actualRoot, actualSource);
1595
+ if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
1596
+ let parsed;
1597
+ try {
1598
+ parsed = JSON.parse(await readFile8(source, "utf8"));
1599
+ } catch {
1600
+ throw new Error("Evidence is not valid JSON");
1601
+ }
1602
+ return validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours);
1603
+ }
1604
+ async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
1605
+ if (!object(parsed) || parsed.schemaVersion !== 1 || !string(parsed.generatedAt) || !string(parsed.policySha256) || !string(parsed.inputSha256) || !string(parsed.commit) || !/^[0-9a-f]{40}$/.test(parsed.commit) || !Array.isArray(parsed.apps) || !parsed.apps.every(validateApp)) throw new Error("Evidence schema is invalid or incomplete");
1606
+ if (parsed.policySha256 !== policySha256) throw new Error("Evidence was generated for another policy");
1607
+ const generatedAt = Date.parse(parsed.generatedAt);
1608
+ if (!Number.isFinite(generatedAt) || generatedAt > Date.now() + 6e4 || Date.now() - generatedAt > maximumAgeHours * 36e5) {
1609
+ throw new Error("Evidence is stale or has an invalid timestamp");
1610
+ }
1611
+ if (parsed.inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) throw new Error("Evidence inputs changed after scanning");
1612
+ const current = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot })).stdout.trim();
1613
+ if (!/^[0-9a-f]{40}$/.test(current) || parsed.commit !== current) throw new Error("Evidence commit differs from the checked-out commit");
1614
+ const appPaths = parsed.apps.map((app) => app.path);
1615
+ if (new Set(appPaths).size !== appPaths.length) throw new Error("Evidence contains duplicate app paths");
1616
+ return parsed;
1617
+ }
1618
+ var incomplete = (issues, tool, version2, scanned) => ({ status: "incomplete", issues, tool, version: version2, scanned });
1619
+ function evaluateAppEvidence(app, policy) {
1620
+ if (!app) {
1621
+ const missing = incomplete(["No fresh scan evidence for this app"]);
1622
+ return { dependencySecurity: { ...missing }, codeQuality: { ...missing }, codeSecurity: { ...missing }, compatibility: { ...missing } };
1623
+ }
1624
+ const dependency = app.dependencySecurity;
1625
+ let dependencySecurity;
1626
+ if (!dependency.complete || dependency.packagesScanned === 0 || dependency.errors.length) {
1627
+ dependencySecurity = { ...incomplete(["Dependency advisory scan did not cover the lockfile", ...dependency.errors], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories };
1628
+ } else {
1629
+ const high2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "high").length;
1630
+ const critical2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "critical").length;
1631
+ const unknown = dependency.advisories.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
1632
+ dependencySecurity = unknown.length ? { ...incomplete([`${unknown.length} advisories have unknown severity`], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories } : {
1633
+ status: high2 > policy.dependencySecurity.maximumHigh || critical2 > policy.dependencySecurity.maximumCritical ? "fail" : "pass",
1634
+ issues: [
1635
+ ...high2 > policy.dependencySecurity.maximumHigh ? [`${high2} high advisories exceed ${policy.dependencySecurity.maximumHigh}`] : [],
1636
+ ...critical2 > policy.dependencySecurity.maximumCritical ? [`${critical2} critical advisories exceed ${policy.dependencySecurity.maximumCritical}`] : []
1637
+ ],
1638
+ tool: dependency.tool,
1639
+ version: dependency.version,
1640
+ scanned: dependency.packagesScanned,
1641
+ findings: dependency.advisories
1642
+ };
1643
+ }
1644
+ const quality = app.codeQuality;
1645
+ const qualityMissing = [
1646
+ ...!quality.lint.complete || quality.lint.filesScanned === 0 ? ["ESLint did not scan source files", ...quality.lint.errorsDetail] : [],
1647
+ ...policy.codeQuality.requireTypecheck && !quality.typecheck.complete ? ["TypeScript typecheck did not complete", ...quality.typecheck.errors] : [],
1648
+ ...policy.codeQuality.requireTests && (!quality.tests.complete || quality.tests.passed + quality.tests.failed === 0) ? ["Tests did not run", ...quality.tests.errors] : [],
1649
+ ...!quality.coverage.complete ? ["Line coverage is unavailable", ...quality.coverage.errors] : []
1650
+ ];
1651
+ const qualityFailures = [
1652
+ ...quality.lint.errorCount > policy.codeQuality.maximumLintErrors ? [`${quality.lint.errorCount} ESLint errors exceed ${policy.codeQuality.maximumLintErrors}`] : [],
1653
+ ...policy.codeQuality.requireTypecheck && !quality.typecheck.passed ? ["TypeScript typecheck failed"] : [],
1654
+ ...quality.tests.failed > 0 ? [`${quality.tests.failed} tests failed`] : [],
1655
+ ...quality.coverage.linesPercent < policy.codeQuality.minimumLineCoverage ? [`Line coverage ${quality.coverage.linesPercent}% is below ${policy.codeQuality.minimumLineCoverage}%`] : []
1656
+ ];
1657
+ const qualityVersion = `ESLint ${quality.lint.version} \xB7 TypeScript ${quality.typecheck.version} \xB7 Vitest ${quality.tests.version}`;
1658
+ const qualityMetrics = {
1659
+ lintErrors: quality.lint.errorCount,
1660
+ lintWarnings: quality.lint.warnings,
1661
+ typecheck: quality.typecheck.applicable === false ? "not applicable" : quality.typecheck.passed ? "pass" : "fail",
1662
+ testsPassed: quality.tests.passed,
1663
+ testsFailed: quality.tests.failed,
1664
+ lineCoveragePercent: quality.coverage.linesPercent
1665
+ };
1666
+ const codeQuality = qualityMissing.length ? { ...incomplete([...qualityMissing, ...qualityFailures], "ESLint / TypeScript / tests", qualityVersion, quality.lint.filesScanned), metrics: qualityMetrics } : {
1667
+ status: qualityFailures.length ? "fail" : "pass",
1668
+ issues: qualityFailures,
1669
+ tool: "ESLint / TypeScript / tests",
1670
+ version: qualityVersion,
1671
+ scanned: quality.lint.filesScanned,
1672
+ metrics: qualityMetrics
1673
+ };
1674
+ const security = app.codeSecurity;
1675
+ const securityMissing = [
1676
+ ...!security.sast.complete || security.sast.filesScanned === 0 ? ["SAST did not scan source files", ...security.sast.errors] : [],
1677
+ ...!security.secrets.complete || security.secrets.filesScanned === 0 ? ["Secret scanner did not scan source files", ...security.secrets.errors] : []
1678
+ ];
1679
+ const high = security.sast.findings.filter((item) => item.severity.toLowerCase() === "high").length;
1680
+ const critical = security.sast.findings.filter((item) => item.severity.toLowerCase() === "critical").length;
1681
+ const unknownSecurity = security.sast.findings.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
1682
+ if (unknownSecurity.length) securityMissing.push(`${unknownSecurity.length} SAST findings have unknown severity`);
1683
+ const securityFailures = [
1684
+ ...high > policy.codeSecurity.maximumHigh ? [`${high} high SAST findings exceed ${policy.codeSecurity.maximumHigh}`] : [],
1685
+ ...critical > policy.codeSecurity.maximumCritical ? [`${critical} critical SAST findings exceed ${policy.codeSecurity.maximumCritical}`] : [],
1686
+ ...security.secrets.findings.length > policy.codeSecurity.maximumSecrets ? [`${security.secrets.findings.length} detected secrets exceed ${policy.codeSecurity.maximumSecrets}`] : []
1687
+ ];
1688
+ const securityVersion = `Semgrep ${security.sast.version} \xB7 Gitleaks ${security.secrets.version}`;
1689
+ const codeSecurity = securityMissing.length ? { ...incomplete([...securityMissing, ...securityFailures], "Semgrep / Gitleaks", securityVersion, security.sast.filesScanned), findings: [...security.sast.findings, ...security.secrets.findings] } : {
1690
+ status: securityFailures.length ? "fail" : "pass",
1691
+ issues: securityFailures,
1692
+ tool: "Semgrep / Gitleaks",
1693
+ version: securityVersion,
1694
+ scanned: security.sast.filesScanned,
1695
+ findings: [...security.sast.findings, ...security.secrets.findings]
1696
+ };
1697
+ const compatibility2 = app.compatibility;
1698
+ const projectNames = new Set(compatibility2.projects.map((project) => project.name));
1699
+ const missingProjects = policy.compatibility.requiredProjects.filter((name) => !projectNames.has(name));
1700
+ const incompleteProjects = compatibility2.projects.filter((project) => project.passed + project.failed < policy.compatibility.minimumTestsPerProject);
1701
+ const missingBrowserVersions = compatibility2.projects.filter((project) => !project.browserVersion);
1702
+ const expectedEngines = {
1703
+ "chromium-desktop": "chromium",
1704
+ "firefox-desktop": "firefox",
1705
+ "webkit-desktop": "webkit",
1706
+ "android-chrome": "chromium",
1707
+ "iphone-safari": "webkit"
1708
+ };
1709
+ const invalidEngines = compatibility2.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
1710
+ const invalidDevices = compatibility2.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
1711
+ const missingViewports = compatibility2.projects.filter((project) => new Set(project.viewportWidths ?? []).size < 2);
1712
+ const compatibilityMissing = [
1713
+ ...!compatibility2.complete ? ["Compatibility test run did not complete", ...compatibility2.errors] : [],
1714
+ ...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
1715
+ ...incompleteProjects.map((project) => `${project.name} ran fewer than ${policy.compatibility.minimumTestsPerProject} tests`),
1716
+ ...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
1717
+ ...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
1718
+ ...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
1719
+ ...missingViewports.map((project) => `${project.name} did not test two viewport widths`)
1720
+ ];
1721
+ const failedProjects = compatibility2.projects.filter((project) => project.failed > 0);
1722
+ const compatibilityDomain = compatibilityMissing.length ? { ...incomplete([...compatibilityMissing, ...failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`)], compatibility2.tool, compatibility2.version), matrix: compatibility2.projects } : {
1723
+ status: failedProjects.length ? "fail" : "pass",
1724
+ issues: failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`),
1725
+ tool: compatibility2.tool,
1726
+ version: compatibility2.version,
1727
+ scanned: compatibility2.projects.reduce((count, project) => count + project.passed + project.failed, 0),
1728
+ matrix: compatibility2.projects
1729
+ };
1730
+ return { dependencySecurity, codeQuality, codeSecurity, compatibility: compatibilityDomain };
1731
+ }
1732
+
1733
+ // src/governance/policy.ts
1734
+ import { createHash as createHash3 } from "crypto";
1735
+ import { readFile as readFile9 } from "fs/promises";
1736
+ import { resolve as resolve8 } from "path";
1464
1737
  import { valid as valid2 } from "semver";
1465
1738
  var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
1466
1739
  var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
@@ -1502,12 +1775,55 @@ function parseAssessmentPolicy(value) {
1502
1775
  for (const id of architecture.blockedRuleIds) {
1503
1776
  if (!known.has(id)) throw new Error(`Unknown blocked rule ID ${id}`);
1504
1777
  }
1778
+ let evidence;
1779
+ if (value.evidence !== void 0) {
1780
+ if (!record3(value.evidence)) throw new Error("assessment.evidence must be an object");
1781
+ const gates = value.evidence;
1782
+ const nonnegative2 = (source, key, label) => {
1783
+ const result = source[key];
1784
+ if (!Number.isSafeInteger(result) || result < 0) throw new Error(`${label}.${key} must be a nonnegative integer`);
1785
+ return result;
1786
+ };
1787
+ if (!Number.isFinite(gates.maximumAgeHours) || gates.maximumAgeHours <= 0 || gates.maximumAgeHours > 168) {
1788
+ throw new Error("assessment.evidence.maximumAgeHours must be greater than 0 and at most 168");
1789
+ }
1790
+ for (const domain of ["dependencySecurity", "codeQuality", "codeSecurity", "compatibility"]) {
1791
+ if (!record3(gates[domain])) throw new Error(`assessment.evidence.${domain} is required`);
1792
+ }
1793
+ const dependencySecurity = gates.dependencySecurity;
1794
+ const codeQuality = gates.codeQuality;
1795
+ const codeSecurity = gates.codeSecurity;
1796
+ const compatibility2 = gates.compatibility;
1797
+ const minimumLineCoverage = codeQuality.minimumLineCoverage;
1798
+ if (typeof minimumLineCoverage !== "number" || !Number.isFinite(minimumLineCoverage) || minimumLineCoverage < 0 || minimumLineCoverage > 100) {
1799
+ throw new Error("assessment.evidence.codeQuality.minimumLineCoverage must be 0\u2013100");
1800
+ }
1801
+ if (typeof codeQuality.requireTypecheck !== "boolean" || typeof codeQuality.requireTests !== "boolean") {
1802
+ throw new Error("assessment.evidence.codeQuality requires boolean requireTypecheck and requireTests");
1803
+ }
1804
+ const projects = compatibility2.requiredProjects;
1805
+ if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
1806
+ throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
1807
+ }
1808
+ evidence = {
1809
+ maximumAgeHours: gates.maximumAgeHours,
1810
+ dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
1811
+ codeQuality: {
1812
+ maximumLintErrors: nonnegative2(codeQuality, "maximumLintErrors", "assessment.evidence.codeQuality"),
1813
+ minimumLineCoverage,
1814
+ requireTypecheck: codeQuality.requireTypecheck,
1815
+ requireTests: codeQuality.requireTests
1816
+ },
1817
+ codeSecurity: { maximumHigh: nonnegative2(codeSecurity, "maximumHigh", "assessment.evidence.codeSecurity"), maximumCritical: nonnegative2(codeSecurity, "maximumCritical", "assessment.evidence.codeSecurity"), maximumSecrets: nonnegative2(codeSecurity, "maximumSecrets", "assessment.evidence.codeSecurity") },
1818
+ compatibility: { requiredProjects: projects, minimumTestsPerProject: nonnegative2(compatibility2, "minimumTestsPerProject", "assessment.evidence.compatibility") }
1819
+ };
1820
+ }
1505
1821
  return { architecture: {
1506
1822
  minimumScore: architecture.minimumScore,
1507
1823
  maximumErrors: architecture.maximumErrors,
1508
1824
  blockedRuleIds: architecture.blockedRuleIds,
1509
1825
  analysis
1510
- } };
1826
+ }, ...evidence ? { evidence } : {} };
1511
1827
  }
1512
1828
  function parsePolicy(raw, source) {
1513
1829
  let parsed;
@@ -1531,11 +1847,11 @@ function parsePolicy(raw, source) {
1531
1847
  throw new Error(`Policy profile ${name} must list packages`);
1532
1848
  }
1533
1849
  const versions = {};
1534
- for (const [packageName, version] of Object.entries(rules)) {
1535
- if (typeof version !== "string" || !minimumVersion.test(version) || valid2(version.slice(2)) === null) {
1850
+ for (const [packageName, version2] of Object.entries(rules)) {
1851
+ if (typeof version2 !== "string" || !minimumVersion.test(version2) || valid2(version2.slice(2)) === null) {
1536
1852
  throw new Error(`Policy minimum version (>=X.Y.Z) required for ${packageName} in ${name}`);
1537
1853
  }
1538
- versions[packageName] = version;
1854
+ versions[packageName] = version2;
1539
1855
  }
1540
1856
  profiles3[name] = versions;
1541
1857
  }
@@ -1545,7 +1861,7 @@ function parsePolicy(raw, source) {
1545
1861
  profiles: profiles3,
1546
1862
  ...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
1547
1863
  source,
1548
- sha256: createHash2("sha256").update(raw).digest("hex")
1864
+ sha256: createHash3("sha256").update(raw).digest("hex")
1549
1865
  };
1550
1866
  }
1551
1867
  async function loadPolicy(source) {
@@ -1562,7 +1878,7 @@ async function loadPolicy(source) {
1562
1878
  }
1563
1879
  return parsePolicy(await response.text(), source);
1564
1880
  }
1565
- return parsePolicy(await readFile8(resolve7(source), "utf8"), source);
1881
+ return parsePolicy(await readFile9(resolve8(source), "utf8"), source);
1566
1882
  }
1567
1883
 
1568
1884
  // src/governance/assess.ts
@@ -1585,27 +1901,50 @@ function escapeHtml2(value) {
1585
1901
  return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1586
1902
  }
1587
1903
  function renderAssessmentHtml(report) {
1904
+ const labels = {
1905
+ inventory: "App inventory",
1906
+ dependencies: "Dependency policy",
1907
+ architecture: "Architecture",
1908
+ dependencySecurity: "Dependency security",
1909
+ codeQuality: "Code quality",
1910
+ codeSecurity: "Code security",
1911
+ compatibility: "Browser compatibility"
1912
+ };
1913
+ const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
1914
+ const overview = domainNames.map((name) => {
1915
+ const statuses = report.apps.map((app) => app.domains[name]?.status ?? "incomplete");
1916
+ const status = statusOf(statuses);
1917
+ const passed = statuses.filter((item) => item === "pass").length;
1918
+ return `<li><span>${escapeHtml2(labels[name] ?? name)}</span><strong class="${status}">${escapeHtml2(status)}</strong><small>${passed}/${report.apps.length} apps passed</small></li>`;
1919
+ }).join("");
1588
1920
  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("");
1921
+ const domains = Object.entries(app.domains).map(([name, domain]) => {
1922
+ const evidence = domain;
1923
+ const identity = evidence.tool ? `<p><small>${escapeHtml2(evidence.tool)} ${escapeHtml2(evidence.version ?? "")} \xB7 scanned ${escapeHtml2(evidence.scanned ?? "unknown")}</small></p>` : "";
1924
+ const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml2(key)}: ${escapeHtml2(value)}`).join(" \xB7 ")}</small></p>` : "";
1925
+ const findings3 = evidence.findings?.length ? `<details><summary>${evidence.findings.length} finding(s)</summary><ul>${evidence.findings.map((item) => `<li><strong>${escapeHtml2(item.severity)}</strong> ${escapeHtml2(item.id)} \xB7 <code>${escapeHtml2(item.subject)}</code> \u2014 ${escapeHtml2(item.message)}${item.remediation ? ` \xB7 ${escapeHtml2(item.remediation)}` : ""}</li>`).join("")}</ul></details>` : "";
1926
+ const matrix = evidence.matrix?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>Browser / device</th><th>Widths</th></tr></thead><tbody>${evidence.matrix.map((item) => `<tr><th scope="row">${escapeHtml2(item.name)}</th><td>${item.passed}</td><td>${item.failed}</td><td>${item.skipped}</td><td>${escapeHtml2([item.browserVersion, item.device].filter(Boolean).join(" \xB7 ") || "Not recorded")}</td><td>${escapeHtml2(item.viewportWidths?.join(", ") ?? "Not recorded")}</td></tr>`).join("")}</tbody></table></div>` : "";
1927
+ return `<tr><th scope="row">${escapeHtml2(labels[name] ?? name)}</th><td class="${domain.status}">${escapeHtml2(domain.status)}</td><td>${identity}${metrics}${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : "No issues"}${findings3}${matrix}</td></tr>`;
1928
+ }).join("");
1590
1929
  const architecture = app.domains.architecture;
1591
1930
  const categoryRows = architecture.scoreBreakdown?.categories.map((category) => `<tr><th scope="row">${escapeHtml2(category.id)}</th><td>${escapeHtml2(category.weight)}%</td><td>${escapeHtml2(category.score)}</td><td>${escapeHtml2(category.findingCount)}</td></tr>`).join("") ?? "";
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>`;
1931
+ const findings2 = architecture.findings.map((finding) => `<tr><td>${escapeHtml2(finding.severity)}</td><td>${escapeHtml2(finding.ruleId)}</td><td><code>${escapeHtml2(finding.file)}:${finding.line}</code></td><td>${escapeHtml2(finding.evidence)}</td></tr>`).join("");
1932
+ return `<section class="app"><h2>${escapeHtml2(app.path)} <span class="${app.status}">${escapeHtml2(app.status)}</span></h2><p>Profile: ${escapeHtml2(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${findings2 ? `<details><summary>Architecture findings (${architecture.findings.length})</summary><div class="scroll"><table><thead><tr><th>Severity</th><th>Rule</th><th>Location</th><th>Evidence</th></tr></thead><tbody>${findings2}</tbody></table></div></details>` : ""}</section>`;
1594
1933
  }).join("");
1595
1934
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>NKLattice governance assessment</title><style>
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>
1935
+ :root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#f3f6f4;color:#1b2925}body{max-width:1160px;margin:auto;padding:2rem 1rem}header,.app,.meta,.overview{background:white;border:1px solid #d9e1dc;border-radius:12px;padding:1.4rem;margin:0 0 1.2rem}header{background:#13231d;color:#f5f8f4;padding:2rem}header small{color:#9de2bc;font-weight:700;letter-spacing:.12em}header p{color:#c8d6ce}h1{font-size:clamp(2rem,5vw,3.5rem);line-height:1.08;margin:.3rem 0 1rem}h2{font-size:1.35rem;margin:0 0 .75rem}p{margin:.5rem 0}code{overflow-wrap:anywhere}.pass{color:#146c43;font-weight:700}.fail{color:#b3242b;font-weight:700}.incomplete{color:#8a5500;font-weight:700}header .pass{color:#9de2bc}.scroll{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d9e1dc;padding:.8rem}th:first-child{width:12rem}ul{padding-left:1.2rem;margin:.1rem 0}li+li{margin-top:.3rem}small{color:#50645c}details{border-top:1px solid #d9e1dc;margin-top:1rem;padding-top:1rem}summary{cursor:pointer;font-weight:650}details[open] summary{margin-bottom:.6rem}.domain-overview{list-style:none;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.65rem;padding:0;margin:1rem 0 0}.domain-overview li{border:1px solid #d9e1dc;background:#f8faf8;border-radius:8px;padding:.9rem;display:grid;grid-template-columns:1fr auto;gap:.35rem}.domain-overview li small{grid-column:1/-1}.domain-overview li+li{margin:0}
1936
+ @media(max-width:800px){.domain-overview{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:480px){.domain-overview{grid-template-columns:1fr}}</style></head><body><header><small>NKLATTICE \xB7 CONFIGURED POLICY ASSESSMENT</small><h1>Project governance <span class="${report.status}">${escapeHtml2(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s) \xB7 policy ${escapeHtml2(report.policy.version)}</p></header><section class="overview"><h2>Assessment by domain</h2><p>Each status is based on the checked apps and fresh scanner evidence. Open an app below for findings and browser versions.</p><ul class="domain-overview">${overview}</ul></section><section class="meta"><h2>Evidence identity</h2><p>Policy ${escapeHtml2(report.policy.version)} \xB7 <code>${escapeHtml2(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml2(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml2(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml2(basename2(report.inventory.source))}</code></p>${report.evidence ? `<p>Scanners: ${escapeHtml2(report.evidence.generatedAt)} \xB7 commit <code>${escapeHtml2(report.evidence.commit)}</code> \xB7 input <code>${escapeHtml2(report.evidence.inputSha256)}</code></p>` : "<p>Scanner evidence unavailable.</p>"}${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml2).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml2).join(", ")}.</p></section></body></html>
1598
1937
  `;
1599
1938
  }
1600
1939
  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");
1940
+ const scanRoot = resolve9(options.scanRoot);
1941
+ const workspaceRoot = resolve9(options.workspaceRoot ?? scanRoot);
1942
+ const scanRelative = relative7(workspaceRoot, scanRoot);
1943
+ if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
1605
1944
  if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
1606
1945
  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"));
1946
+ const outputDirectory = resolve9(options.outputDirectory ?? join10(scanRoot, ".nklattice-governance"));
1947
+ const inventorySource = resolve9(options.inventory ?? join10(scanRoot, ".nklattice-apps.json"));
1609
1948
  let inventory;
1610
1949
  const issues = [];
1611
1950
  try {
@@ -1624,9 +1963,22 @@ async function assessGovernance(options) {
1624
1963
  if (options.expectedApps !== void 0 && apps.length !== options.expectedApps) {
1625
1964
  issues.push(`Expected ${options.expectedApps} managed apps; discovered ${apps.length}`);
1626
1965
  }
1966
+ let evidence;
1967
+ if (assessment.evidence) {
1968
+ if (!options.evidence && !options.trustedEvidence) issues.push("Scan evidence is required by the policy; run governance collect and pass --evidence");
1969
+ else try {
1970
+ evidence = options.trustedEvidence ? await validateScanEvidence(options.trustedEvidence, scanRoot, workspaceRoot, options.policy.sha256, assessment.evidence.maximumAgeHours) : await loadScanEvidence(options.evidence, scanRoot, workspaceRoot, options.policy.sha256, assessment.evidence.maximumAgeHours);
1971
+ } catch (error) {
1972
+ issues.push(`Scan evidence: ${error instanceof Error ? error.message : String(error)}`);
1973
+ }
1974
+ if (evidence) {
1975
+ const discovered = new Set(apps.map((app) => relative7(scanRoot, app.directory).replaceAll("\\", "/") || "."));
1976
+ for (const item of evidence.apps) if (!discovered.has(item.path)) issues.push(`Scan evidence contains unrecognized app ${item.path}`);
1977
+ }
1978
+ }
1627
1979
  const results = [];
1628
1980
  for (const app of apps) {
1629
- const appPath = relative6(scanRoot, app.directory).replaceAll("\\", "/") || ".";
1981
+ const appPath = relative7(scanRoot, app.directory).replaceAll("\\", "/") || ".";
1630
1982
  const inventoryIssues = issues.filter((issue) => issue.includes(appPath) || issue.startsWith("Inventory:") || issue.startsWith("Discovery:"));
1631
1983
  const inventoryDomain = { status: inventoryIssues.length ? "incomplete" : "pass", issues: inventoryIssues };
1632
1984
  const dependencyDomain = { status: "pass", issues: [], findings: [] };
@@ -1679,7 +2031,8 @@ async function assessGovernance(options) {
1679
2031
  architectureDomain.status = "incomplete";
1680
2032
  architectureDomain.issues.push(error instanceof Error ? error.message : String(error));
1681
2033
  }
1682
- const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain };
2034
+ const extra = assessment.evidence ? evaluateAppEvidence(evidence?.apps.find((item) => item.path === appPath && item.profile === app.profile), assessment.evidence) : {};
2035
+ const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain, ...extra };
1683
2036
  results.push({ path: appPath, profile: app.profile, status: statusOf(Object.values(domains).map((domain) => domain.status)), domains });
1684
2037
  }
1685
2038
  const report = {
@@ -1687,21 +2040,531 @@ async function assessGovernance(options) {
1687
2040
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1688
2041
  status: issues.length ? "incomplete" : statusOf(results.map((app) => app.status)),
1689
2042
  policy: { version: options.policy.policyVersion, source: options.policy.source, sha256: options.policy.sha256 },
2043
+ ...evidence ? { evidence: { source: options.evidence ? resolve9(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
1690
2044
  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 },
2045
+ scope: {
2046
+ domains: ["explicit app inventory", "dependency minimums", "source architecture", ...assessment.evidence ? ["known dependency advisories", "source quality", "source security", "browser and mobile emulation"] : []],
2047
+ notEvaluated: assessment.evidence ? ["licenses", "physical Android/iOS devices", "runtime performance", "cryptographic evidence attestation", "branch protection", "repository access controls", "cross-repository rollout status"] : notEvaluated
2048
+ },
1692
2049
  issues,
1693
2050
  apps: results
1694
2051
  };
1695
2052
  await mkdir3(outputDirectory, { recursive: true });
1696
- await writeFile2(join9(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
2053
+ await writeFile2(join10(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
1697
2054
  `);
1698
- await writeFile2(join9(outputDirectory, "report.html"), renderAssessmentHtml(report));
2055
+ await writeFile2(join10(outputDirectory, "report.html"), renderAssessmentHtml(report));
2056
+ return report;
2057
+ }
2058
+
2059
+ // src/governance/collect.ts
2060
+ import { execFile as execFile2 } from "child_process";
2061
+ import { createHash as createHash4 } from "crypto";
2062
+ import { mkdtemp, mkdir as mkdir4, readFile as readFile10, rm, writeFile as writeFile3 } from "fs/promises";
2063
+ import { tmpdir } from "os";
2064
+ import { dirname as dirname4, join as join11, relative as relative8, resolve as resolve10, sep as sep7 } from "path";
2065
+ import { promisify as promisify2 } from "util";
2066
+ import fg2 from "fast-glob";
2067
+ import YAML2 from "yaml";
2068
+ var execFileAsync2 = promisify2(execFile2);
2069
+ var cap = 16 * 1024 * 1024;
2070
+ async function run(file, args, cwd, env) {
2071
+ try {
2072
+ const result = await execFileAsync2(file, args, { cwd, env, timeout: 3e5, maxBuffer: cap });
2073
+ return { code: 0, stdout: result.stdout, stderr: result.stderr };
2074
+ } catch (error) {
2075
+ const failure = error;
2076
+ return {
2077
+ code: typeof failure.code === "number" ? failure.code : -1,
2078
+ stdout: failure.stdout ?? "",
2079
+ stderr: failure.stderr ?? "",
2080
+ ...typeof failure.code === "number" ? {} : { error: failure.killed ? "Scanner timed out" : failure.message }
2081
+ };
2082
+ }
2083
+ }
2084
+ function inside(parent, child) {
2085
+ const path = relative8(parent, child);
2086
+ return path === "" || path !== ".." && !path.startsWith(`..${sep7}`);
2087
+ }
2088
+ async function binary(name, app, workspaceRoot) {
2089
+ let directory = app;
2090
+ while (inside(workspaceRoot, directory)) {
2091
+ const candidate = join11(directory, "node_modules", ".bin", name);
2092
+ try {
2093
+ await readFile10(candidate);
2094
+ return candidate;
2095
+ } catch {
2096
+ }
2097
+ if (directory === workspaceRoot) break;
2098
+ directory = dirname4(directory);
2099
+ }
2100
+ return name;
2101
+ }
2102
+ async function version(file, cwd) {
2103
+ const result = await run(file, ["--version"], cwd);
2104
+ if (result.code !== 0) return "unavailable";
2105
+ const reported = result.stdout.trim().slice(0, 120) || "unknown";
2106
+ if (file === "gitleaks" && reported.includes("version is set by build process")) {
2107
+ const located = await run("which", [file], cwd);
2108
+ if (located.code === 0) {
2109
+ const build = await run("go", ["version", "-m", located.stdout.trim()], cwd);
2110
+ const match = build.stdout.match(/^\s*mod\s+\S+\/gitleaks\/v8\s+(v\d+\.\d+\.\d+)/m);
2111
+ if (match) return match[1];
2112
+ }
2113
+ }
2114
+ return reported;
2115
+ }
2116
+ function json(text) {
2117
+ try {
2118
+ return JSON.parse(text);
2119
+ } catch {
2120
+ return void 0;
2121
+ }
2122
+ }
2123
+ function obj(value) {
2124
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2125
+ }
2126
+ function str(value) {
2127
+ return typeof value === "string" ? value : "";
2128
+ }
2129
+ function num(value) {
2130
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
2131
+ }
2132
+ async function sourcePaths(app) {
2133
+ return fg2("**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}", {
2134
+ cwd: app,
2135
+ dot: true,
2136
+ onlyFiles: true,
2137
+ followSymbolicLinks: false,
2138
+ ignore: ["**/node_modules/**", "**/dist/**", "**/.next/**", "**/build/**", "**/coverage/**", "**/*.d.ts"]
2139
+ });
2140
+ }
2141
+ async function sourceCount(app) {
2142
+ return (await sourcePaths(app)).length;
2143
+ }
2144
+ async function productionSources(app) {
2145
+ return (await sourcePaths(app)).filter((path) => !/(^|\/)(e2e|tests?|__tests__|fixtures)\//.test(path) && !/\.(test|spec|config)\.[cm]?[jt]sx?$/.test(path));
2146
+ }
2147
+ async function dependencyAudit(app, workspaceRoot, cache) {
2148
+ const failure = (errors2, tool = "npm/pnpm audit") => ({ tool, version: "unavailable", complete: false, packagesScanned: 0, advisories: [], errors: errors2 });
2149
+ let lock;
2150
+ try {
2151
+ lock = await findLockfile(app.directory, workspaceRoot);
2152
+ } catch (error) {
2153
+ return failure([error instanceof Error ? error.message : String(error)]);
2154
+ }
2155
+ const cached = cache.get(lock.path);
2156
+ if (cached) return cached;
2157
+ const raw = await readFile10(lock.path, "utf8");
2158
+ const parsedLock = lock.kind === "npm" ? json(raw) : YAML2.parse(raw);
2159
+ const packages = obj(parsedLock) && obj(parsedLock.packages) ? Object.keys(parsedLock.packages).filter((key) => key !== "").length : 0;
2160
+ const executable = lock.kind === "npm" ? "npm" : "pnpm";
2161
+ const toolVersion = await version(executable, lock.root);
2162
+ const result = await run(executable, ["audit", "--json"], lock.root);
2163
+ const report = json(result.stdout);
2164
+ if (!obj(report)) {
2165
+ const evidence2 = { ...failure([result.error ?? "Audit JSON output is unavailable"], `${executable} audit`), version: toolVersion, packagesScanned: packages };
2166
+ cache.set(lock.path, evidence2);
2167
+ return evidence2;
2168
+ }
2169
+ const auditMetadata = obj(report.metadata) ? report.metadata : void 0;
2170
+ const auditedPackages = lock.kind === "npm" && auditMetadata && obj(auditMetadata.dependencies) ? num(auditMetadata.dependencies.total) : lock.kind === "pnpm" && auditMetadata ? num(auditMetadata.totalDependencies) : 0;
2171
+ const supportedShape = lock.kind === "npm" ? report.auditReportVersion === 2 && obj(report.vulnerabilities) : obj(report.advisories);
2172
+ if (!supportedShape || packages === 0 || auditedPackages < packages || result.error || result.code > 1) {
2173
+ const evidence2 = { ...failure([result.error ?? "Audit output was unavailable or lockfile package inventory was empty"], `${executable} audit`), version: toolVersion, packagesScanned: packages };
2174
+ evidence2.errors = [result.error ?? `Audit schema or package coverage is incomplete (${auditedPackages}/${packages})`];
2175
+ cache.set(lock.path, evidence2);
2176
+ return evidence2;
2177
+ }
2178
+ const advisories = [];
2179
+ if (lock.kind === "npm" && obj(report.vulnerabilities)) {
2180
+ for (const [name, value] of Object.entries(report.vulnerabilities)) {
2181
+ if (!obj(value)) continue;
2182
+ const via = Array.isArray(value.via) ? value.via.find(obj) : void 0;
2183
+ advisories.push({
2184
+ id: obj(via) ? str(via.url) || str(via.title) || `npm:${name}` : `npm:${name}`,
2185
+ severity: str(value.severity) || "unknown",
2186
+ subject: name,
2187
+ message: "Known advisory affects a dependency",
2188
+ remediation: "Upgrade the affected package or apply a reviewed exception"
2189
+ });
2190
+ }
2191
+ } else if (lock.kind === "pnpm" && obj(report.advisories)) {
2192
+ for (const [key, value] of Object.entries(report.advisories)) {
2193
+ if (!obj(value)) continue;
2194
+ advisories.push({
2195
+ id: str(value.url) || str(value.id) || key,
2196
+ severity: str(value.severity) || "unknown",
2197
+ subject: str(value.module_name) || "workspace dependency",
2198
+ message: "Known advisory affects a workspace dependency",
2199
+ remediation: "Upgrade the affected package or apply a reviewed exception"
2200
+ });
2201
+ }
2202
+ } else if (result.code !== 0) {
2203
+ const evidence2 = { tool: `${executable} audit`, version: toolVersion, complete: false, packagesScanned: packages, advisories: [], errors: ["Audit returned an unsupported JSON shape"] };
2204
+ cache.set(lock.path, evidence2);
2205
+ return evidence2;
2206
+ }
2207
+ const registryErrors = [report.error, report.errors].filter(Boolean);
2208
+ const evidence = {
2209
+ tool: `${executable} audit${lock.root !== app.directory ? " (workspace-wide)" : ""}`,
2210
+ version: toolVersion,
2211
+ complete: registryErrors.length === 0,
2212
+ packagesScanned: packages,
2213
+ advisories,
2214
+ errors: registryErrors.length ? ["Advisory service returned an error"] : []
2215
+ };
2216
+ cache.set(lock.path, evidence);
2217
+ return evidence;
2218
+ }
2219
+ async function lint(app, workspaceRoot) {
2220
+ const file = await binary("eslint", app.directory, workspaceRoot);
2221
+ const toolVersion = await version(file, app.directory);
2222
+ const result = await run(file, [".", "--format", "json"], app.directory);
2223
+ const parsed = json(result.stdout);
2224
+ if (!Array.isArray(parsed) || result.error || !parsed.every(obj)) {
2225
+ return {
2226
+ tool: "ESLint",
2227
+ version: toolVersion,
2228
+ complete: false,
2229
+ filesScanned: 0,
2230
+ errorCount: 0,
2231
+ warnings: 0,
2232
+ errorsDetail: [result.error ?? "ESLint JSON output is unavailable; install and configure ESLint"]
2233
+ };
2234
+ }
2235
+ const files = parsed;
2236
+ if (!files.every((item) => str(item.filePath) && Number.isSafeInteger(item.errorCount) && Number.isSafeInteger(item.warningCount))) {
2237
+ return {
2238
+ tool: "ESLint",
2239
+ version: toolVersion,
2240
+ complete: false,
2241
+ filesScanned: 0,
2242
+ errorCount: 0,
2243
+ warnings: 0,
2244
+ errorsDetail: ["ESLint JSON result did not contain reliable file paths or diagnostic counts"]
2245
+ };
2246
+ }
2247
+ const expected = await sourcePaths(app.directory);
2248
+ const analyzed = new Set(files.map((item) => str(item.filePath).replaceAll("\\", "/")));
2249
+ const missing = expected.filter((path) => ![...analyzed].some((file2) => file2.endsWith(`/${path}`) || file2 === path));
2250
+ return {
2251
+ tool: "ESLint",
2252
+ version: toolVersion,
2253
+ complete: missing.length === 0,
2254
+ filesScanned: expected.length - missing.length,
2255
+ errorCount: files.reduce((sum, file2) => sum + num(file2.errorCount), 0),
2256
+ warnings: files.reduce((sum, file2) => sum + num(file2.warningCount), 0),
2257
+ errorsDetail: missing.length ? [`ESLint skipped ${missing.length} eligible source files`] : []
2258
+ };
2259
+ }
2260
+ async function typecheck(app, workspaceRoot) {
2261
+ const production = await productionSources(app.directory);
2262
+ const applicable = production.some((path) => /\.(ts|tsx|mts|cts)$/.test(path));
2263
+ const file = await binary("tsc", app.directory, workspaceRoot);
2264
+ const toolVersion = await version(file, app.directory);
2265
+ if (!applicable) return { tool: "TypeScript", version: toolVersion, applicable: false, complete: true, passed: true, errors: [] };
2266
+ const config = await readFile10(join11(app.directory, "tsconfig.json"), "utf8").catch(() => "");
2267
+ if (!config) return {
2268
+ tool: "TypeScript",
2269
+ version: toolVersion,
2270
+ applicable: true,
2271
+ complete: false,
2272
+ passed: false,
2273
+ errors: ["TypeScript source has no readable tsconfig.json"]
2274
+ };
2275
+ const result = await run(file, ["--project", app.directory, "--noEmit", "--pretty", "false"], app.directory);
2276
+ return {
2277
+ tool: "TypeScript",
2278
+ version: toolVersion,
2279
+ applicable: true,
2280
+ complete: !result.error && toolVersion !== "unavailable",
2281
+ passed: result.code === 0,
2282
+ errors: result.error ? [result.error] : result.code === 0 ? [] : [result.stdout.trim().slice(0, 500) || "TypeScript typecheck failed"]
2283
+ };
2284
+ }
2285
+ async function testAndCoverage(app, workspaceRoot, temp) {
2286
+ const file = await binary("vitest", app.directory, workspaceRoot);
2287
+ const toolVersion = await version(file, app.directory);
2288
+ const resultPath = join11(temp, "vitest.json");
2289
+ const coverageDirectory = join11(temp, "coverage");
2290
+ const result = await run(file, [
2291
+ "run",
2292
+ "--reporter=json",
2293
+ "--outputFile",
2294
+ resultPath,
2295
+ "--coverage",
2296
+ "--coverage.reporter=json-summary",
2297
+ "--coverage.reportsDirectory",
2298
+ coverageDirectory
2299
+ ], app.directory);
2300
+ const output = await readFile10(resultPath, "utf8").catch(() => "");
2301
+ const parsed = json(output);
2302
+ const summary = json(await readFile10(join11(coverageDirectory, "coverage-summary.json"), "utf8").catch(() => ""));
2303
+ const tests = obj(parsed) && typeof parsed.numPassedTests === "number" && typeof parsed.numFailedTests === "number";
2304
+ const lineCoverage = obj(summary) && obj(summary.total) && obj(summary.total.lines) ? summary.total.lines.pct : void 0;
2305
+ const expectedFiles = await productionSources(app.directory);
2306
+ const coveredFiles = obj(summary) ? Object.keys(summary).filter((key) => key !== "total") : [];
2307
+ const coveredPaths = new Set(coveredFiles.map((file2) => resolve10(app.directory, file2)));
2308
+ const uncovered = expectedFiles.filter((path) => !coveredPaths.has(resolve10(app.directory, path)));
2309
+ const coverage = typeof lineCoverage === "number" && expectedFiles.length > 0 && uncovered.length === 0;
2310
+ const unhandled = obj(parsed) && Array.isArray(parsed.unhandledErrors) && parsed.unhandledErrors.length > 0;
2311
+ const reporterFailedWithoutTests = tests && result.code !== 0 && num(parsed.numFailedTests) === 0;
2312
+ return {
2313
+ tests: {
2314
+ tool: "Vitest",
2315
+ version: toolVersion,
2316
+ complete: tests && !result.error && !unhandled && !reporterFailedWithoutTests,
2317
+ passed: tests ? num(parsed.numPassedTests) : 0,
2318
+ failed: tests ? num(parsed.numFailedTests) : 0,
2319
+ errors: tests && !unhandled && !reporterFailedWithoutTests ? [] : [result.error ?? "Vitest reporter recorded an unsuccessful run or unhandled error"]
2320
+ },
2321
+ coverage: {
2322
+ tool: "Vitest coverage",
2323
+ version: toolVersion,
2324
+ complete: coverage,
2325
+ linesPercent: coverage ? lineCoverage : 0,
2326
+ errors: coverage ? [] : [uncovered.length ? `Coverage omitted ${uncovered.length} eligible source files` : "Vitest line coverage JSON summary is unavailable"]
2327
+ }
2328
+ };
2329
+ }
2330
+ async function sast(app, temp) {
2331
+ const file = "semgrep";
2332
+ const toolVersion = await version(file, app.directory);
2333
+ const outputPath = join11(temp, "semgrep.json");
2334
+ const result = await run(file, [
2335
+ "scan",
2336
+ "--config",
2337
+ "p/ci",
2338
+ "--no-git-ignore",
2339
+ "--exclude",
2340
+ "**/node_modules/**",
2341
+ "--exclude",
2342
+ "**/.next/**",
2343
+ "--exclude",
2344
+ "**/coverage/**",
2345
+ "--exclude",
2346
+ "**/dist/**",
2347
+ "--json",
2348
+ "--output",
2349
+ outputPath,
2350
+ app.directory
2351
+ ], app.directory);
2352
+ const parsed = json(await readFile10(outputPath, "utf8").catch(() => result.stdout));
2353
+ const expectedSources = await sourcePaths(app.directory);
2354
+ if (!obj(parsed) || !Array.isArray(parsed.results) || result.error) {
2355
+ return {
2356
+ tool: "Semgrep",
2357
+ version: toolVersion,
2358
+ complete: false,
2359
+ filesScanned: 0,
2360
+ findings: [],
2361
+ errors: [result.error ?? "Semgrep JSON result is unavailable; install Semgrep and enable rule download"]
2362
+ };
2363
+ }
2364
+ const findings2 = parsed.results.filter(obj).map((item) => {
2365
+ const extra = obj(item.extra) ? item.extra : {};
2366
+ const metadata = obj(extra.metadata) ? extra.metadata : {};
2367
+ const impact = str(metadata.impact).toLowerCase();
2368
+ const severity = impact === "high" || impact === "critical" ? impact : str(extra.severity).toLowerCase() === "error" ? "high" : "moderate";
2369
+ return {
2370
+ id: str(item.check_id) || "semgrep:unknown",
2371
+ severity,
2372
+ subject: `${relative8(app.directory, str(item.path) || app.directory)}:${obj(item.start) ? num(item.start.line) : 0}`,
2373
+ message: "Static security rule matched; inspect the source location",
2374
+ remediation: "Review and fix the matched rule"
2375
+ };
2376
+ });
2377
+ const scannedPaths = obj(parsed.paths) && Array.isArray(parsed.paths.scanned) ? parsed.paths.scanned.filter((item) => typeof item === "string") : [];
2378
+ const missing = expectedSources.filter((path) => !scannedPaths.some((scanned) => scanned.replaceAll("\\", "/").endsWith(`/${path}`) || scanned === path));
2379
+ const scanErrors = [
2380
+ ...Array.isArray(parsed.errors) && parsed.errors.length ? ["Semgrep reported scanner errors"] : [],
2381
+ ...missing.length ? [`Semgrep skipped ${missing.length} eligible source files`] : []
2382
+ ];
2383
+ return { tool: "Semgrep", version: toolVersion, complete: scanErrors.length === 0, filesScanned: expectedSources.length - missing.length, findings: findings2, errors: scanErrors };
2384
+ }
2385
+ async function secrets(app, temp) {
2386
+ const file = "gitleaks";
2387
+ const toolVersion = await version(file, app.directory);
2388
+ const outputPath = join11(temp, "gitleaks.json");
2389
+ const configPath = join11(temp, "gitleaks.toml");
2390
+ await writeFile3(configPath, '[extend]\nuseDefault = true\n\n[allowlist]\npaths = ["(^|/)(node_modules|\\\\.next|dist|build|coverage|test-results|playwright-report)/"]\n');
2391
+ const result = await run(file, [
2392
+ "dir",
2393
+ app.directory,
2394
+ "--config",
2395
+ configPath,
2396
+ "--report-format",
2397
+ "json",
2398
+ "--report-path",
2399
+ outputPath,
2400
+ "--redact",
2401
+ "--no-banner"
2402
+ ], app.directory);
2403
+ const parsed = json(await readFile10(outputPath, "utf8").catch(() => ""));
2404
+ const count = await sourceCount(app.directory);
2405
+ if (!Array.isArray(parsed) || result.error) {
2406
+ return {
2407
+ tool: "Gitleaks",
2408
+ version: toolVersion,
2409
+ complete: false,
2410
+ filesScanned: 0,
2411
+ findings: [],
2412
+ errors: [result.error ?? "Gitleaks JSON result is unavailable; install Gitleaks"]
2413
+ };
2414
+ }
2415
+ const findings2 = parsed.filter(obj).map((item) => ({
2416
+ id: str(item.RuleID) || "gitleaks:secret",
2417
+ severity: "critical",
2418
+ subject: `${str(item.File) ? relative8(app.directory, resolve10(app.directory, str(item.File))).replaceAll("\\", "/") : "source"}:${num(item.StartLine)}`,
2419
+ message: `Potential secret detected (fingerprint ${createHash4("sha256").update(str(item.Fingerprint)).digest("hex").slice(0, 16)})`,
2420
+ remediation: "Revoke the credential and remove it from source/history"
2421
+ }));
2422
+ return {
2423
+ tool: "Gitleaks",
2424
+ version: toolVersion,
2425
+ complete: result.code === 0 || result.code === 1,
2426
+ filesScanned: count,
2427
+ findings: findings2,
2428
+ errors: result.code > 1 ? ["Gitleaks scanner failed"] : []
2429
+ };
2430
+ }
2431
+ function playwrightProjects(value) {
2432
+ if (!obj(value) || !Array.isArray(value.suites)) return [];
2433
+ const totals = /* @__PURE__ */ new Map();
2434
+ const visit = (suite) => {
2435
+ if (!obj(suite)) return;
2436
+ for (const spec of Array.isArray(suite.specs) ? suite.specs : []) {
2437
+ if (!obj(spec) || !Array.isArray(spec.tests)) continue;
2438
+ for (const test of spec.tests) {
2439
+ if (!obj(test) || !str(test.projectName)) continue;
2440
+ const projectName = str(test.projectName);
2441
+ const project = totals.get(projectName) ?? {
2442
+ name: projectName,
2443
+ passed: 0,
2444
+ failed: 0,
2445
+ skipped: 0,
2446
+ viewportWidths: /* @__PURE__ */ new Set()
2447
+ };
2448
+ for (const annotation of Array.isArray(test.annotations) ? test.annotations : []) {
2449
+ if (!obj(annotation)) continue;
2450
+ if (annotation.type === "browser-version" && str(annotation.description)) project.browserVersion = str(annotation.description);
2451
+ if (annotation.type === "viewport-width" && /^\d+$/.test(str(annotation.description))) project.viewportWidths.add(Number(annotation.description));
2452
+ }
2453
+ const status = str(test.status);
2454
+ if (status === "expected") project.passed++;
2455
+ else if (status === "skipped") project.skipped++;
2456
+ else project.failed++;
2457
+ totals.set(project.name, project);
2458
+ }
2459
+ }
2460
+ for (const child of Array.isArray(suite.suites) ? suite.suites : []) visit(child);
2461
+ };
2462
+ for (const suite of value.suites) visit(suite);
2463
+ return [...totals.values()].map((project) => ({ ...project, viewportWidths: [...project.viewportWidths].sort((a, b) => a - b) }));
2464
+ }
2465
+ async function compatibility(app, workspaceRoot, temp) {
2466
+ const file = await binary("playwright", app.directory, workspaceRoot);
2467
+ const toolVersion = await version(file, app.directory);
2468
+ const outputPath = join11(temp, "playwright.json");
2469
+ const configPath = join11(temp, "playwright-projects.json");
2470
+ const reporterPath = join11(temp, "lattice-playwright-reporter.cjs");
2471
+ await writeFile3(reporterPath, 'module.exports=class { onBegin(config) { require("node:fs").writeFileSync(process.env.LATTICE_PLAYWRIGHT_CONFIG_OUT, JSON.stringify(config.projects.map(p => ({ name: p.name, engine: p.use.browserName || p.use.defaultBrowserType, mobile: p.use.isMobile === true, touch: p.use.hasTouch === true, userAgent: p.use.userAgent || "" })))); } };\n');
2472
+ const result = await run(
2473
+ file,
2474
+ ["test", `--reporter=json,${reporterPath}`],
2475
+ app.directory,
2476
+ { ...process.env, PLAYWRIGHT_JSON_OUTPUT_FILE: outputPath, LATTICE_PLAYWRIGHT_CONFIG_OUT: configPath }
2477
+ );
2478
+ const parsed = json(await readFile10(outputPath, "utf8").catch(() => result.stdout));
2479
+ const projects = playwrightProjects(parsed);
2480
+ const config = json(await readFile10(configPath, "utf8").catch(() => ""));
2481
+ const configurations = Array.isArray(config) && config.every(obj) ? config : [];
2482
+ for (const project of projects) {
2483
+ const configured = configurations.find((item) => item.name === project.name);
2484
+ if (!configured) continue;
2485
+ project.engine = str(configured.engine);
2486
+ project.mobile = configured.mobile === true && configured.touch === true;
2487
+ const agent = str(configured.userAgent);
2488
+ const android = agent.match(/Android\s+([\d.]+)/i);
2489
+ const iphone = agent.match(/iPhone OS\s+([\d_]+)/i);
2490
+ if (project.name === "android-chrome" && android && project.mobile) project.device = `Android ${android[1]} UA emulation`;
2491
+ if (project.name === "iphone-safari" && iphone && project.mobile) project.device = `iOS ${iphone[1].replaceAll("_", ".")} UA emulation`;
2492
+ }
2493
+ const reportedErrors = obj(parsed) && Array.isArray(parsed.errors) ? parsed.errors : [];
2494
+ const infrastructureFailure = reportedErrors.length > 0 || configurations.length === 0 || typeof result.stderr === "string" && /browser executable.*doesn't exist|failed to launch browser|install playwright browsers/i.test(result.stderr);
2495
+ return {
2496
+ tool: "Playwright",
2497
+ version: toolVersion,
2498
+ complete: Boolean(parsed) && !result.error && !infrastructureFailure && projects.length > 0,
2499
+ projects,
2500
+ errors: infrastructureFailure ? ["Playwright browser infrastructure or configuration failed"] : projects.length ? [] : [result.error ?? "Playwright JSON results are unavailable; configure required browser and mobile projects"]
2501
+ };
2502
+ }
2503
+ async function collectGovernanceEvidence(options) {
2504
+ const scanRoot = resolve10(options.scanRoot);
2505
+ const workspaceRoot = resolve10(options.workspaceRoot ?? scanRoot);
2506
+ const destination = resolve10(options.evidence);
2507
+ if (!inside(workspaceRoot, scanRoot)) throw new Error("Scan root must be inside workspace root");
2508
+ if (inside(scanRoot, destination)) throw new Error("Evidence file must be outside the scan root");
2509
+ if (!options.policy.assessment?.evidence) throw new Error("Policy assessment.evidence is required for governance collect");
2510
+ const inputSha256 = await hashAssessmentInputs(scanRoot, workspaceRoot);
2511
+ const assertStableInputs = async (stage) => {
2512
+ if (inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) {
2513
+ throw new Error(`Assessment inputs changed ${stage}; rerun on a stable checkout`);
2514
+ }
2515
+ };
2516
+ const git = await run("git", ["rev-parse", "HEAD"], workspaceRoot);
2517
+ const commit = git.code === 0 && /^[0-9a-f]{40}$/.test(git.stdout.trim()) ? git.stdout.trim() : "unversioned";
2518
+ const apps = await discoverApps(scanRoot, workspaceRoot);
2519
+ const auditCache = /* @__PURE__ */ new Map();
2520
+ const scans = [];
2521
+ for (const app of apps) {
2522
+ const temp = await mkdtemp(join11(tmpdir(), "lattice-scanners-"));
2523
+ try {
2524
+ const dependencySecurity = await dependencyAudit(app, workspaceRoot, auditCache);
2525
+ await assertStableInputs("after dependency audit");
2526
+ const qualityLint = await lint(app, workspaceRoot);
2527
+ await assertStableInputs("after ESLint");
2528
+ const qualityTypecheck = await typecheck(app, workspaceRoot);
2529
+ await assertStableInputs("after TypeScript");
2530
+ const qualityTests = await testAndCoverage(app, workspaceRoot, temp);
2531
+ await assertStableInputs("after Vitest");
2532
+ const securitySast = await sast(app, temp);
2533
+ await assertStableInputs("after Semgrep");
2534
+ const securitySecrets = await secrets(app, temp);
2535
+ await assertStableInputs("after Gitleaks");
2536
+ const compatibilityResult = await compatibility(app, workspaceRoot, temp);
2537
+ await assertStableInputs("after Playwright");
2538
+ scans.push({
2539
+ path: relative8(scanRoot, app.directory).replaceAll("\\", "/") || ".",
2540
+ profile: app.profile,
2541
+ dependencySecurity,
2542
+ codeQuality: { lint: qualityLint, typecheck: qualityTypecheck, ...qualityTests },
2543
+ codeSecurity: { sast: securitySast, secrets: securitySecrets },
2544
+ compatibility: compatibilityResult
2545
+ });
2546
+ } finally {
2547
+ await rm(temp, { recursive: true, force: true });
2548
+ }
2549
+ }
2550
+ await assertStableInputs("before evidence write");
2551
+ const report = {
2552
+ schemaVersion: 1,
2553
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2554
+ policySha256: options.policy.sha256,
2555
+ inputSha256,
2556
+ commit,
2557
+ apps: scans
2558
+ };
2559
+ await mkdir4(dirname4(destination), { recursive: true });
2560
+ await writeFile3(destination, `${JSON.stringify(report, null, 2)}
2561
+ `, { flag: "w" });
1699
2562
  return report;
1700
2563
  }
1701
2564
 
1702
2565
  // src/runtime.ts
1703
- function isSupportedNodeVersion(version) {
1704
- const major = Number(version.split(".", 1)[0]);
2566
+ function isSupportedNodeVersion(version2) {
2567
+ const major = Number(version2.split(".", 1)[0]);
1705
2568
  return Number.isInteger(major) && major >= 24;
1706
2569
  }
1707
2570
 
@@ -1722,12 +2585,12 @@ async function runCli(arguments_, output = defaultOutput) {
1722
2585
  program.configureOutput({ writeOut: output.out, writeErr: output.err });
1723
2586
  program.exitOverride();
1724
2587
  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"));
2588
+ const root = resolve11(path ?? ".");
2589
+ const destination = resolve11(flags.output ?? join12(root, ".nklattice-report"));
1727
2590
  const report = await analyzeProject(root, { outputDirectory: destination });
1728
2591
  output.out(`NKLattice analyzed ${report.graph.modules.length} modules. Score ${report.score.score} (${report.score.grade}); ${report.findings.length} findings.
1729
2592
  `);
1730
- output.out(`Reports: ${join10(destination, "report.html")} and ${join10(destination, "analysis.json")}
2593
+ output.out(`Reports: ${join12(destination, "report.html")} and ${join12(destination, "analysis.json")}
1731
2594
  `);
1732
2595
  });
1733
2596
  program.command("doctor [path]").description("Check whether a directory contains supported source files").action(async (path) => {
@@ -1737,8 +2600,8 @@ async function runCli(arguments_, output = defaultOutput) {
1737
2600
  `);
1738
2601
  });
1739
2602
  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" });
2603
+ const destination = join12(resolve11(path ?? "."), ".nklattice.json");
2604
+ await writeFile4(destination, '{\n "analysis": {},\n "policies": []\n}\n', { flag: "wx" });
1742
2605
  output.out(`Created ${destination}
1743
2606
  `);
1744
2607
  });
@@ -1750,7 +2613,7 @@ async function runCli(arguments_, output = defaultOutput) {
1750
2613
  expectedApps = Number(flags.expectApps);
1751
2614
  if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
1752
2615
  }
1753
- const policyPath = flags.policy ?? resolve9(dirname3(fileURLToPath(import.meta.url)), "../governance/policy.json");
2616
+ const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
1754
2617
  const policy = await loadPolicy(policyPath);
1755
2618
  const result = await checkGovernance({
1756
2619
  scanRoot: path ?? ".",
@@ -1776,21 +2639,22 @@ async function runCli(arguments_, output = defaultOutput) {
1776
2639
  }
1777
2640
  if (result.findings.length > 0) exitCode = 7;
1778
2641
  });
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) => {
2642
+ governance.command("assess [path]").description("Assess app inventory, dependency, architecture, security, quality, and compatibility policy").option("--policy <source>", "Local file or HTTPS policy URL").option("--inventory <path>", "Reviewed app inventory JSON").option("--workspace-root <path>", "Repository root containing a shared lockfile").option("--expect-apps <count>", "Additional expected managed application count").option("--output <directory>", "Assessment report directory").option("--evidence <file>", "Fresh governance collect JSON outside the scan root").option("--json", "Print machine-readable assessment").action(async (path, flags) => {
1780
2643
  let expectedApps;
1781
2644
  if (flags.expectApps !== void 0) {
1782
2645
  if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
1783
2646
  expectedApps = Number(flags.expectApps);
1784
2647
  if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
1785
2648
  }
1786
- const scanRoot = resolve9(path ?? ".");
1787
- const policyPath = flags.policy ?? resolve9(dirname3(fileURLToPath(import.meta.url)), "../governance/policy.json");
2649
+ const scanRoot = resolve11(path ?? ".");
2650
+ const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
1788
2651
  const report = await assessGovernance({
1789
2652
  scanRoot,
1790
2653
  workspaceRoot: flags.workspaceRoot,
1791
2654
  inventory: flags.inventory,
1792
2655
  expectedApps,
1793
2656
  outputDirectory: flags.output,
2657
+ evidence: flags.evidence,
1794
2658
  policy: await loadPolicy(policyPath)
1795
2659
  });
1796
2660
  if (flags.json) output.out(`${JSON.stringify(report, null, 2)}
@@ -1801,12 +2665,58 @@ async function runCli(arguments_, output = defaultOutput) {
1801
2665
  for (const issue of report.issues) output.out(`Inventory: ${issue}
1802
2666
  `);
1803
2667
  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"}).
2668
+ output.out(`${app.path} (${app.profile}): ${app.status}; ${Object.entries(app.domains).map(([name, domain]) => `${name} ${domain.status}`).join("; ")}; architecture score ${app.domains.architecture.score ?? "unavailable"}.
2669
+ `);
2670
+ for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
2671
+ `);
2672
+ }
2673
+ output.out(`Reports: ${resolve11(flags.output ?? join12(scanRoot, ".nklattice-governance"))}
2674
+ `);
2675
+ }
2676
+ exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
2677
+ });
2678
+ governance.command("collect [path]").description("Run project scanners and write fresh machine-readable evidence").requiredOption("--evidence <file>", "Evidence JSON outside the scan root").option("--policy <source>", "Local file or HTTPS policy URL").option("--workspace-root <path>", "Repository root containing a shared lockfile").action(async (path, flags) => {
2679
+ const scanRoot = resolve11(path ?? ".");
2680
+ const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
2681
+ const result = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy: await loadPolicy(policyPath) });
2682
+ output.out(`Collected scanner evidence for ${result.apps.length} app(s) at ${resolve11(flags.evidence)}.
2683
+ `);
2684
+ });
2685
+ governance.command("run [path]").description("Collect and assess all governance domains in one trusted process").requiredOption("--evidence <file>", "Evidence artifact outside the scan root").option("--policy <source>", "Local file or HTTPS policy URL").option("--inventory <path>", "Reviewed app inventory JSON").option("--workspace-root <path>", "Repository root containing a shared lockfile").option("--expect-apps <count>", "Additional expected managed application count").option("--output <directory>", "Assessment report directory").option("--json", "Print machine-readable assessment").action(async (path, flags) => {
2686
+ let expectedApps;
2687
+ if (flags.expectApps !== void 0) {
2688
+ if (!/^[1-9]\d*$/.test(flags.expectApps)) throw new Error("--expect-apps must be a positive integer");
2689
+ expectedApps = Number(flags.expectApps);
2690
+ if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
2691
+ }
2692
+ const scanRoot = resolve11(path ?? ".");
2693
+ const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
2694
+ const policy = await loadPolicy(policyPath);
2695
+ const collected = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy });
2696
+ const report = await assessGovernance({
2697
+ scanRoot,
2698
+ workspaceRoot: flags.workspaceRoot,
2699
+ inventory: flags.inventory,
2700
+ expectedApps,
2701
+ outputDirectory: flags.output,
2702
+ evidence: flags.evidence,
2703
+ trustedEvidence: collected,
2704
+ policy
2705
+ });
2706
+ if (flags.json) output.out(`${JSON.stringify(report, null, 2)}
2707
+ `);
2708
+ else {
2709
+ output.out(`Governance ${report.status}: ${report.apps.length} app(s); policy ${report.policy.version} (${report.policy.sha256}).
2710
+ `);
2711
+ for (const issue of report.issues) output.out(`Assessment: ${issue}
2712
+ `);
2713
+ for (const app of report.apps) {
2714
+ output.out(`${app.path} (${app.profile}): ${app.status}; ${Object.entries(app.domains).map(([name, domain]) => `${name} ${domain.status}`).join("; ")}; architecture score ${app.domains.architecture.score ?? "unavailable"}.
1805
2715
  `);
1806
2716
  for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
1807
2717
  `);
1808
2718
  }
1809
- output.out(`Reports: ${resolve9(flags.output ?? join10(scanRoot, ".nklattice-governance"))}
2719
+ output.out(`Reports: ${resolve11(flags.output ?? join12(scanRoot, ".nklattice-governance"))}
1810
2720
  `);
1811
2721
  }
1812
2722
  exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
@@ -1822,7 +2732,7 @@ async function runCli(arguments_, output = defaultOutput) {
1822
2732
  }
1823
2733
  return exitCode;
1824
2734
  }
1825
- if (process.argv[1] && realpathSync(resolve9(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
2735
+ if (process.argv[1] && realpathSync(resolve11(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
1826
2736
  runCli(process.argv.slice(2)).then((code) => {
1827
2737
  process.exitCode = code;
1828
2738
  });