@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/index.js CHANGED
@@ -139,12 +139,55 @@ function parseAssessmentPolicy(value) {
139
139
  for (const id of architecture.blockedRuleIds) {
140
140
  if (!known.has(id)) throw new Error(`Unknown blocked rule ID ${id}`);
141
141
  }
142
+ let evidence;
143
+ if (value.evidence !== void 0) {
144
+ if (!record2(value.evidence)) throw new Error("assessment.evidence must be an object");
145
+ const gates = value.evidence;
146
+ const nonnegative2 = (source, key, label) => {
147
+ const result = source[key];
148
+ if (!Number.isSafeInteger(result) || result < 0) throw new Error(`${label}.${key} must be a nonnegative integer`);
149
+ return result;
150
+ };
151
+ if (!Number.isFinite(gates.maximumAgeHours) || gates.maximumAgeHours <= 0 || gates.maximumAgeHours > 168) {
152
+ throw new Error("assessment.evidence.maximumAgeHours must be greater than 0 and at most 168");
153
+ }
154
+ for (const domain of ["dependencySecurity", "codeQuality", "codeSecurity", "compatibility"]) {
155
+ if (!record2(gates[domain])) throw new Error(`assessment.evidence.${domain} is required`);
156
+ }
157
+ const dependencySecurity = gates.dependencySecurity;
158
+ const codeQuality = gates.codeQuality;
159
+ const codeSecurity = gates.codeSecurity;
160
+ const compatibility = gates.compatibility;
161
+ const minimumLineCoverage = codeQuality.minimumLineCoverage;
162
+ if (typeof minimumLineCoverage !== "number" || !Number.isFinite(minimumLineCoverage) || minimumLineCoverage < 0 || minimumLineCoverage > 100) {
163
+ throw new Error("assessment.evidence.codeQuality.minimumLineCoverage must be 0\u2013100");
164
+ }
165
+ if (typeof codeQuality.requireTypecheck !== "boolean" || typeof codeQuality.requireTests !== "boolean") {
166
+ throw new Error("assessment.evidence.codeQuality requires boolean requireTypecheck and requireTests");
167
+ }
168
+ const projects = compatibility.requiredProjects;
169
+ if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
170
+ throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
171
+ }
172
+ evidence = {
173
+ maximumAgeHours: gates.maximumAgeHours,
174
+ dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
175
+ codeQuality: {
176
+ maximumLintErrors: nonnegative2(codeQuality, "maximumLintErrors", "assessment.evidence.codeQuality"),
177
+ minimumLineCoverage,
178
+ requireTypecheck: codeQuality.requireTypecheck,
179
+ requireTests: codeQuality.requireTests
180
+ },
181
+ codeSecurity: { maximumHigh: nonnegative2(codeSecurity, "maximumHigh", "assessment.evidence.codeSecurity"), maximumCritical: nonnegative2(codeSecurity, "maximumCritical", "assessment.evidence.codeSecurity"), maximumSecrets: nonnegative2(codeSecurity, "maximumSecrets", "assessment.evidence.codeSecurity") },
182
+ compatibility: { requiredProjects: projects, minimumTestsPerProject: nonnegative2(compatibility, "minimumTestsPerProject", "assessment.evidence.compatibility") }
183
+ };
184
+ }
142
185
  return { architecture: {
143
186
  minimumScore: architecture.minimumScore,
144
187
  maximumErrors: architecture.maximumErrors,
145
188
  blockedRuleIds: architecture.blockedRuleIds,
146
189
  analysis
147
- } };
190
+ }, ...evidence ? { evidence } : {} };
148
191
  }
149
192
  function parsePolicy(raw, source) {
150
193
  let parsed;
@@ -390,7 +433,7 @@ async function readResolution(app, workspaceRoot, name) {
390
433
  async function checkApp(app, workspaceRoot, policy) {
391
434
  const requirements = policy.profiles[app.profile];
392
435
  if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
393
- const findings = [];
436
+ const findings2 = [];
394
437
  for (const [packageName, expected] of Object.entries(requirements)) {
395
438
  const declared = app.dependencies[packageName];
396
439
  const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
@@ -408,7 +451,7 @@ async function checkApp(app, workspaceRoot, policy) {
408
451
  reasons.push("lockfile resolution outside manifest range");
409
452
  }
410
453
  }
411
- if (reasons.length) findings.push({
454
+ if (reasons.length) findings2.push({
412
455
  app: app.relativePath,
413
456
  packageName,
414
457
  expected,
@@ -418,7 +461,7 @@ async function checkApp(app, workspaceRoot, policy) {
418
461
  reason: reasons.join("; ")
419
462
  });
420
463
  }
421
- return findings;
464
+ return findings2;
422
465
  }
423
466
  async function checkGovernance(options) {
424
467
  const scanRoot = resolve3(options.scanRoot);
@@ -429,10 +472,10 @@ async function checkGovernance(options) {
429
472
  }
430
473
  const apps = await discoverApps(scanRoot, workspaceRoot);
431
474
  if (apps.length === 0) throw new Error("No managed applications found");
432
- const findings = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
475
+ const findings2 = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
433
476
  return {
434
477
  appsChecked: apps.length,
435
- findings,
478
+ findings: findings2,
436
479
  policyVersion: options.policy.policyVersion,
437
480
  policySource: options.policy.source,
438
481
  policySha256: options.policy.sha256
@@ -441,7 +484,7 @@ async function checkGovernance(options) {
441
484
 
442
485
  // src/governance/assess.ts
443
486
  import { mkdir, writeFile } from "fs/promises";
444
- import { join as join7, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
487
+ import { basename, join as join8, relative as relative7, resolve as resolve8, sep as sep6 } from "path";
445
488
 
446
489
  // src/analysis/graph.ts
447
490
  function buildGraph(_modules) {
@@ -494,12 +537,12 @@ function findCycles(graph) {
494
537
  // src/analysis/rules.ts
495
538
  import { matchesGlob } from "path";
496
539
  function evaluateRules(graph, config) {
497
- const findings = [];
540
+ const findings2 = [];
498
541
  const outgoing = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly)]));
499
542
  const incoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path && !edge.isTypeOnly)]));
500
543
  const allIncoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path)]));
501
544
  for (const cycle of findCycles(graph)) {
502
- findings.push({
545
+ findings2.push({
503
546
  ruleId: "module-cycle",
504
547
  category: "architecture",
505
548
  severity: "error",
@@ -516,7 +559,7 @@ function evaluateRules(graph, config) {
516
559
  for (const imported of module.imports) {
517
560
  if (imported.isTypeOnly) continue;
518
561
  if (seen.has(imported.specifier)) {
519
- findings.push({
562
+ findings2.push({
520
563
  ruleId: "duplicate-import",
521
564
  category: "imports",
522
565
  severity: "warning",
@@ -529,7 +572,7 @@ function evaluateRules(graph, config) {
529
572
  seen.add(imported.specifier);
530
573
  }
531
574
  if (out.length > config.thresholds.maxFanOut) {
532
- findings.push({
575
+ findings2.push({
533
576
  ruleId: "high-fan-out",
534
577
  category: "architecture",
535
578
  severity: "warning",
@@ -540,7 +583,7 @@ function evaluateRules(graph, config) {
540
583
  });
541
584
  }
542
585
  if (into.length > config.thresholds.maxFanIn) {
543
- findings.push({
586
+ findings2.push({
544
587
  ruleId: "high-fan-in",
545
588
  category: "architecture",
546
589
  severity: "warning",
@@ -551,7 +594,7 @@ function evaluateRules(graph, config) {
551
594
  });
552
595
  }
553
596
  if (into.length === 0 && out.length === 0 && graph.modules.length > 1 && !module.isRoute) {
554
- findings.push({
597
+ findings2.push({
555
598
  ruleId: "orphan-module",
556
599
  category: "architecture",
557
600
  severity: "info",
@@ -567,7 +610,7 @@ function evaluateRules(graph, config) {
567
610
  for (const name of module.exports) {
568
611
  if (name === "*" || module.typeExports.includes(name)) continue;
569
612
  if (!usedNames.has(name)) {
570
- findings.push({
613
+ findings2.push({
571
614
  ruleId: "unused-export",
572
615
  category: "imports",
573
616
  severity: "info",
@@ -581,7 +624,7 @@ function evaluateRules(graph, config) {
581
624
  }
582
625
  }
583
626
  if (module.componentCount > 0 && module.lines > config.thresholds.maxComponentLines) {
584
- findings.push({
627
+ findings2.push({
585
628
  ruleId: "large-component",
586
629
  category: "components",
587
630
  severity: "warning",
@@ -592,7 +635,7 @@ function evaluateRules(graph, config) {
592
635
  });
593
636
  }
594
637
  if (module.jsxDepth > config.thresholds.maxJsxDepth) {
595
- findings.push({
638
+ findings2.push({
596
639
  ruleId: "deep-jsx",
597
640
  category: "components",
598
641
  severity: "warning",
@@ -603,7 +646,7 @@ function evaluateRules(graph, config) {
603
646
  });
604
647
  }
605
648
  if (module.complexity > config.thresholds.maxComplexity) {
606
- findings.push({
649
+ findings2.push({
607
650
  ruleId: "high-complexity",
608
651
  category: "complexity",
609
652
  severity: "warning",
@@ -619,7 +662,7 @@ function evaluateRules(graph, config) {
619
662
  const allowed = policy.allow?.some((pattern) => matchesGlob(edge.to, pattern));
620
663
  const disallowed = policy.disallow?.some((pattern) => matchesGlob(edge.to, pattern));
621
664
  if (policy.allow && !allowed || policy.disallow && disallowed) {
622
- findings.push({
665
+ findings2.push({
623
666
  ruleId: policy.id,
624
667
  category: "architecture",
625
668
  severity: policy.severity,
@@ -646,7 +689,7 @@ function evaluateRules(graph, config) {
646
689
  return false;
647
690
  };
648
691
  if (visit(module.path, /* @__PURE__ */ new Set([module.path]), 0)) {
649
- findings.push({
692
+ findings2.push({
650
693
  ruleId: "deep-dependency-chain",
651
694
  category: "architecture",
652
695
  severity: "warning",
@@ -657,7 +700,7 @@ function evaluateRules(graph, config) {
657
700
  });
658
701
  }
659
702
  }
660
- return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
703
+ return findings2.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
661
704
  }
662
705
 
663
706
  // src/analysis/scan.ts
@@ -986,10 +1029,10 @@ function grade(score) {
986
1029
  if (score >= 55) return "D";
987
1030
  return "F";
988
1031
  }
989
- function scoreFindings(findings, config) {
1032
+ function scoreFindings(findings2, config) {
990
1033
  const penalties = { error: 18, warning: 7, info: 2 };
991
1034
  const categories = Object.entries(config.weights).map(([id, weight]) => {
992
- const categoryFindings = findings.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
1035
+ const categoryFindings = findings2.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
993
1036
  const occurrences = /* @__PURE__ */ new Map();
994
1037
  let deduction = 0;
995
1038
  for (const finding of categoryFindings) {
@@ -1001,8 +1044,8 @@ function scoreFindings(findings, config) {
1001
1044
  return { id, weight, score: round(100 - deduction), deduction: round(deduction), findingCount: categoryFindings.length };
1002
1045
  });
1003
1046
  const weightedDeduction = round(categories.reduce((sum, category) => sum + category.deduction * category.weight / 100, 0));
1004
- const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings.length - 10) * 0.15));
1005
- const criticalAdjustment = round(Math.min(10, findings.filter((finding) => finding.severity === "error").length * 1.5));
1047
+ const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings2.length - 10) * 0.15));
1048
+ const criticalAdjustment = round(Math.min(10, findings2.filter((finding) => finding.severity === "error").length * 1.5));
1006
1049
  const score = round(Math.max(0, Math.min(100, 100 - weightedDeduction - maintenanceAdjustment - criticalAdjustment)));
1007
1050
  return { score, grade: grade(score), weightedDeduction, maintenanceAdjustment, criticalAdjustment, categories };
1008
1051
  }
@@ -1065,6 +1108,279 @@ function compareInventory(inventory, discovered, scanRoot) {
1065
1108
  return issues;
1066
1109
  }
1067
1110
 
1111
+ // src/governance/evidence.ts
1112
+ import { createHash as createHash3 } from "crypto";
1113
+ import { execFile } from "child_process";
1114
+ import { createReadStream } from "fs";
1115
+ import { lstat, readFile as readFile8, realpath as realpath2 } from "fs/promises";
1116
+ import { dirname as dirname3, join as join7, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
1117
+ import { promisify } from "util";
1118
+ import fg from "fast-glob";
1119
+ var execFileAsync = promisify(execFile);
1120
+ function object(value) {
1121
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1122
+ }
1123
+ function string(value) {
1124
+ return typeof value === "string" && value.length > 0;
1125
+ }
1126
+ function nonnegative(value) {
1127
+ return Number.isSafeInteger(value) && value >= 0;
1128
+ }
1129
+ function errors(value) {
1130
+ return Array.isArray(value) && value.every(string);
1131
+ }
1132
+ function findings(value) {
1133
+ 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)));
1134
+ }
1135
+ function base(value) {
1136
+ return object(value) && string(value.tool) && string(value.version) && typeof value.complete === "boolean" && errors(value.errors);
1137
+ }
1138
+ function validateApp(value) {
1139
+ if (!object(value) || !string(value.path) || !string(value.profile)) return false;
1140
+ const dependency = value.dependencySecurity;
1141
+ const quality = value.codeQuality;
1142
+ const security = value.codeSecurity;
1143
+ const compatibility = value.compatibility;
1144
+ if (!base(dependency) || !nonnegative(dependency.packagesScanned) || !findings(dependency.advisories)) return false;
1145
+ 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;
1146
+ 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;
1147
+ 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;
1148
+ if (!base(compatibility) || !Array.isArray(compatibility.projects) || !compatibility.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;
1149
+ return true;
1150
+ }
1151
+ async function hashFile(hash, path) {
1152
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
1153
+ }
1154
+ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
1155
+ const root = resolve7(scanRoot);
1156
+ const workspace = resolve7(workspaceRoot);
1157
+ const scanRelative = relative6(workspace, root);
1158
+ if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
1159
+ const paths = await fg("**/*", {
1160
+ cwd: root,
1161
+ dot: true,
1162
+ onlyFiles: true,
1163
+ followSymbolicLinks: false,
1164
+ ignore: [
1165
+ "**/.git/**",
1166
+ "**/node_modules/**",
1167
+ "**/dist/**",
1168
+ "**/build/**",
1169
+ "**/.next/**",
1170
+ "**/coverage/**",
1171
+ "**/playwright-report/**",
1172
+ "**/test-results/**",
1173
+ "**/.nklattice-governance/**",
1174
+ "**/.nklattice-report/**",
1175
+ "**/AGENTS.md",
1176
+ "**/*.tsbuildinfo",
1177
+ "**/next-env.d.ts"
1178
+ ]
1179
+ });
1180
+ paths.sort();
1181
+ const hash = createHash3("sha256");
1182
+ for (const path of paths) {
1183
+ const absolute = resolve7(root, path);
1184
+ const normalized = relative6(root, absolute);
1185
+ if (normalized === ".." || normalized.startsWith(`..${sep5}`)) throw new Error("Input path escapes scan root");
1186
+ if (!(await lstat(absolute)).isFile()) continue;
1187
+ hash.update(path.replaceAll("\\", "/"));
1188
+ hash.update("\0");
1189
+ await hashFile(hash, absolute);
1190
+ hash.update("\0");
1191
+ }
1192
+ let ancestor = root;
1193
+ while (ancestor === workspace || ancestor !== dirname3(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
1194
+ for (const name of [
1195
+ "package.json",
1196
+ "package-lock.json",
1197
+ "pnpm-lock.yaml",
1198
+ "pnpm-workspace.yaml",
1199
+ "tsconfig.json",
1200
+ "eslint.config.js",
1201
+ "eslint.config.mjs",
1202
+ "eslint.config.cjs",
1203
+ "eslint.config.ts",
1204
+ "eslint.config.mts",
1205
+ "vitest.config.js",
1206
+ "vitest.config.mjs",
1207
+ "vitest.config.ts",
1208
+ "vitest.config.mts",
1209
+ "playwright.config.js",
1210
+ "playwright.config.mjs",
1211
+ "playwright.config.ts",
1212
+ "playwright.config.mts",
1213
+ ".semgrep.yml",
1214
+ ".semgrep.yaml",
1215
+ "semgrep.yml",
1216
+ "semgrep.yaml"
1217
+ ]) {
1218
+ const path = join7(ancestor, name);
1219
+ try {
1220
+ const stat2 = await lstat(path);
1221
+ if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
1222
+ if (!stat2.isFile()) continue;
1223
+ hash.update(relative6(workspace, path).replaceAll("\\", "/"));
1224
+ hash.update("\0");
1225
+ await hashFile(hash, path);
1226
+ hash.update("\0");
1227
+ } catch (error) {
1228
+ if (error.code !== "ENOENT") throw error;
1229
+ }
1230
+ }
1231
+ if (ancestor === workspace) break;
1232
+ ancestor = dirname3(ancestor);
1233
+ }
1234
+ return hash.digest("hex");
1235
+ }
1236
+ async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
1237
+ const source = resolve7(path);
1238
+ const stat2 = await lstat(source);
1239
+ if (!stat2.isFile() || stat2.isSymbolicLink() || stat2.size > 1e7) throw new Error("Evidence must be a regular JSON file under 10 MB");
1240
+ const root = resolve7(scanRoot);
1241
+ const relativePath = relative6(root, source);
1242
+ if (relativePath !== ".." && !relativePath.startsWith(`..${sep5}`)) throw new Error("Evidence file must be outside the scan root");
1243
+ const actualRoot = await realpath2(root);
1244
+ const actualSource = await realpath2(source);
1245
+ const actualRelative = relative6(actualRoot, actualSource);
1246
+ if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
1247
+ let parsed;
1248
+ try {
1249
+ parsed = JSON.parse(await readFile8(source, "utf8"));
1250
+ } catch {
1251
+ throw new Error("Evidence is not valid JSON");
1252
+ }
1253
+ return validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours);
1254
+ }
1255
+ async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
1256
+ 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");
1257
+ if (parsed.policySha256 !== policySha256) throw new Error("Evidence was generated for another policy");
1258
+ const generatedAt = Date.parse(parsed.generatedAt);
1259
+ if (!Number.isFinite(generatedAt) || generatedAt > Date.now() + 6e4 || Date.now() - generatedAt > maximumAgeHours * 36e5) {
1260
+ throw new Error("Evidence is stale or has an invalid timestamp");
1261
+ }
1262
+ if (parsed.inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) throw new Error("Evidence inputs changed after scanning");
1263
+ const current = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot })).stdout.trim();
1264
+ if (!/^[0-9a-f]{40}$/.test(current) || parsed.commit !== current) throw new Error("Evidence commit differs from the checked-out commit");
1265
+ const appPaths = parsed.apps.map((app) => app.path);
1266
+ if (new Set(appPaths).size !== appPaths.length) throw new Error("Evidence contains duplicate app paths");
1267
+ return parsed;
1268
+ }
1269
+ var incomplete = (issues, tool, version, scanned) => ({ status: "incomplete", issues, tool, version, scanned });
1270
+ function evaluateAppEvidence(app, policy) {
1271
+ if (!app) {
1272
+ const missing = incomplete(["No fresh scan evidence for this app"]);
1273
+ return { dependencySecurity: { ...missing }, codeQuality: { ...missing }, codeSecurity: { ...missing }, compatibility: { ...missing } };
1274
+ }
1275
+ const dependency = app.dependencySecurity;
1276
+ let dependencySecurity;
1277
+ if (!dependency.complete || dependency.packagesScanned === 0 || dependency.errors.length) {
1278
+ dependencySecurity = { ...incomplete(["Dependency advisory scan did not cover the lockfile", ...dependency.errors], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories };
1279
+ } else {
1280
+ const high2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "high").length;
1281
+ const critical2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "critical").length;
1282
+ const unknown = dependency.advisories.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
1283
+ dependencySecurity = unknown.length ? { ...incomplete([`${unknown.length} advisories have unknown severity`], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories } : {
1284
+ status: high2 > policy.dependencySecurity.maximumHigh || critical2 > policy.dependencySecurity.maximumCritical ? "fail" : "pass",
1285
+ issues: [
1286
+ ...high2 > policy.dependencySecurity.maximumHigh ? [`${high2} high advisories exceed ${policy.dependencySecurity.maximumHigh}`] : [],
1287
+ ...critical2 > policy.dependencySecurity.maximumCritical ? [`${critical2} critical advisories exceed ${policy.dependencySecurity.maximumCritical}`] : []
1288
+ ],
1289
+ tool: dependency.tool,
1290
+ version: dependency.version,
1291
+ scanned: dependency.packagesScanned,
1292
+ findings: dependency.advisories
1293
+ };
1294
+ }
1295
+ const quality = app.codeQuality;
1296
+ const qualityMissing = [
1297
+ ...!quality.lint.complete || quality.lint.filesScanned === 0 ? ["ESLint did not scan source files", ...quality.lint.errorsDetail] : [],
1298
+ ...policy.codeQuality.requireTypecheck && !quality.typecheck.complete ? ["TypeScript typecheck did not complete", ...quality.typecheck.errors] : [],
1299
+ ...policy.codeQuality.requireTests && (!quality.tests.complete || quality.tests.passed + quality.tests.failed === 0) ? ["Tests did not run", ...quality.tests.errors] : [],
1300
+ ...!quality.coverage.complete ? ["Line coverage is unavailable", ...quality.coverage.errors] : []
1301
+ ];
1302
+ const qualityFailures = [
1303
+ ...quality.lint.errorCount > policy.codeQuality.maximumLintErrors ? [`${quality.lint.errorCount} ESLint errors exceed ${policy.codeQuality.maximumLintErrors}`] : [],
1304
+ ...policy.codeQuality.requireTypecheck && !quality.typecheck.passed ? ["TypeScript typecheck failed"] : [],
1305
+ ...quality.tests.failed > 0 ? [`${quality.tests.failed} tests failed`] : [],
1306
+ ...quality.coverage.linesPercent < policy.codeQuality.minimumLineCoverage ? [`Line coverage ${quality.coverage.linesPercent}% is below ${policy.codeQuality.minimumLineCoverage}%`] : []
1307
+ ];
1308
+ const qualityVersion = `ESLint ${quality.lint.version} \xB7 TypeScript ${quality.typecheck.version} \xB7 Vitest ${quality.tests.version}`;
1309
+ const qualityMetrics = {
1310
+ lintErrors: quality.lint.errorCount,
1311
+ lintWarnings: quality.lint.warnings,
1312
+ typecheck: quality.typecheck.applicable === false ? "not applicable" : quality.typecheck.passed ? "pass" : "fail",
1313
+ testsPassed: quality.tests.passed,
1314
+ testsFailed: quality.tests.failed,
1315
+ lineCoveragePercent: quality.coverage.linesPercent
1316
+ };
1317
+ const codeQuality = qualityMissing.length ? { ...incomplete([...qualityMissing, ...qualityFailures], "ESLint / TypeScript / tests", qualityVersion, quality.lint.filesScanned), metrics: qualityMetrics } : {
1318
+ status: qualityFailures.length ? "fail" : "pass",
1319
+ issues: qualityFailures,
1320
+ tool: "ESLint / TypeScript / tests",
1321
+ version: qualityVersion,
1322
+ scanned: quality.lint.filesScanned,
1323
+ metrics: qualityMetrics
1324
+ };
1325
+ const security = app.codeSecurity;
1326
+ const securityMissing = [
1327
+ ...!security.sast.complete || security.sast.filesScanned === 0 ? ["SAST did not scan source files", ...security.sast.errors] : [],
1328
+ ...!security.secrets.complete || security.secrets.filesScanned === 0 ? ["Secret scanner did not scan source files", ...security.secrets.errors] : []
1329
+ ];
1330
+ const high = security.sast.findings.filter((item) => item.severity.toLowerCase() === "high").length;
1331
+ const critical = security.sast.findings.filter((item) => item.severity.toLowerCase() === "critical").length;
1332
+ const unknownSecurity = security.sast.findings.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
1333
+ if (unknownSecurity.length) securityMissing.push(`${unknownSecurity.length} SAST findings have unknown severity`);
1334
+ const securityFailures = [
1335
+ ...high > policy.codeSecurity.maximumHigh ? [`${high} high SAST findings exceed ${policy.codeSecurity.maximumHigh}`] : [],
1336
+ ...critical > policy.codeSecurity.maximumCritical ? [`${critical} critical SAST findings exceed ${policy.codeSecurity.maximumCritical}`] : [],
1337
+ ...security.secrets.findings.length > policy.codeSecurity.maximumSecrets ? [`${security.secrets.findings.length} detected secrets exceed ${policy.codeSecurity.maximumSecrets}`] : []
1338
+ ];
1339
+ const securityVersion = `Semgrep ${security.sast.version} \xB7 Gitleaks ${security.secrets.version}`;
1340
+ const codeSecurity = securityMissing.length ? { ...incomplete([...securityMissing, ...securityFailures], "Semgrep / Gitleaks", securityVersion, security.sast.filesScanned), findings: [...security.sast.findings, ...security.secrets.findings] } : {
1341
+ status: securityFailures.length ? "fail" : "pass",
1342
+ issues: securityFailures,
1343
+ tool: "Semgrep / Gitleaks",
1344
+ version: securityVersion,
1345
+ scanned: security.sast.filesScanned,
1346
+ findings: [...security.sast.findings, ...security.secrets.findings]
1347
+ };
1348
+ const compatibility = app.compatibility;
1349
+ const projectNames = new Set(compatibility.projects.map((project) => project.name));
1350
+ const missingProjects = policy.compatibility.requiredProjects.filter((name) => !projectNames.has(name));
1351
+ const incompleteProjects = compatibility.projects.filter((project) => project.passed + project.failed < policy.compatibility.minimumTestsPerProject);
1352
+ const missingBrowserVersions = compatibility.projects.filter((project) => !project.browserVersion);
1353
+ const expectedEngines = {
1354
+ "chromium-desktop": "chromium",
1355
+ "firefox-desktop": "firefox",
1356
+ "webkit-desktop": "webkit",
1357
+ "android-chrome": "chromium",
1358
+ "iphone-safari": "webkit"
1359
+ };
1360
+ const invalidEngines = compatibility.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
1361
+ const invalidDevices = compatibility.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
1362
+ const missingViewports = compatibility.projects.filter((project) => new Set(project.viewportWidths ?? []).size < 2);
1363
+ const compatibilityMissing = [
1364
+ ...!compatibility.complete ? ["Compatibility test run did not complete", ...compatibility.errors] : [],
1365
+ ...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
1366
+ ...incompleteProjects.map((project) => `${project.name} ran fewer than ${policy.compatibility.minimumTestsPerProject} tests`),
1367
+ ...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
1368
+ ...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
1369
+ ...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
1370
+ ...missingViewports.map((project) => `${project.name} did not test two viewport widths`)
1371
+ ];
1372
+ const failedProjects = compatibility.projects.filter((project) => project.failed > 0);
1373
+ const compatibilityDomain = compatibilityMissing.length ? { ...incomplete([...compatibilityMissing, ...failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`)], compatibility.tool, compatibility.version), matrix: compatibility.projects } : {
1374
+ status: failedProjects.length ? "fail" : "pass",
1375
+ issues: failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`),
1376
+ tool: compatibility.tool,
1377
+ version: compatibility.version,
1378
+ scanned: compatibility.projects.reduce((count, project) => count + project.passed + project.failed, 0),
1379
+ matrix: compatibility.projects
1380
+ };
1381
+ return { dependencySecurity, codeQuality, codeSecurity, compatibility: compatibilityDomain };
1382
+ }
1383
+
1068
1384
  // src/governance/assess.ts
1069
1385
  var notEvaluated = [
1070
1386
  "vulnerability databases",
@@ -1085,27 +1401,50 @@ function escapeHtml(value) {
1085
1401
  return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1086
1402
  }
1087
1403
  function renderAssessmentHtml(report) {
1404
+ const labels = {
1405
+ inventory: "App inventory",
1406
+ dependencies: "Dependency policy",
1407
+ architecture: "Architecture",
1408
+ dependencySecurity: "Dependency security",
1409
+ codeQuality: "Code quality",
1410
+ codeSecurity: "Code security",
1411
+ compatibility: "Browser compatibility"
1412
+ };
1413
+ const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
1414
+ const overview = domainNames.map((name) => {
1415
+ const statuses = report.apps.map((app) => app.domains[name]?.status ?? "incomplete");
1416
+ const status = statusOf(statuses);
1417
+ const passed = statuses.filter((item) => item === "pass").length;
1418
+ return `<li><span>${escapeHtml(labels[name] ?? name)}</span><strong class="${status}">${escapeHtml(status)}</strong><small>${passed}/${report.apps.length} apps passed</small></li>`;
1419
+ }).join("");
1088
1420
  const apps = report.apps.map((app) => {
1089
- const domains = Object.entries(app.domains).map(([name, domain]) => `<tr><th scope="row">${escapeHtml(name)}</th><td class="${domain.status}">${escapeHtml(domain.status)}</td><td>${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : "No issues"}</td></tr>`).join("");
1421
+ const domains = Object.entries(app.domains).map(([name, domain]) => {
1422
+ const evidence = domain;
1423
+ const identity = evidence.tool ? `<p><small>${escapeHtml(evidence.tool)} ${escapeHtml(evidence.version ?? "")} \xB7 scanned ${escapeHtml(evidence.scanned ?? "unknown")}</small></p>` : "";
1424
+ const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(value)}`).join(" \xB7 ")}</small></p>` : "";
1425
+ const findings3 = evidence.findings?.length ? `<details><summary>${evidence.findings.length} finding(s)</summary><ul>${evidence.findings.map((item) => `<li><strong>${escapeHtml(item.severity)}</strong> ${escapeHtml(item.id)} \xB7 <code>${escapeHtml(item.subject)}</code> \u2014 ${escapeHtml(item.message)}${item.remediation ? ` \xB7 ${escapeHtml(item.remediation)}` : ""}</li>`).join("")}</ul></details>` : "";
1426
+ 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">${escapeHtml(item.name)}</th><td>${item.passed}</td><td>${item.failed}</td><td>${item.skipped}</td><td>${escapeHtml([item.browserVersion, item.device].filter(Boolean).join(" \xB7 ") || "Not recorded")}</td><td>${escapeHtml(item.viewportWidths?.join(", ") ?? "Not recorded")}</td></tr>`).join("")}</tbody></table></div>` : "";
1427
+ return `<tr><th scope="row">${escapeHtml(labels[name] ?? name)}</th><td class="${domain.status}">${escapeHtml(domain.status)}</td><td>${identity}${metrics}${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : "No issues"}${findings3}${matrix}</td></tr>`;
1428
+ }).join("");
1090
1429
  const architecture = app.domains.architecture;
1091
1430
  const categoryRows = architecture.scoreBreakdown?.categories.map((category) => `<tr><th scope="row">${escapeHtml(category.id)}</th><td>${escapeHtml(category.weight)}%</td><td>${escapeHtml(category.score)}</td><td>${escapeHtml(category.findingCount)}</td></tr>`).join("") ?? "";
1092
- const findings = architecture.findings.map((finding) => `<tr><td>${escapeHtml(finding.severity)}</td><td>${escapeHtml(finding.ruleId)}</td><td><code>${escapeHtml(finding.file)}:${finding.line}</code></td><td>${escapeHtml(finding.evidence)}</td></tr>`).join("");
1093
- return `<section class="app"><h2>${escapeHtml(app.path)} <span class="${app.status}">${escapeHtml(app.status)}</span></h2><p>Profile: ${escapeHtml(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${findings ? `<details><summary>Architecture findings (${architecture.findings.length})</summary><div class="scroll"><table><thead><tr><th>Severity</th><th>Rule</th><th>Location</th><th>Evidence</th></tr></thead><tbody>${findings}</tbody></table></div></details>` : ""}</section>`;
1431
+ const findings2 = architecture.findings.map((finding) => `<tr><td>${escapeHtml(finding.severity)}</td><td>${escapeHtml(finding.ruleId)}</td><td><code>${escapeHtml(finding.file)}:${finding.line}</code></td><td>${escapeHtml(finding.evidence)}</td></tr>`).join("");
1432
+ return `<section class="app"><h2>${escapeHtml(app.path)} <span class="${app.status}">${escapeHtml(app.status)}</span></h2><p>Profile: ${escapeHtml(app.profile)} \xB7 Source files scanned: ${architecture.filesScanned} \xB7 Local import edges: ${architecture.localEdges} \xB7 Architecture score: ${architecture.score ?? "unavailable"} \xB7 Findings: ${architecture.findings.length}</p><div class="scroll"><table><thead><tr><th>Domain</th><th>Status</th><th>Evidence</th></tr></thead><tbody>${domains}</tbody></table></div>${categoryRows ? `<details><summary>Score breakdown</summary><div class="scroll"><table><thead><tr><th>Category</th><th>Weight</th><th>Score</th><th>Findings</th></tr></thead><tbody>${categoryRows}</tbody></table></div></details>` : ""}${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>`;
1094
1433
  }).join("");
1095
1434
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>NKLattice governance assessment</title><style>
1096
- :root{color-scheme:light;font:16px/1.5 system-ui,sans-serif;background:#f6f7f5;color:#1b2925}body{max-width:1100px;margin:auto;padding:2rem 1rem}header,.app,.meta{background:white;border:1px solid #d9e1dc;border-radius:12px;padding:1.4rem;margin:0 0 1.2rem}h1{font-size:clamp(2rem,5vw,3.5rem);line-height:1.08;margin:.3rem 0 1rem}h2{font-size:1.35rem;margin:0 0 .75rem}p{margin:.5rem 0}code{overflow-wrap:anywhere}.pass{color:#146c43;font-weight:700}.fail{color:#b3242b;font-weight:700}.incomplete{color:#8a5500;font-weight:700}.scroll{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d9e1dc;padding:.8rem}th:first-child{width:9rem}ul{padding-left:1.2rem;margin:.1rem 0}li+li{margin-top:.3rem}small{color:#50645c}details{border-top:1px solid #d9e1dc;margin-top:1rem;padding-top:1rem}summary{cursor:pointer;font-weight:650}details[open] summary{margin-bottom:.6rem}
1097
- </style></head><body><header><small>NKLATTICE \xB7 CONFIGURED POLICY ASSESSMENT</small><h1>Project governance <span class="${report.status}">${escapeHtml(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s)</p></header><section class="meta"><h2>Evidence identity</h2><p>Policy ${escapeHtml(report.policy.version)} \xB7 <code>${escapeHtml(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml(report.inventory.source)}</code></p>${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml).join(", ")}.</p></section></body></html>
1435
+ :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}
1436
+ @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}">${escapeHtml(report.status)}</span></h1><p>${report.apps.length} discovered app(s) \xB7 ${report.inventory.expectedApps} expected app(s) \xB7 policy ${escapeHtml(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 ${escapeHtml(report.policy.version)} \xB7 <code>${escapeHtml(report.policy.sha256)}</code></p><p>Policy source: <code>${escapeHtml(report.policy.source)}</code></p><p>Inventory <code>${escapeHtml(report.inventory.sha256 ?? "unavailable")}</code></p><p>Inventory source: <code>${escapeHtml(basename(report.inventory.source))}</code></p>${report.evidence ? `<p>Scanners: ${escapeHtml(report.evidence.generatedAt)} \xB7 commit <code>${escapeHtml(report.evidence.commit)}</code> \xB7 input <code>${escapeHtml(report.evidence.inputSha256)}</code></p>` : "<p>Scanner evidence unavailable.</p>"}${report.issues.length ? `<ul>${report.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul>` : ""}</section>${apps}<section class="meta"><h2>Assessment scope</h2><p>Evaluated: ${report.scope.domains.map(escapeHtml).join(", ")}.</p><p>Not evaluated: ${report.scope.notEvaluated.map(escapeHtml).join(", ")}.</p></section></body></html>
1098
1437
  `;
1099
1438
  }
1100
1439
  async function assessGovernance(options) {
1101
- const scanRoot = resolve7(options.scanRoot);
1102
- const workspaceRoot = resolve7(options.workspaceRoot ?? scanRoot);
1103
- const scanRelative = relative6(workspaceRoot, scanRoot);
1104
- if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
1440
+ const scanRoot = resolve8(options.scanRoot);
1441
+ const workspaceRoot = resolve8(options.workspaceRoot ?? scanRoot);
1442
+ const scanRelative = relative7(workspaceRoot, scanRoot);
1443
+ if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
1105
1444
  if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
1106
1445
  const assessment = parseAssessmentPolicy(options.policy.assessment);
1107
- const outputDirectory = resolve7(options.outputDirectory ?? join7(scanRoot, ".nklattice-governance"));
1108
- const inventorySource = resolve7(options.inventory ?? join7(scanRoot, ".nklattice-apps.json"));
1446
+ const outputDirectory = resolve8(options.outputDirectory ?? join8(scanRoot, ".nklattice-governance"));
1447
+ const inventorySource = resolve8(options.inventory ?? join8(scanRoot, ".nklattice-apps.json"));
1109
1448
  let inventory;
1110
1449
  const issues = [];
1111
1450
  try {
@@ -1124,9 +1463,22 @@ async function assessGovernance(options) {
1124
1463
  if (options.expectedApps !== void 0 && apps.length !== options.expectedApps) {
1125
1464
  issues.push(`Expected ${options.expectedApps} managed apps; discovered ${apps.length}`);
1126
1465
  }
1466
+ let evidence;
1467
+ if (assessment.evidence) {
1468
+ if (!options.evidence && !options.trustedEvidence) issues.push("Scan evidence is required by the policy; run governance collect and pass --evidence");
1469
+ else try {
1470
+ 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);
1471
+ } catch (error) {
1472
+ issues.push(`Scan evidence: ${error instanceof Error ? error.message : String(error)}`);
1473
+ }
1474
+ if (evidence) {
1475
+ const discovered = new Set(apps.map((app) => relative7(scanRoot, app.directory).replaceAll("\\", "/") || "."));
1476
+ for (const item of evidence.apps) if (!discovered.has(item.path)) issues.push(`Scan evidence contains unrecognized app ${item.path}`);
1477
+ }
1478
+ }
1127
1479
  const results = [];
1128
1480
  for (const app of apps) {
1129
- const appPath = relative6(scanRoot, app.directory).replaceAll("\\", "/") || ".";
1481
+ const appPath = relative7(scanRoot, app.directory).replaceAll("\\", "/") || ".";
1130
1482
  const inventoryIssues = issues.filter((issue) => issue.includes(appPath) || issue.startsWith("Inventory:") || issue.startsWith("Discovery:"));
1131
1483
  const inventoryDomain = { status: inventoryIssues.length ? "incomplete" : "pass", issues: inventoryIssues };
1132
1484
  const dependencyDomain = { status: "pass", issues: [], findings: [] };
@@ -1179,7 +1531,8 @@ async function assessGovernance(options) {
1179
1531
  architectureDomain.status = "incomplete";
1180
1532
  architectureDomain.issues.push(error instanceof Error ? error.message : String(error));
1181
1533
  }
1182
- const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain };
1534
+ const extra = assessment.evidence ? evaluateAppEvidence(evidence?.apps.find((item) => item.path === appPath && item.profile === app.profile), assessment.evidence) : {};
1535
+ const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain, ...extra };
1183
1536
  results.push({ path: appPath, profile: app.profile, status: statusOf(Object.values(domains).map((domain) => domain.status)), domains });
1184
1537
  }
1185
1538
  const report = {
@@ -1187,15 +1540,19 @@ async function assessGovernance(options) {
1187
1540
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1188
1541
  status: issues.length ? "incomplete" : statusOf(results.map((app) => app.status)),
1189
1542
  policy: { version: options.policy.policyVersion, source: options.policy.source, sha256: options.policy.sha256 },
1543
+ ...evidence ? { evidence: { source: options.evidence ? resolve8(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
1190
1544
  inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
1191
- scope: { domains: ["explicit app inventory", "dependency minimums", "source architecture"], notEvaluated },
1545
+ scope: {
1546
+ domains: ["explicit app inventory", "dependency minimums", "source architecture", ...assessment.evidence ? ["known dependency advisories", "source quality", "source security", "browser and mobile emulation"] : []],
1547
+ notEvaluated: assessment.evidence ? ["licenses", "physical Android/iOS devices", "runtime performance", "cryptographic evidence attestation", "branch protection", "repository access controls", "cross-repository rollout status"] : notEvaluated
1548
+ },
1192
1549
  issues,
1193
1550
  apps: results
1194
1551
  };
1195
1552
  await mkdir(outputDirectory, { recursive: true });
1196
- await writeFile(join7(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
1553
+ await writeFile(join8(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
1197
1554
  `);
1198
- await writeFile(join7(outputDirectory, "report.html"), renderAssessmentHtml(report));
1555
+ await writeFile(join8(outputDirectory, "report.html"), renderAssessmentHtml(report));
1199
1556
  return report;
1200
1557
  }
1201
1558
  export {