@anhcodo13/lattice 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -101,6 +101,7 @@ function parseAnalysisConfig(value) {
101
101
  // src/governance/policy.ts
102
102
  var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
103
103
  var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
104
+ var measuredVersion = /^(0|[1-9]\d*)(?:\.(0|[1-9]\d*)){0,3}$/;
104
105
  function record2(value) {
105
106
  return typeof value === "object" && value !== null && !Array.isArray(value);
106
107
  }
@@ -139,12 +140,106 @@ function parseAssessmentPolicy(value) {
139
140
  for (const id of architecture.blockedRuleIds) {
140
141
  if (!known.has(id)) throw new Error(`Unknown blocked rule ID ${id}`);
141
142
  }
143
+ let evidence;
144
+ if (value.evidence !== void 0) {
145
+ if (!record2(value.evidence)) throw new Error("assessment.evidence must be an object");
146
+ const gates = value.evidence;
147
+ const nonnegative2 = (source, key, label) => {
148
+ const result = source[key];
149
+ if (!Number.isSafeInteger(result) || result < 0) throw new Error(`${label}.${key} must be a nonnegative integer`);
150
+ return result;
151
+ };
152
+ if (!Number.isFinite(gates.maximumAgeHours) || gates.maximumAgeHours <= 0 || gates.maximumAgeHours > 168) {
153
+ throw new Error("assessment.evidence.maximumAgeHours must be greater than 0 and at most 168");
154
+ }
155
+ for (const domain of ["dependencySecurity", "codeQuality", "codeSecurity", "compatibility"]) {
156
+ if (!record2(gates[domain])) throw new Error(`assessment.evidence.${domain} is required`);
157
+ }
158
+ const dependencySecurity = gates.dependencySecurity;
159
+ const codeQuality = gates.codeQuality;
160
+ const codeSecurity = gates.codeSecurity;
161
+ const compatibility = gates.compatibility;
162
+ const minimumLineCoverage = codeQuality.minimumLineCoverage;
163
+ if (typeof minimumLineCoverage !== "number" || !Number.isFinite(minimumLineCoverage) || minimumLineCoverage < 0 || minimumLineCoverage > 100) {
164
+ throw new Error("assessment.evidence.codeQuality.minimumLineCoverage must be 0\u2013100");
165
+ }
166
+ if (typeof codeQuality.requireTypecheck !== "boolean" || typeof codeQuality.requireTests !== "boolean") {
167
+ throw new Error("assessment.evidence.codeQuality requires boolean requireTypecheck and requireTests");
168
+ }
169
+ const projects = compatibility.requiredProjects;
170
+ if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
171
+ throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
172
+ }
173
+ const versionFloors = (key) => {
174
+ const raw = compatibility[key];
175
+ if (raw === void 0) return void 0;
176
+ if (!record2(raw)) throw new Error(`assessment.evidence.compatibility.${key} must be an object`);
177
+ for (const [project, version] of Object.entries(raw)) {
178
+ if (!projects.includes(project)) throw new Error(`${key} references unknown required project ${project}`);
179
+ if (key === "minimumEmulatedOsVersions" && project !== "android-chrome" && project !== "iphone-safari") {
180
+ throw new Error(`${key} requires an Android Chrome or iPhone Safari project`);
181
+ }
182
+ if (typeof version !== "string" || !measuredVersion.test(version) || !version.split(".").map(Number).every(Number.isSafeInteger)) {
183
+ throw new Error(`${key}.${project} must be a numeric version with one to four components`);
184
+ }
185
+ }
186
+ return raw;
187
+ };
188
+ const minimumTestedBrowserVersions = versionFloors("minimumTestedBrowserVersions");
189
+ const minimumEmulatedOsVersions = versionFloors("minimumEmulatedOsVersions");
190
+ let accessibility;
191
+ if (gates.accessibility !== void 0) {
192
+ if (!record2(gates.accessibility)) throw new Error("assessment.evidence.accessibility must be an object");
193
+ const source = gates.accessibility;
194
+ const requiredProjects = source.requiredProjects;
195
+ if (!Array.isArray(requiredProjects) || requiredProjects.length === 0 || !requiredProjects.every((project) => typeof project === "string" && projects.includes(project)) || new Set(requiredProjects).size !== requiredProjects.length) {
196
+ throw new Error("assessment.evidence.accessibility projects must be unique members of compatibility.requiredProjects");
197
+ }
198
+ const requiredTags = source.requiredTags;
199
+ if (!Array.isArray(requiredTags) || requiredTags.length === 0 || !requiredTags.every((tag) => typeof tag === "string" && /^wcag(?:2|21|22)(?:a|aa)$/.test(tag)) || new Set(requiredTags).size !== requiredTags.length) {
200
+ throw new Error("assessment.evidence.accessibility.requiredTags must list unique supported WCAG A/AA tags");
201
+ }
202
+ if (typeof source.minimumAxeVersion !== "string" || !minimumVersion.test(`>=${source.minimumAxeVersion}`) || valid(source.minimumAxeVersion) === null) {
203
+ throw new Error("assessment.evidence.accessibility.minimumAxeVersion must be an exact stable version");
204
+ }
205
+ accessibility = {
206
+ requiredProjects,
207
+ requiredTags,
208
+ minimumScansPerProject: nonnegative2(source, "minimumScansPerProject", "assessment.evidence.accessibility"),
209
+ minimumChecksPerScan: nonnegative2(source, "minimumChecksPerScan", "assessment.evidence.accessibility"),
210
+ minimumAxeVersion: source.minimumAxeVersion,
211
+ maximumViolations: nonnegative2(source, "maximumViolations", "assessment.evidence.accessibility"),
212
+ maximumNeedsReview: nonnegative2(source, "maximumNeedsReview", "assessment.evidence.accessibility")
213
+ };
214
+ if (accessibility.minimumScansPerProject === 0 || accessibility.minimumChecksPerScan === 0) {
215
+ throw new Error("assessment.evidence.accessibility minimum scans and checks must be positive");
216
+ }
217
+ }
218
+ evidence = {
219
+ maximumAgeHours: gates.maximumAgeHours,
220
+ dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
221
+ codeQuality: {
222
+ maximumLintErrors: nonnegative2(codeQuality, "maximumLintErrors", "assessment.evidence.codeQuality"),
223
+ minimumLineCoverage,
224
+ requireTypecheck: codeQuality.requireTypecheck,
225
+ requireTests: codeQuality.requireTests
226
+ },
227
+ codeSecurity: { maximumHigh: nonnegative2(codeSecurity, "maximumHigh", "assessment.evidence.codeSecurity"), maximumCritical: nonnegative2(codeSecurity, "maximumCritical", "assessment.evidence.codeSecurity"), maximumSecrets: nonnegative2(codeSecurity, "maximumSecrets", "assessment.evidence.codeSecurity") },
228
+ compatibility: {
229
+ requiredProjects: projects,
230
+ minimumTestsPerProject: nonnegative2(compatibility, "minimumTestsPerProject", "assessment.evidence.compatibility"),
231
+ ...minimumTestedBrowserVersions ? { minimumTestedBrowserVersions } : {},
232
+ ...minimumEmulatedOsVersions ? { minimumEmulatedOsVersions } : {}
233
+ },
234
+ ...accessibility ? { accessibility } : {}
235
+ };
236
+ }
142
237
  return { architecture: {
143
238
  minimumScore: architecture.minimumScore,
144
239
  maximumErrors: architecture.maximumErrors,
145
240
  blockedRuleIds: architecture.blockedRuleIds,
146
241
  analysis
147
- } };
242
+ }, ...evidence ? { evidence } : {} };
148
243
  }
149
244
  function parsePolicy(raw, source) {
150
245
  let parsed;
@@ -176,10 +271,25 @@ function parsePolicy(raw, source) {
176
271
  }
177
272
  profiles3[name] = versions;
178
273
  }
274
+ let managedPackages;
275
+ if (parsed.managedPackages !== void 0) {
276
+ if (!record2(parsed.managedPackages)) throw new Error("Policy managedPackages must be an object");
277
+ managedPackages = {};
278
+ for (const [packageName, version] of Object.entries(parsed.managedPackages)) {
279
+ if (typeof version !== "string" || !minimumVersion.test(version) || valid(version.slice(2)) === null) {
280
+ throw new Error(`Policy minimum version (>=X.Y.Z) required for managed package ${packageName}`);
281
+ }
282
+ if (Object.values(profiles3).some((profile) => packageName in profile)) {
283
+ throw new Error(`Package ${packageName} appears in both profiles and managedPackages`);
284
+ }
285
+ managedPackages[packageName] = version;
286
+ }
287
+ }
179
288
  return {
180
289
  schemaVersion: 2,
181
290
  policyVersion: parsed.policyVersion,
182
291
  profiles: profiles3,
292
+ ...managedPackages ? { managedPackages } : {},
183
293
  ...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
184
294
  source,
185
295
  sha256: createHash("sha256").update(raw).digest("hex")
@@ -203,9 +313,145 @@ async function loadPolicy(source) {
203
313
  }
204
314
 
205
315
  // src/governance/check.ts
206
- import { resolve as resolve3, relative as relative3, sep as sep2 } from "path";
316
+ import { readFile as readFile5 } from "fs/promises";
317
+ import { resolve as resolve3, relative as relative3, sep as sep2, join as join4, dirname as dirname2 } from "path";
318
+ import fastGlob2 from "fast-glob";
207
319
  import { satisfies, subset, valid as valid2, validRange } from "semver";
208
320
 
321
+ // src/analysis/parse.ts
322
+ import ts from "typescript";
323
+ function hasModifier(node, kind) {
324
+ return ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) === true;
325
+ }
326
+ function containsJsx(node) {
327
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true;
328
+ return ts.forEachChild(node, containsJsx) === true;
329
+ }
330
+ function importNames(clause) {
331
+ if (!clause) return [];
332
+ const names = [];
333
+ if (clause.name) names.push("default");
334
+ if (clause.namedBindings) {
335
+ if (ts.isNamespaceImport(clause.namedBindings)) names.push("*");
336
+ else for (const item of clause.namedBindings.elements) names.push(item.propertyName?.text ?? item.name.text);
337
+ }
338
+ return names;
339
+ }
340
+ function sourceKind(path) {
341
+ if (path.endsWith(".tsx")) return ts.ScriptKind.TSX;
342
+ if (path.endsWith(".jsx")) return ts.ScriptKind.JSX;
343
+ if (path.endsWith(".js") || path.endsWith(".mjs") || path.endsWith(".cjs")) return ts.ScriptKind.JS;
344
+ return ts.ScriptKind.TS;
345
+ }
346
+ function parseModule(path, contents) {
347
+ const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
348
+ const imports = [];
349
+ const exports = [];
350
+ const typeExports = [];
351
+ let jsxDepth = 0;
352
+ let complexity = 1;
353
+ let componentCount = 0;
354
+ for (const node of source.statements) {
355
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
356
+ imports.push({
357
+ specifier: node.moduleSpecifier.text,
358
+ line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
359
+ importedNames: importNames(node.importClause),
360
+ isTypeOnly: node.importClause?.isTypeOnly ?? false
361
+ });
362
+ }
363
+ if (ts.isExportDeclaration(node)) {
364
+ if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
365
+ imports.push({
366
+ specifier: node.moduleSpecifier.text,
367
+ line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
368
+ importedNames: node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements.map((item) => item.propertyName?.text ?? item.name.text) : ["*"],
369
+ isTypeOnly: node.isTypeOnly
370
+ });
371
+ }
372
+ if (node.exportClause && ts.isNamedExports(node.exportClause)) {
373
+ exports.push(...node.exportClause.elements.map((item) => item.name.text));
374
+ if (node.isTypeOnly) typeExports.push(...node.exportClause.elements.map((item) => item.name.text));
375
+ }
376
+ }
377
+ if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) {
378
+ if (hasModifier(node, ts.SyntaxKind.DefaultKeyword)) exports.push("default");
379
+ else if (ts.isVariableStatement(node)) {
380
+ for (const declaration of node.declarationList.declarations) {
381
+ if (ts.isIdentifier(declaration.name)) exports.push(declaration.name.text);
382
+ }
383
+ } else if ("name" in node && node.name && ts.isIdentifier(node.name)) {
384
+ const name = node.name.text;
385
+ exports.push(name);
386
+ if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) typeExports.push(name);
387
+ }
388
+ }
389
+ if (ts.isFunctionDeclaration(node) && node.name && /^[A-Z]/.test(node.name.text) && containsJsx(node)) {
390
+ componentCount++;
391
+ }
392
+ if (ts.isVariableStatement(node)) {
393
+ for (const declaration of node.declarationList.declarations) {
394
+ if (ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text) && declaration.initializer && containsJsx(declaration.initializer)) {
395
+ componentCount++;
396
+ }
397
+ }
398
+ }
399
+ }
400
+ function visit(node, depth) {
401
+ if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
402
+ const argument = node.arguments[0];
403
+ if (argument && ts.isStringLiteralLike(argument)) {
404
+ imports.push({
405
+ specifier: argument.text,
406
+ line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
407
+ importedNames: ["*"],
408
+ isTypeOnly: false
409
+ });
410
+ }
411
+ }
412
+ const isJsx = ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
413
+ const nextDepth = depth + (isJsx ? 1 : 0);
414
+ jsxDepth = Math.max(jsxDepth, nextDepth);
415
+ if (ts.isIfStatement(node) || ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isCatchClause(node) || ts.isConditionalExpression(node) || ts.isCaseBlock(node)) complexity++;
416
+ if (ts.isBinaryExpression(node) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind)) {
417
+ complexity++;
418
+ }
419
+ ts.forEachChild(node, (child) => visit(child, nextDepth));
420
+ }
421
+ visit(source, 0);
422
+ return {
423
+ path,
424
+ imports,
425
+ exports: [...new Set(exports)].sort(),
426
+ typeExports: [...new Set(typeExports)].sort(),
427
+ lines: contents.split(/\r?\n/).length,
428
+ jsxDepth,
429
+ componentCount,
430
+ complexity,
431
+ isRoute: /(^|\/)(app|pages)\/.+(page|layout|route|index)\.[cm]?[jt]sx?$/.test(path) || /(^|\/)app\/(page|layout|route)\.[cm]?[jt]sx?$/.test(path)
432
+ };
433
+ }
434
+ function moduleParseDiagnostics(path, contents) {
435
+ const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
436
+ const diagnostics = source.parseDiagnostics ?? [];
437
+ const messages = diagnostics.map((diagnostic) => {
438
+ const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1;
439
+ return `${path}:${line}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`;
440
+ });
441
+ function visit(node) {
442
+ if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
443
+ const argument = node.arguments[0];
444
+ if (!argument || !ts.isStringLiteralLike(argument)) {
445
+ const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
446
+ messages.push(`${path}:${line}: nonliteral dynamic import or require cannot be analyzed`);
447
+ }
448
+ }
449
+ ts.forEachChild(node, visit);
450
+ }
451
+ visit(source);
452
+ return messages;
453
+ }
454
+
209
455
  // src/governance/discover.ts
210
456
  import { readFile as readFile3 } from "fs/promises";
211
457
  import { join as join2, relative } from "path";
@@ -387,11 +633,36 @@ async function readResolution(app, workspaceRoot, name) {
387
633
  }
388
634
 
389
635
  // src/governance/check.ts
636
+ var scanIgnore = ["**/node_modules/**", "**/.git/**", "**/.next/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**", "**/*.d.ts"];
637
+ async function importedManagedPackages(app, managed) {
638
+ if (Object.keys(managed).length === 0) return /* @__PURE__ */ new Set();
639
+ const nestedManifests = await fastGlob2("**/package.json", { cwd: app.directory, onlyFiles: true, dot: true, followSymbolicLinks: false, ignore: scanIgnore });
640
+ const nestedIgnore = nestedManifests.filter((path) => path !== "package.json").map((path) => `${dirname2(path)}/**`);
641
+ const files = await fastGlob2("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
642
+ cwd: app.directory,
643
+ onlyFiles: true,
644
+ dot: true,
645
+ followSymbolicLinks: false,
646
+ ignore: [...scanIgnore, ...nestedIgnore]
647
+ });
648
+ const found = /* @__PURE__ */ new Set();
649
+ await Promise.all(files.map(async (path) => {
650
+ const module = parseModule(path, await readFile5(join4(app.directory, path), "utf8"));
651
+ for (const imported of module.imports) {
652
+ for (const name of Object.keys(managed)) {
653
+ if (imported.specifier === name || imported.specifier.startsWith(`${name}/`)) found.add(name);
654
+ }
655
+ }
656
+ }));
657
+ return found;
658
+ }
390
659
  async function checkApp(app, workspaceRoot, policy) {
391
660
  const requirements = policy.profiles[app.profile];
392
661
  if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
393
- const findings = [];
394
- for (const [packageName, expected] of Object.entries(requirements)) {
662
+ const findings2 = [];
663
+ const imported = await importedManagedPackages(app, policy.managedPackages ?? {});
664
+ const managed = Object.fromEntries(Object.entries(policy.managedPackages ?? {}).filter(([packageName]) => app.dependencies[packageName] !== void 0 || imported.has(packageName)));
665
+ for (const [packageName, expected] of Object.entries({ ...requirements, ...managed })) {
395
666
  const declared = app.dependencies[packageName];
396
667
  const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
397
668
  const reasons = [];
@@ -408,7 +679,7 @@ async function checkApp(app, workspaceRoot, policy) {
408
679
  reasons.push("lockfile resolution outside manifest range");
409
680
  }
410
681
  }
411
- if (reasons.length) findings.push({
682
+ if (reasons.length) findings2.push({
412
683
  app: app.relativePath,
413
684
  packageName,
414
685
  expected,
@@ -418,7 +689,7 @@ async function checkApp(app, workspaceRoot, policy) {
418
689
  reason: reasons.join("; ")
419
690
  });
420
691
  }
421
- return findings;
692
+ return findings2;
422
693
  }
423
694
  async function checkGovernance(options) {
424
695
  const scanRoot = resolve3(options.scanRoot);
@@ -429,10 +700,10 @@ async function checkGovernance(options) {
429
700
  }
430
701
  const apps = await discoverApps(scanRoot, workspaceRoot);
431
702
  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();
703
+ const findings2 = (await Promise.all(apps.map((app) => checkApp(app, workspaceRoot, options.policy)))).flat();
433
704
  return {
434
705
  appsChecked: apps.length,
435
- findings,
706
+ findings: findings2,
436
707
  policyVersion: options.policy.policyVersion,
437
708
  policySource: options.policy.source,
438
709
  policySha256: options.policy.sha256
@@ -441,7 +712,7 @@ async function checkGovernance(options) {
441
712
 
442
713
  // src/governance/assess.ts
443
714
  import { mkdir, writeFile } from "fs/promises";
444
- import { join as join7, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
715
+ import { basename, join as join9, relative as relative7, resolve as resolve8, sep as sep6 } from "path";
445
716
 
446
717
  // src/analysis/graph.ts
447
718
  function buildGraph(_modules) {
@@ -494,12 +765,12 @@ function findCycles(graph) {
494
765
  // src/analysis/rules.ts
495
766
  import { matchesGlob } from "path";
496
767
  function evaluateRules(graph, config) {
497
- const findings = [];
768
+ const findings2 = [];
498
769
  const outgoing = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.from === module.path && !edge.isTypeOnly)]));
499
770
  const incoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path && !edge.isTypeOnly)]));
500
771
  const allIncoming = new Map(graph.modules.map((module) => [module.path, graph.edges.filter((edge) => edge.to === module.path)]));
501
772
  for (const cycle of findCycles(graph)) {
502
- findings.push({
773
+ findings2.push({
503
774
  ruleId: "module-cycle",
504
775
  category: "architecture",
505
776
  severity: "error",
@@ -516,7 +787,7 @@ function evaluateRules(graph, config) {
516
787
  for (const imported of module.imports) {
517
788
  if (imported.isTypeOnly) continue;
518
789
  if (seen.has(imported.specifier)) {
519
- findings.push({
790
+ findings2.push({
520
791
  ruleId: "duplicate-import",
521
792
  category: "imports",
522
793
  severity: "warning",
@@ -529,7 +800,7 @@ function evaluateRules(graph, config) {
529
800
  seen.add(imported.specifier);
530
801
  }
531
802
  if (out.length > config.thresholds.maxFanOut) {
532
- findings.push({
803
+ findings2.push({
533
804
  ruleId: "high-fan-out",
534
805
  category: "architecture",
535
806
  severity: "warning",
@@ -540,7 +811,7 @@ function evaluateRules(graph, config) {
540
811
  });
541
812
  }
542
813
  if (into.length > config.thresholds.maxFanIn) {
543
- findings.push({
814
+ findings2.push({
544
815
  ruleId: "high-fan-in",
545
816
  category: "architecture",
546
817
  severity: "warning",
@@ -551,7 +822,7 @@ function evaluateRules(graph, config) {
551
822
  });
552
823
  }
553
824
  if (into.length === 0 && out.length === 0 && graph.modules.length > 1 && !module.isRoute) {
554
- findings.push({
825
+ findings2.push({
555
826
  ruleId: "orphan-module",
556
827
  category: "architecture",
557
828
  severity: "info",
@@ -567,7 +838,7 @@ function evaluateRules(graph, config) {
567
838
  for (const name of module.exports) {
568
839
  if (name === "*" || module.typeExports.includes(name)) continue;
569
840
  if (!usedNames.has(name)) {
570
- findings.push({
841
+ findings2.push({
571
842
  ruleId: "unused-export",
572
843
  category: "imports",
573
844
  severity: "info",
@@ -581,7 +852,7 @@ function evaluateRules(graph, config) {
581
852
  }
582
853
  }
583
854
  if (module.componentCount > 0 && module.lines > config.thresholds.maxComponentLines) {
584
- findings.push({
855
+ findings2.push({
585
856
  ruleId: "large-component",
586
857
  category: "components",
587
858
  severity: "warning",
@@ -592,7 +863,7 @@ function evaluateRules(graph, config) {
592
863
  });
593
864
  }
594
865
  if (module.jsxDepth > config.thresholds.maxJsxDepth) {
595
- findings.push({
866
+ findings2.push({
596
867
  ruleId: "deep-jsx",
597
868
  category: "components",
598
869
  severity: "warning",
@@ -603,7 +874,7 @@ function evaluateRules(graph, config) {
603
874
  });
604
875
  }
605
876
  if (module.complexity > config.thresholds.maxComplexity) {
606
- findings.push({
877
+ findings2.push({
607
878
  ruleId: "high-complexity",
608
879
  category: "complexity",
609
880
  severity: "warning",
@@ -619,7 +890,7 @@ function evaluateRules(graph, config) {
619
890
  const allowed = policy.allow?.some((pattern) => matchesGlob(edge.to, pattern));
620
891
  const disallowed = policy.disallow?.some((pattern) => matchesGlob(edge.to, pattern));
621
892
  if (policy.allow && !allowed || policy.disallow && disallowed) {
622
- findings.push({
893
+ findings2.push({
623
894
  ruleId: policy.id,
624
895
  category: "architecture",
625
896
  severity: policy.severity,
@@ -646,7 +917,7 @@ function evaluateRules(graph, config) {
646
917
  return false;
647
918
  };
648
919
  if (visit(module.path, /* @__PURE__ */ new Set([module.path]), 0)) {
649
- findings.push({
920
+ findings2.push({
650
921
  ruleId: "deep-dependency-chain",
651
922
  category: "architecture",
652
923
  severity: "warning",
@@ -657,157 +928,23 @@ function evaluateRules(graph, config) {
657
928
  });
658
929
  }
659
930
  }
660
- return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
931
+ return findings2.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.ruleId.localeCompare(b.ruleId));
661
932
  }
662
933
 
663
934
  // src/analysis/scan.ts
664
- import { readFile as readFile6 } from "fs/promises";
665
- import { join as join5, resolve as resolve5 } from "path";
666
- import fastGlob2 from "fast-glob";
667
-
668
- // src/analysis/parse.ts
669
- import ts from "typescript";
670
- function hasModifier(node, kind) {
671
- return ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) === true;
672
- }
673
- function containsJsx(node) {
674
- if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true;
675
- return ts.forEachChild(node, containsJsx) === true;
676
- }
677
- function importNames(clause) {
678
- if (!clause) return [];
679
- const names = [];
680
- if (clause.name) names.push("default");
681
- if (clause.namedBindings) {
682
- if (ts.isNamespaceImport(clause.namedBindings)) names.push("*");
683
- else for (const item of clause.namedBindings.elements) names.push(item.propertyName?.text ?? item.name.text);
684
- }
685
- return names;
686
- }
687
- function sourceKind(path) {
688
- if (path.endsWith(".tsx")) return ts.ScriptKind.TSX;
689
- if (path.endsWith(".jsx")) return ts.ScriptKind.JSX;
690
- if (path.endsWith(".js") || path.endsWith(".mjs") || path.endsWith(".cjs")) return ts.ScriptKind.JS;
691
- return ts.ScriptKind.TS;
692
- }
693
- function parseModule(path, contents) {
694
- const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
695
- const imports = [];
696
- const exports = [];
697
- const typeExports = [];
698
- let jsxDepth = 0;
699
- let complexity = 1;
700
- let componentCount = 0;
701
- for (const node of source.statements) {
702
- if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
703
- imports.push({
704
- specifier: node.moduleSpecifier.text,
705
- line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
706
- importedNames: importNames(node.importClause),
707
- isTypeOnly: node.importClause?.isTypeOnly ?? false
708
- });
709
- }
710
- if (ts.isExportDeclaration(node)) {
711
- if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
712
- imports.push({
713
- specifier: node.moduleSpecifier.text,
714
- line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
715
- importedNames: node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements.map((item) => item.propertyName?.text ?? item.name.text) : ["*"],
716
- isTypeOnly: node.isTypeOnly
717
- });
718
- }
719
- if (node.exportClause && ts.isNamedExports(node.exportClause)) {
720
- exports.push(...node.exportClause.elements.map((item) => item.name.text));
721
- if (node.isTypeOnly) typeExports.push(...node.exportClause.elements.map((item) => item.name.text));
722
- }
723
- }
724
- if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) {
725
- if (hasModifier(node, ts.SyntaxKind.DefaultKeyword)) exports.push("default");
726
- else if (ts.isVariableStatement(node)) {
727
- for (const declaration of node.declarationList.declarations) {
728
- if (ts.isIdentifier(declaration.name)) exports.push(declaration.name.text);
729
- }
730
- } else if ("name" in node && node.name && ts.isIdentifier(node.name)) {
731
- const name = node.name.text;
732
- exports.push(name);
733
- if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) typeExports.push(name);
734
- }
735
- }
736
- if (ts.isFunctionDeclaration(node) && node.name && /^[A-Z]/.test(node.name.text) && containsJsx(node)) {
737
- componentCount++;
738
- }
739
- if (ts.isVariableStatement(node)) {
740
- for (const declaration of node.declarationList.declarations) {
741
- if (ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text) && declaration.initializer && containsJsx(declaration.initializer)) {
742
- componentCount++;
743
- }
744
- }
745
- }
746
- }
747
- function visit(node, depth) {
748
- if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
749
- const argument = node.arguments[0];
750
- if (argument && ts.isStringLiteralLike(argument)) {
751
- imports.push({
752
- specifier: argument.text,
753
- line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
754
- importedNames: ["*"],
755
- isTypeOnly: false
756
- });
757
- }
758
- }
759
- const isJsx = ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
760
- const nextDepth = depth + (isJsx ? 1 : 0);
761
- jsxDepth = Math.max(jsxDepth, nextDepth);
762
- if (ts.isIfStatement(node) || ts.isForStatement(node) || ts.isForOfStatement(node) || ts.isForInStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node) || ts.isCatchClause(node) || ts.isConditionalExpression(node) || ts.isCaseBlock(node)) complexity++;
763
- if (ts.isBinaryExpression(node) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind)) {
764
- complexity++;
765
- }
766
- ts.forEachChild(node, (child) => visit(child, nextDepth));
767
- }
768
- visit(source, 0);
769
- return {
770
- path,
771
- imports,
772
- exports: [...new Set(exports)].sort(),
773
- typeExports: [...new Set(typeExports)].sort(),
774
- lines: contents.split(/\r?\n/).length,
775
- jsxDepth,
776
- componentCount,
777
- complexity,
778
- isRoute: /(^|\/)(app|pages)\/.+(page|layout|route|index)\.[cm]?[jt]sx?$/.test(path) || /(^|\/)app\/(page|layout|route)\.[cm]?[jt]sx?$/.test(path)
779
- };
780
- }
781
- function moduleParseDiagnostics(path, contents) {
782
- const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
783
- const diagnostics = source.parseDiagnostics ?? [];
784
- const messages = diagnostics.map((diagnostic) => {
785
- const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1;
786
- return `${path}:${line}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`;
787
- });
788
- function visit(node) {
789
- if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
790
- const argument = node.arguments[0];
791
- if (!argument || !ts.isStringLiteralLike(argument)) {
792
- const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
793
- messages.push(`${path}:${line}: nonliteral dynamic import or require cannot be analyzed`);
794
- }
795
- }
796
- ts.forEachChild(node, visit);
797
- }
798
- visit(source);
799
- return messages;
800
- }
935
+ import { readFile as readFile7 } from "fs/promises";
936
+ import { join as join6, resolve as resolve5 } from "path";
937
+ import fastGlob3 from "fast-glob";
801
938
 
802
939
  // src/analysis/resolve.ts
803
- import { readFile as readFile5, stat } from "fs/promises";
804
- import { dirname as dirname2, extname, join as join4, relative as relative4, resolve as resolve4, sep as sep3 } from "path";
940
+ import { readFile as readFile6, stat } from "fs/promises";
941
+ import { dirname as dirname3, extname, join as join5, relative as relative4, resolve as resolve4, sep as sep3 } from "path";
805
942
  import ts2 from "typescript";
806
943
  async function compilerOptions(root) {
807
- const path = join4(root, "tsconfig.json");
944
+ const path = join5(root, "tsconfig.json");
808
945
  let content;
809
946
  try {
810
- content = await readFile5(path, "utf8");
947
+ content = await readFile6(path, "utf8");
811
948
  } catch (error) {
812
949
  if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return { options: {}, diagnostics: [] };
813
950
  throw error;
@@ -884,7 +1021,7 @@ async function viteAliasDiagnostics(root, options, aliases) {
884
1021
  var visit = visit2;
885
1022
  let sourceText;
886
1023
  try {
887
- sourceText = await readFile5(join4(root, file), "utf8");
1024
+ sourceText = await readFile6(join5(root, file), "utf8");
888
1025
  } catch (error) {
889
1026
  if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue;
890
1027
  throw error;
@@ -915,12 +1052,12 @@ async function resolveImportsDetailed(root, modules, aliases = {}) {
915
1052
  diagnostics.push(...await viteAliasDiagnostics(root, options, aliases));
916
1053
  const files = new Set(modules.map((module) => module.path));
917
1054
  for (const module of modules) {
918
- const from = join4(root, module.path);
1055
+ const from = join5(root, module.path);
919
1056
  for (const imported of module.imports) {
920
1057
  const specifier = imported.specifier.split("?")[0];
921
1058
  const matchingAlias = Object.keys(aliases).sort((a, b) => b.length - a.length).find((prefix) => specifier.startsWith(prefix));
922
1059
  const aliasTarget = matchingAlias ? resolve4(root, aliases[matchingAlias], specifier.slice(matchingAlias.length)) : void 0;
923
- const candidate = aliasTarget ? relative4(dirname2(from), aliasTarget).replaceAll("\\", "/") : specifier;
1060
+ const candidate = aliasTarget ? relative4(dirname3(from), aliasTarget).replaceAll("\\", "/") : specifier;
924
1061
  const lookup = aliasTarget && !candidate.startsWith(".") ? `./${candidate}` : candidate;
925
1062
  const result = ts2.resolveModuleName(lookup, from, options, ts2.sys).resolvedModule;
926
1063
  const localAlias = Object.keys(options.paths ?? {}).some((pattern) => {
@@ -929,7 +1066,7 @@ async function resolveImportsDetailed(root, modules, aliases = {}) {
929
1066
  });
930
1067
  const isLocal = specifier.startsWith(".") || localAlias || aliasTarget !== void 0 || /^[~@#]\//.test(specifier);
931
1068
  if (!result) {
932
- const assetPath = aliasTarget ?? (specifier.startsWith(".") ? resolve4(dirname2(from), specifier) : void 0);
1069
+ const assetPath = aliasTarget ?? (specifier.startsWith(".") ? resolve4(dirname3(from), specifier) : void 0);
933
1070
  const assetRelative = assetPath ? relative4(root, assetPath) : void 0;
934
1071
  if (assetRelative && (assetRelative === ".." || assetRelative.startsWith(`..${sep3}`))) {
935
1072
  diagnostics.push(`${module.path}:${imported.line}: local import escapes app root ${imported.specifier}`);
@@ -955,7 +1092,7 @@ async function resolveImportsDetailed(root, modules, aliases = {}) {
955
1092
  // src/analysis/scan.ts
956
1093
  async function scanProjectDetailed(projectRoot, aliases = {}) {
957
1094
  const root = resolve5(projectRoot);
958
- const files = await fastGlob2("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
1095
+ const files = await fastGlob3("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
959
1096
  cwd: root,
960
1097
  onlyFiles: true,
961
1098
  unique: true,
@@ -965,7 +1102,7 @@ async function scanProjectDetailed(projectRoot, aliases = {}) {
965
1102
  });
966
1103
  const parsed = await Promise.all(files.sort().map(async (path) => {
967
1104
  const normalized = path.replaceAll("\\", "/");
968
- const contents = await readFile6(join5(root, path), "utf8");
1105
+ const contents = await readFile7(join6(root, path), "utf8");
969
1106
  return { module: parseModule(normalized, contents), diagnostics: moduleParseDiagnostics(normalized, contents) };
970
1107
  }));
971
1108
  const modules = parsed.map((item) => item.module);
@@ -986,10 +1123,10 @@ function grade(score) {
986
1123
  if (score >= 55) return "D";
987
1124
  return "F";
988
1125
  }
989
- function scoreFindings(findings, config) {
1126
+ function scoreFindings(findings2, config) {
990
1127
  const penalties = { error: 18, warning: 7, info: 2 };
991
1128
  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));
1129
+ const categoryFindings = findings2.filter((finding) => finding.category === id).sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.file.localeCompare(b.file));
993
1130
  const occurrences = /* @__PURE__ */ new Map();
994
1131
  let deduction = 0;
995
1132
  for (const finding of categoryFindings) {
@@ -1001,16 +1138,16 @@ function scoreFindings(findings, config) {
1001
1138
  return { id, weight, score: round(100 - deduction), deduction: round(deduction), findingCount: categoryFindings.length };
1002
1139
  });
1003
1140
  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));
1141
+ const maintenanceAdjustment = round(Math.min(5, Math.max(0, findings2.length - 10) * 0.15));
1142
+ const criticalAdjustment = round(Math.min(10, findings2.filter((finding) => finding.severity === "error").length * 1.5));
1006
1143
  const score = round(Math.max(0, Math.min(100, 100 - weightedDeduction - maintenanceAdjustment - criticalAdjustment)));
1007
1144
  return { score, grade: grade(score), weightedDeduction, maintenanceAdjustment, criticalAdjustment, categories };
1008
1145
  }
1009
1146
 
1010
1147
  // src/governance/inventory.ts
1011
1148
  import { createHash as createHash2 } from "crypto";
1012
- import { readFile as readFile7, realpath } from "fs/promises";
1013
- import { isAbsolute, join as join6, relative as relative5, resolve as resolve6, sep as sep4 } from "path";
1149
+ import { readFile as readFile8, realpath } from "fs/promises";
1150
+ import { isAbsolute, join as join7, relative as relative5, resolve as resolve6, sep as sep4 } from "path";
1014
1151
  var profiles2 = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
1015
1152
  async function loadInventory(source, scanRoot) {
1016
1153
  const absolute = resolve6(source);
@@ -1018,7 +1155,7 @@ async function loadInventory(source, scanRoot) {
1018
1155
  const inventoryPath = await realpath(absolute);
1019
1156
  const inventoryRelative = relative5(root, inventoryPath);
1020
1157
  if (inventoryRelative === ".." || inventoryRelative.startsWith(`..${sep4}`)) throw new Error("Inventory must be inside scan root");
1021
- const raw = await readFile7(inventoryPath, "utf8");
1158
+ const raw = await readFile8(inventoryPath, "utf8");
1022
1159
  let value;
1023
1160
  try {
1024
1161
  value = JSON.parse(raw);
@@ -1043,7 +1180,7 @@ async function loadInventory(source, scanRoot) {
1043
1180
  if (seen.has(path)) throw new Error(`Duplicate inventory app ${path}`);
1044
1181
  seen.add(path);
1045
1182
  try {
1046
- const actual = await realpath(join6(root, path));
1183
+ const actual = await realpath(join7(root, path));
1047
1184
  const rel = relative5(root, actual);
1048
1185
  if (rel === ".." || rel.startsWith(`..${sep4}`)) throw new Error(`App path escapes scan root: ${path}`);
1049
1186
  } catch (error) {
@@ -1065,6 +1202,377 @@ function compareInventory(inventory, discovered, scanRoot) {
1065
1202
  return issues;
1066
1203
  }
1067
1204
 
1205
+ // src/governance/evidence.ts
1206
+ import { createHash as createHash3 } from "crypto";
1207
+ import { execFile } from "child_process";
1208
+ import { createReadStream } from "fs";
1209
+ import { lstat, readFile as readFile9, realpath as realpath2 } from "fs/promises";
1210
+ import { dirname as dirname4, join as join8, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
1211
+ import { promisify } from "util";
1212
+ import fg from "fast-glob";
1213
+ var execFileAsync = promisify(execFile);
1214
+ function object(value) {
1215
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1216
+ }
1217
+ function string(value) {
1218
+ return typeof value === "string" && value.length > 0;
1219
+ }
1220
+ function nonnegative(value) {
1221
+ return Number.isSafeInteger(value) && value >= 0;
1222
+ }
1223
+ function errors(value) {
1224
+ return Array.isArray(value) && value.every(string);
1225
+ }
1226
+ function findings(value) {
1227
+ 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)));
1228
+ }
1229
+ function base(value) {
1230
+ return object(value) && string(value.tool) && string(value.version) && typeof value.complete === "boolean" && errors(value.errors);
1231
+ }
1232
+ function validateApp(value) {
1233
+ if (!object(value) || !string(value.path) || !string(value.profile)) return false;
1234
+ const dependency = value.dependencySecurity;
1235
+ const quality = value.codeQuality;
1236
+ const security = value.codeSecurity;
1237
+ const compatibility = value.compatibility;
1238
+ if (!base(dependency) || !nonnegative(dependency.packagesScanned) || !findings(dependency.advisories)) return false;
1239
+ 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;
1240
+ 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;
1241
+ 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;
1242
+ 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.emulatedOs === void 0 || string(project.emulatedOs)) && (project.emulatedOsVersion === void 0 || string(project.emulatedOsVersion)) && (project.viewportWidths === void 0 || Array.isArray(project.viewportWidths) && project.viewportWidths.every(nonnegative)))) return false;
1243
+ if (value.accessibility !== void 0) {
1244
+ const accessibility = value.accessibility;
1245
+ if (!base(accessibility) || !Array.isArray(accessibility.scans) || !accessibility.scans.every((scan) => object(scan) && string(scan.project) && string(scan.route) && /^\/[^?#]*$/.test(scan.route) && string(scan.axeVersion) && /^\d+\.\d+\.\d+$/.test(scan.axeVersion) && Array.isArray(scan.tags) && scan.tags.length > 0 && scan.tags.every(string) && new Set(scan.tags).size === scan.tags.length && nonnegative(scan.checks) && scan.checks > 0 && nonnegative(scan.needsReview) && findings(scan.violations))) return false;
1246
+ }
1247
+ return true;
1248
+ }
1249
+ async function hashFile(hash, path) {
1250
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
1251
+ }
1252
+ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
1253
+ const root = resolve7(scanRoot);
1254
+ const workspace = resolve7(workspaceRoot);
1255
+ const scanRelative = relative6(workspace, root);
1256
+ if (scanRelative === ".." || scanRelative.startsWith(`..${sep5}`)) throw new Error("Scan root must be inside workspace root");
1257
+ const paths = await fg("**/*", {
1258
+ cwd: root,
1259
+ dot: true,
1260
+ onlyFiles: true,
1261
+ followSymbolicLinks: false,
1262
+ ignore: [
1263
+ "**/.git/**",
1264
+ "**/node_modules/**",
1265
+ "**/dist/**",
1266
+ "**/build/**",
1267
+ "**/.next/**",
1268
+ "**/coverage/**",
1269
+ "**/playwright-report/**",
1270
+ "**/test-results/**",
1271
+ "**/.nklattice-governance/**",
1272
+ "**/.nklattice-report/**",
1273
+ "**/AGENTS.md",
1274
+ "**/*.tsbuildinfo",
1275
+ "**/next-env.d.ts"
1276
+ ]
1277
+ });
1278
+ paths.sort();
1279
+ const hash = createHash3("sha256");
1280
+ for (const path of paths) {
1281
+ const absolute = resolve7(root, path);
1282
+ const normalized = relative6(root, absolute);
1283
+ if (normalized === ".." || normalized.startsWith(`..${sep5}`)) throw new Error("Input path escapes scan root");
1284
+ if (!(await lstat(absolute)).isFile()) continue;
1285
+ hash.update(path.replaceAll("\\", "/"));
1286
+ hash.update("\0");
1287
+ await hashFile(hash, absolute);
1288
+ hash.update("\0");
1289
+ }
1290
+ let ancestor = root;
1291
+ while (ancestor === workspace || ancestor !== dirname4(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
1292
+ for (const name of [
1293
+ "package.json",
1294
+ "package-lock.json",
1295
+ "pnpm-lock.yaml",
1296
+ "pnpm-workspace.yaml",
1297
+ "tsconfig.json",
1298
+ "eslint.config.js",
1299
+ "eslint.config.mjs",
1300
+ "eslint.config.cjs",
1301
+ "eslint.config.ts",
1302
+ "eslint.config.mts",
1303
+ "vitest.config.js",
1304
+ "vitest.config.mjs",
1305
+ "vitest.config.ts",
1306
+ "vitest.config.mts",
1307
+ "playwright.config.js",
1308
+ "playwright.config.mjs",
1309
+ "playwright.config.ts",
1310
+ "playwright.config.mts",
1311
+ ".semgrep.yml",
1312
+ ".semgrep.yaml",
1313
+ "semgrep.yml",
1314
+ "semgrep.yaml"
1315
+ ]) {
1316
+ const path = join8(ancestor, name);
1317
+ try {
1318
+ const stat2 = await lstat(path);
1319
+ if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
1320
+ if (!stat2.isFile()) continue;
1321
+ hash.update(relative6(workspace, path).replaceAll("\\", "/"));
1322
+ hash.update("\0");
1323
+ await hashFile(hash, path);
1324
+ hash.update("\0");
1325
+ } catch (error) {
1326
+ if (error.code !== "ENOENT") throw error;
1327
+ }
1328
+ }
1329
+ if (ancestor === workspace) break;
1330
+ ancestor = dirname4(ancestor);
1331
+ }
1332
+ return hash.digest("hex");
1333
+ }
1334
+ async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
1335
+ const source = resolve7(path);
1336
+ const stat2 = await lstat(source);
1337
+ if (!stat2.isFile() || stat2.isSymbolicLink() || stat2.size > 1e7) throw new Error("Evidence must be a regular JSON file under 10 MB");
1338
+ const root = resolve7(scanRoot);
1339
+ const relativePath = relative6(root, source);
1340
+ if (relativePath !== ".." && !relativePath.startsWith(`..${sep5}`)) throw new Error("Evidence file must be outside the scan root");
1341
+ const actualRoot = await realpath2(root);
1342
+ const actualSource = await realpath2(source);
1343
+ const actualRelative = relative6(actualRoot, actualSource);
1344
+ if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
1345
+ let parsed;
1346
+ try {
1347
+ parsed = JSON.parse(await readFile9(source, "utf8"));
1348
+ } catch {
1349
+ throw new Error("Evidence is not valid JSON");
1350
+ }
1351
+ return validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours);
1352
+ }
1353
+ async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha256, maximumAgeHours) {
1354
+ 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");
1355
+ if (parsed.policySha256 !== policySha256) throw new Error("Evidence was generated for another policy");
1356
+ const generatedAt = Date.parse(parsed.generatedAt);
1357
+ if (!Number.isFinite(generatedAt) || generatedAt > Date.now() + 6e4 || Date.now() - generatedAt > maximumAgeHours * 36e5) {
1358
+ throw new Error("Evidence is stale or has an invalid timestamp");
1359
+ }
1360
+ if (parsed.inputSha256 !== await hashAssessmentInputs(scanRoot, workspaceRoot)) throw new Error("Evidence inputs changed after scanning");
1361
+ const current = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: workspaceRoot })).stdout.trim();
1362
+ if (!/^[0-9a-f]{40}$/.test(current) || parsed.commit !== current) throw new Error("Evidence commit differs from the checked-out commit");
1363
+ const appPaths = parsed.apps.map((app) => app.path);
1364
+ if (new Set(appPaths).size !== appPaths.length) throw new Error("Evidence contains duplicate app paths");
1365
+ return parsed;
1366
+ }
1367
+ var incomplete = (issues, tool, version, scanned) => ({ status: "incomplete", issues, tool, version, scanned });
1368
+ function numericVersion(value) {
1369
+ if (!value || !/^(0|[1-9]\d*)(?:\.(0|[1-9]\d*)){0,3}$/.test(value)) return void 0;
1370
+ const parts = value.split(".").map(Number);
1371
+ return parts.every(Number.isSafeInteger) ? parts : void 0;
1372
+ }
1373
+ function belowVersion(actual, minimum) {
1374
+ for (let index = 0; index < Math.max(actual.length, minimum.length); index++) {
1375
+ if ((actual[index] ?? 0) < (minimum[index] ?? 0)) return true;
1376
+ if ((actual[index] ?? 0) > (minimum[index] ?? 0)) return false;
1377
+ }
1378
+ return false;
1379
+ }
1380
+ function evaluateAppEvidence(app, policy) {
1381
+ if (!app) {
1382
+ const missing = incomplete(["No fresh scan evidence for this app"]);
1383
+ return {
1384
+ dependencySecurity: { ...missing },
1385
+ codeQuality: { ...missing },
1386
+ codeSecurity: { ...missing },
1387
+ compatibility: { ...missing },
1388
+ ...policy.accessibility ? { accessibility: { ...missing } } : {}
1389
+ };
1390
+ }
1391
+ const dependency = app.dependencySecurity;
1392
+ let dependencySecurity;
1393
+ if (!dependency.complete || dependency.packagesScanned === 0 || dependency.errors.length) {
1394
+ dependencySecurity = { ...incomplete(["Dependency advisory scan did not cover the lockfile", ...dependency.errors], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories };
1395
+ } else {
1396
+ const high2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "high").length;
1397
+ const critical2 = dependency.advisories.filter((item) => item.severity.toLowerCase() === "critical").length;
1398
+ const unknown = dependency.advisories.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
1399
+ dependencySecurity = unknown.length ? { ...incomplete([`${unknown.length} advisories have unknown severity`], dependency.tool, dependency.version, dependency.packagesScanned), findings: dependency.advisories } : {
1400
+ status: high2 > policy.dependencySecurity.maximumHigh || critical2 > policy.dependencySecurity.maximumCritical ? "fail" : "pass",
1401
+ issues: [
1402
+ ...high2 > policy.dependencySecurity.maximumHigh ? [`${high2} high advisories exceed ${policy.dependencySecurity.maximumHigh}`] : [],
1403
+ ...critical2 > policy.dependencySecurity.maximumCritical ? [`${critical2} critical advisories exceed ${policy.dependencySecurity.maximumCritical}`] : []
1404
+ ],
1405
+ tool: dependency.tool,
1406
+ version: dependency.version,
1407
+ scanned: dependency.packagesScanned,
1408
+ findings: dependency.advisories
1409
+ };
1410
+ }
1411
+ const quality = app.codeQuality;
1412
+ const qualityMissing = [
1413
+ ...!quality.lint.complete || quality.lint.filesScanned === 0 ? ["ESLint did not scan source files", ...quality.lint.errorsDetail] : [],
1414
+ ...policy.codeQuality.requireTypecheck && !quality.typecheck.complete ? ["TypeScript typecheck did not complete", ...quality.typecheck.errors] : [],
1415
+ ...policy.codeQuality.requireTests && (!quality.tests.complete || quality.tests.passed + quality.tests.failed === 0) ? ["Tests did not run", ...quality.tests.errors] : [],
1416
+ ...!quality.coverage.complete ? ["Line coverage is unavailable", ...quality.coverage.errors] : []
1417
+ ];
1418
+ const qualityFailures = [
1419
+ ...quality.lint.errorCount > policy.codeQuality.maximumLintErrors ? [`${quality.lint.errorCount} ESLint errors exceed ${policy.codeQuality.maximumLintErrors}`] : [],
1420
+ ...policy.codeQuality.requireTypecheck && !quality.typecheck.passed ? ["TypeScript typecheck failed"] : [],
1421
+ ...quality.tests.failed > 0 ? [`${quality.tests.failed} tests failed`] : [],
1422
+ ...quality.coverage.linesPercent < policy.codeQuality.minimumLineCoverage ? [`Line coverage ${quality.coverage.linesPercent}% is below ${policy.codeQuality.minimumLineCoverage}%`] : []
1423
+ ];
1424
+ const qualityVersion = `ESLint ${quality.lint.version} \xB7 TypeScript ${quality.typecheck.version} \xB7 Vitest ${quality.tests.version}`;
1425
+ const qualityMetrics = {
1426
+ lintErrors: quality.lint.errorCount,
1427
+ lintWarnings: quality.lint.warnings,
1428
+ typecheck: quality.typecheck.applicable === false ? "not applicable" : quality.typecheck.passed ? "pass" : "fail",
1429
+ testsPassed: quality.tests.passed,
1430
+ testsFailed: quality.tests.failed,
1431
+ lineCoveragePercent: quality.coverage.linesPercent
1432
+ };
1433
+ const codeQuality = qualityMissing.length ? { ...incomplete([...qualityMissing, ...qualityFailures], "ESLint / TypeScript / tests", qualityVersion, quality.lint.filesScanned), metrics: qualityMetrics } : {
1434
+ status: qualityFailures.length ? "fail" : "pass",
1435
+ issues: qualityFailures,
1436
+ tool: "ESLint / TypeScript / tests",
1437
+ version: qualityVersion,
1438
+ scanned: quality.lint.filesScanned,
1439
+ metrics: qualityMetrics
1440
+ };
1441
+ const security = app.codeSecurity;
1442
+ const securityMissing = [
1443
+ ...!security.sast.complete || security.sast.filesScanned === 0 ? ["SAST did not scan source files", ...security.sast.errors] : [],
1444
+ ...!security.secrets.complete || security.secrets.filesScanned === 0 ? ["Secret scanner did not scan source files", ...security.secrets.errors] : []
1445
+ ];
1446
+ const high = security.sast.findings.filter((item) => item.severity.toLowerCase() === "high").length;
1447
+ const critical = security.sast.findings.filter((item) => item.severity.toLowerCase() === "critical").length;
1448
+ const unknownSecurity = security.sast.findings.filter((item) => !["low", "moderate", "medium", "high", "critical"].includes(item.severity.toLowerCase()));
1449
+ if (unknownSecurity.length) securityMissing.push(`${unknownSecurity.length} SAST findings have unknown severity`);
1450
+ const securityFailures = [
1451
+ ...high > policy.codeSecurity.maximumHigh ? [`${high} high SAST findings exceed ${policy.codeSecurity.maximumHigh}`] : [],
1452
+ ...critical > policy.codeSecurity.maximumCritical ? [`${critical} critical SAST findings exceed ${policy.codeSecurity.maximumCritical}`] : [],
1453
+ ...security.secrets.findings.length > policy.codeSecurity.maximumSecrets ? [`${security.secrets.findings.length} detected secrets exceed ${policy.codeSecurity.maximumSecrets}`] : []
1454
+ ];
1455
+ const securityVersion = `Semgrep ${security.sast.version} \xB7 Gitleaks ${security.secrets.version}`;
1456
+ const codeSecurity = securityMissing.length ? { ...incomplete([...securityMissing, ...securityFailures], "Semgrep / Gitleaks", securityVersion, security.sast.filesScanned), findings: [...security.sast.findings, ...security.secrets.findings] } : {
1457
+ status: securityFailures.length ? "fail" : "pass",
1458
+ issues: securityFailures,
1459
+ tool: "Semgrep / Gitleaks",
1460
+ version: securityVersion,
1461
+ scanned: security.sast.filesScanned,
1462
+ findings: [...security.sast.findings, ...security.secrets.findings]
1463
+ };
1464
+ const compatibility = app.compatibility;
1465
+ const projectNames = new Set(compatibility.projects.map((project) => project.name));
1466
+ const missingProjects = policy.compatibility.requiredProjects.filter((name) => !projectNames.has(name));
1467
+ const incompleteProjects = compatibility.projects.filter((project) => project.passed + project.failed < policy.compatibility.minimumTestsPerProject);
1468
+ const missingBrowserVersions = compatibility.projects.filter((project) => !project.browserVersion);
1469
+ const expectedEngines = {
1470
+ "chromium-desktop": "chromium",
1471
+ "firefox-desktop": "firefox",
1472
+ "webkit-desktop": "webkit",
1473
+ "android-chrome": "chromium",
1474
+ "iphone-safari": "webkit"
1475
+ };
1476
+ const invalidEngines = compatibility.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
1477
+ const invalidDevices = compatibility.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
1478
+ const missingViewports = compatibility.projects.filter((project) => new Set(project.viewportWidths ?? []).size < 2);
1479
+ const versionEvidenceMissing = [];
1480
+ const versionFailures = [];
1481
+ for (const [name, minimum] of Object.entries(policy.compatibility.minimumTestedBrowserVersions ?? {})) {
1482
+ const project = compatibility.projects.find((item) => item.name === name);
1483
+ if (!project) continue;
1484
+ const actual = numericVersion(project.browserVersion);
1485
+ if (!actual) versionEvidenceMissing.push(`${name} did not record a valid measured browser version`);
1486
+ else if (belowVersion(actual, numericVersion(minimum))) {
1487
+ versionFailures.push(`${name} measured browser version ${project.browserVersion} is below minimum tested browser version ${minimum}`);
1488
+ }
1489
+ }
1490
+ for (const [name, minimum] of Object.entries(policy.compatibility.minimumEmulatedOsVersions ?? {})) {
1491
+ const project = compatibility.projects.find((item) => item.name === name);
1492
+ if (!project) continue;
1493
+ const expectedOs = name === "android-chrome" ? "Android" : "iOS";
1494
+ const actual = numericVersion(project.emulatedOsVersion);
1495
+ if (project.emulatedOs !== expectedOs || !actual) {
1496
+ versionEvidenceMissing.push(`${name} did not record a valid emulated ${expectedOs} version`);
1497
+ } else if (belowVersion(actual, numericVersion(minimum))) {
1498
+ versionFailures.push(`${name} emulated ${expectedOs} version ${project.emulatedOsVersion} is below minimum emulated ${expectedOs} version ${minimum}`);
1499
+ }
1500
+ }
1501
+ const compatibilityMissing = [
1502
+ ...!compatibility.complete ? ["Compatibility test run did not complete", ...compatibility.errors] : [],
1503
+ ...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
1504
+ ...incompleteProjects.map((project) => `${project.name} ran fewer than ${policy.compatibility.minimumTestsPerProject} tests`),
1505
+ ...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
1506
+ ...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
1507
+ ...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
1508
+ ...missingViewports.map((project) => `${project.name} did not test two viewport widths`),
1509
+ ...versionEvidenceMissing
1510
+ ];
1511
+ const failedProjects = compatibility.projects.filter((project) => project.failed > 0);
1512
+ const testFailures = failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`);
1513
+ const matrix = compatibility.projects.map((project) => ({
1514
+ ...project,
1515
+ minimumTestedBrowserVersion: policy.compatibility.minimumTestedBrowserVersions?.[project.name],
1516
+ minimumEmulatedOsVersion: policy.compatibility.minimumEmulatedOsVersions?.[project.name]
1517
+ }));
1518
+ const compatibilityDomain = compatibilityMissing.length ? { ...incomplete([...compatibilityMissing, ...testFailures, ...versionFailures], compatibility.tool, compatibility.version), matrix } : {
1519
+ status: testFailures.length || versionFailures.length ? "fail" : "pass",
1520
+ issues: [...testFailures, ...versionFailures],
1521
+ tool: compatibility.tool,
1522
+ version: compatibility.version,
1523
+ scanned: compatibility.projects.reduce((count, project) => count + project.passed + project.failed, 0),
1524
+ matrix
1525
+ };
1526
+ let accessibilityDomain;
1527
+ if (policy.accessibility) {
1528
+ const rules = policy.accessibility;
1529
+ const result = app.accessibility;
1530
+ if (!result) accessibilityDomain = incomplete(["Accessibility scan evidence is missing"]);
1531
+ else {
1532
+ const missing = [...!result.complete || result.errors.length ? ["Accessibility scan did not complete", ...result.errors] : []];
1533
+ for (const project of rules.requiredProjects) {
1534
+ const scans = result.scans.filter((scan) => scan.project === project);
1535
+ if (scans.length < rules.minimumScansPerProject) missing.push(`${project} has fewer than ${rules.minimumScansPerProject} accessibility scans`);
1536
+ }
1537
+ for (const scan of result.scans) {
1538
+ if (scan.checks < rules.minimumChecksPerScan) missing.push(`${scan.project} ${scan.route} checked fewer than ${rules.minimumChecksPerScan} axe rules`);
1539
+ if (rules.requiredTags.some((tag) => !scan.tags.includes(tag))) missing.push(`${scan.project} ${scan.route} omitted required WCAG tags`);
1540
+ const measuredVersion2 = numericVersion(scan.axeVersion);
1541
+ if (!measuredVersion2) missing.push(`${scan.project} ${scan.route} did not record a valid axe-core version`);
1542
+ else if (belowVersion(measuredVersion2, numericVersion(rules.minimumAxeVersion))) {
1543
+ missing.push(`${scan.project} ${scan.route} used axe-core ${scan.axeVersion} below ${rules.minimumAxeVersion}`);
1544
+ }
1545
+ }
1546
+ const findings2 = result.scans.flatMap((scan) => scan.violations);
1547
+ const needsReview = result.scans.reduce((sum, scan) => sum + scan.needsReview, 0);
1548
+ if (needsReview > rules.maximumNeedsReview) missing.push(`${needsReview} axe checks need manual review (maximum ${rules.maximumNeedsReview})`);
1549
+ const failures = findings2.length > rules.maximumViolations ? [`${findings2.length} accessibility violations exceed ${rules.maximumViolations}`] : [];
1550
+ const details = {
1551
+ tool: result.tool,
1552
+ version: result.version,
1553
+ scanned: result.scans.length,
1554
+ findings: findings2,
1555
+ metrics: { scans: result.scans.length, axeChecks: result.scans.reduce((sum, scan) => sum + scan.checks, 0), needsReview },
1556
+ routes: result.scans.map((scan) => ({
1557
+ project: scan.project,
1558
+ route: scan.route,
1559
+ axeVersion: scan.axeVersion,
1560
+ checks: scan.checks,
1561
+ needsReview: scan.needsReview
1562
+ }))
1563
+ };
1564
+ accessibilityDomain = failures.length ? { status: "fail", issues: [...failures, ...missing], ...details } : missing.length ? { ...incomplete(missing, result.tool, result.version, result.scans.length), ...details } : { status: "pass", issues: [], ...details };
1565
+ }
1566
+ }
1567
+ return {
1568
+ dependencySecurity,
1569
+ codeQuality,
1570
+ codeSecurity,
1571
+ compatibility: compatibilityDomain,
1572
+ ...accessibilityDomain ? { accessibility: accessibilityDomain } : {}
1573
+ };
1574
+ }
1575
+
1068
1576
  // src/governance/assess.ts
1069
1577
  var notEvaluated = [
1070
1578
  "vulnerability databases",
@@ -1085,27 +1593,52 @@ function escapeHtml(value) {
1085
1593
  return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1086
1594
  }
1087
1595
  function renderAssessmentHtml(report) {
1596
+ const labels = {
1597
+ inventory: "App inventory",
1598
+ dependencies: "Dependency policy",
1599
+ architecture: "Architecture",
1600
+ dependencySecurity: "Dependency security",
1601
+ codeQuality: "Code quality",
1602
+ codeSecurity: "Code security",
1603
+ compatibility: "Browser compatibility",
1604
+ accessibility: "Accessibility"
1605
+ };
1606
+ const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
1607
+ const overview = domainNames.map((name) => {
1608
+ const statuses = report.apps.map((app) => app.domains[name]?.status ?? "incomplete");
1609
+ const status = statusOf(statuses);
1610
+ const passed = statuses.filter((item) => item === "pass").length;
1611
+ return `<li><span>${escapeHtml(labels[name] ?? name)}</span><strong class="${status}">${escapeHtml(status)}</strong><small>${passed}/${report.apps.length} apps passed</small></li>`;
1612
+ }).join("");
1088
1613
  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("");
1614
+ const domains = Object.entries(app.domains).map(([name, domain]) => {
1615
+ const evidence = domain;
1616
+ const identity = evidence.tool ? `<p><small>${escapeHtml(evidence.tool)} ${escapeHtml(evidence.version ?? "")} \xB7 scanned ${escapeHtml(evidence.scanned ?? "unknown")}</small></p>` : "";
1617
+ const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(value)}`).join(" \xB7 ")}</small></p>` : "";
1618
+ 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>` : "";
1619
+ const matrix = evidence.matrix?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>Measured browser / minimum test environment</th><th>Mobile UA emulation / minimum</th><th>Widths</th></tr></thead><tbody>${evidence.matrix.map((item) => `<tr><th scope="row">${escapeHtml(item.name)}</th><td>${item.passed}</td><td>${item.failed}</td><td>${item.skipped}</td><td>${escapeHtml(item.browserVersion ?? "Not recorded")} / ${escapeHtml(item.minimumTestedBrowserVersion ?? "Not set")}</td><td>${escapeHtml(item.device ?? "None")} / ${escapeHtml(item.minimumEmulatedOsVersion ?? "Not set")}</td><td>${escapeHtml(item.viewportWidths?.join(", ") ?? "Not recorded")}</td></tr>`).join("")}</tbody></table><p><small>Version floors check the tested environment. Mobile OS values come from user-agent emulation, not physical devices or a promise of support for older versions.</small></p></div>` : "";
1620
+ const routes = evidence.routes?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Scanned route</th><th>axe-core</th><th>Checks</th><th>Needs review</th></tr></thead><tbody>${evidence.routes.map((item) => `<tr><th scope="row">${escapeHtml(item.project)}</th><td><code>${escapeHtml(item.route)}</code></td><td>${escapeHtml(item.axeVersion)}</td><td>${item.checks}</td><td>${item.needsReview}</td></tr>`).join("")}</tbody></table><p><small>Only these visited routes were scanned automatically. Manual accessibility review remains outside this result.</small></p></div>` : "";
1621
+ 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}${routes}</td></tr>`;
1622
+ }).join("");
1090
1623
  const architecture = app.domains.architecture;
1091
1624
  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>`;
1625
+ 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("");
1626
+ 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
1627
  }).join("");
1095
1628
  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>
1629
+ :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}
1630
+ @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
1631
  `;
1099
1632
  }
1100
1633
  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");
1634
+ const scanRoot = resolve8(options.scanRoot);
1635
+ const workspaceRoot = resolve8(options.workspaceRoot ?? scanRoot);
1636
+ const scanRelative = relative7(workspaceRoot, scanRoot);
1637
+ if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
1105
1638
  if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
1106
1639
  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"));
1640
+ const outputDirectory = resolve8(options.outputDirectory ?? join9(scanRoot, ".nklattice-governance"));
1641
+ const inventorySource = resolve8(options.inventory ?? join9(scanRoot, ".nklattice-apps.json"));
1109
1642
  let inventory;
1110
1643
  const issues = [];
1111
1644
  try {
@@ -1124,9 +1657,22 @@ async function assessGovernance(options) {
1124
1657
  if (options.expectedApps !== void 0 && apps.length !== options.expectedApps) {
1125
1658
  issues.push(`Expected ${options.expectedApps} managed apps; discovered ${apps.length}`);
1126
1659
  }
1660
+ let evidence;
1661
+ if (assessment.evidence) {
1662
+ if (!options.evidence && !options.trustedEvidence) issues.push("Scan evidence is required by the policy; run governance collect and pass --evidence");
1663
+ else try {
1664
+ 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);
1665
+ } catch (error) {
1666
+ issues.push(`Scan evidence: ${error instanceof Error ? error.message : String(error)}`);
1667
+ }
1668
+ if (evidence) {
1669
+ const discovered = new Set(apps.map((app) => relative7(scanRoot, app.directory).replaceAll("\\", "/") || "."));
1670
+ for (const item of evidence.apps) if (!discovered.has(item.path)) issues.push(`Scan evidence contains unrecognized app ${item.path}`);
1671
+ }
1672
+ }
1127
1673
  const results = [];
1128
1674
  for (const app of apps) {
1129
- const appPath = relative6(scanRoot, app.directory).replaceAll("\\", "/") || ".";
1675
+ const appPath = relative7(scanRoot, app.directory).replaceAll("\\", "/") || ".";
1130
1676
  const inventoryIssues = issues.filter((issue) => issue.includes(appPath) || issue.startsWith("Inventory:") || issue.startsWith("Discovery:"));
1131
1677
  const inventoryDomain = { status: inventoryIssues.length ? "incomplete" : "pass", issues: inventoryIssues };
1132
1678
  const dependencyDomain = { status: "pass", issues: [], findings: [] };
@@ -1179,7 +1725,8 @@ async function assessGovernance(options) {
1179
1725
  architectureDomain.status = "incomplete";
1180
1726
  architectureDomain.issues.push(error instanceof Error ? error.message : String(error));
1181
1727
  }
1182
- const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain };
1728
+ const extra = assessment.evidence ? evaluateAppEvidence(evidence?.apps.find((item) => item.path === appPath && item.profile === app.profile), assessment.evidence) : {};
1729
+ const domains = { inventory: inventoryDomain, dependencies: dependencyDomain, architecture: architectureDomain, ...extra };
1183
1730
  results.push({ path: appPath, profile: app.profile, status: statusOf(Object.values(domains).map((domain) => domain.status)), domains });
1184
1731
  }
1185
1732
  const report = {
@@ -1187,15 +1734,42 @@ async function assessGovernance(options) {
1187
1734
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1188
1735
  status: issues.length ? "incomplete" : statusOf(results.map((app) => app.status)),
1189
1736
  policy: { version: options.policy.policyVersion, source: options.policy.source, sha256: options.policy.sha256 },
1737
+ ...evidence ? { evidence: { source: options.evidence ? resolve8(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
1190
1738
  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 },
1739
+ scope: {
1740
+ domains: [
1741
+ "explicit app inventory",
1742
+ "dependency minimums",
1743
+ ...options.policy.managedPackages ? ["conditional managed-library minimums"] : [],
1744
+ "source architecture",
1745
+ ...assessment.evidence ? [
1746
+ "known dependency advisories",
1747
+ "source quality",
1748
+ "source security",
1749
+ "browser and mobile emulation",
1750
+ "tested browser and emulated OS version floors",
1751
+ ...assessment.evidence.accessibility ? ["automated accessibility"] : []
1752
+ ] : []
1753
+ ],
1754
+ notEvaluated: assessment.evidence ? [
1755
+ "licenses",
1756
+ "physical Android/iOS devices",
1757
+ "older browser-version support",
1758
+ "runtime performance",
1759
+ ...assessment.evidence.accessibility ? ["manual accessibility review and unvisited routes"] : ["accessibility"],
1760
+ "cryptographic evidence attestation",
1761
+ "branch protection",
1762
+ "repository access controls",
1763
+ "cross-repository rollout status"
1764
+ ] : notEvaluated
1765
+ },
1192
1766
  issues,
1193
1767
  apps: results
1194
1768
  };
1195
1769
  await mkdir(outputDirectory, { recursive: true });
1196
- await writeFile(join7(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
1770
+ await writeFile(join9(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
1197
1771
  `);
1198
- await writeFile(join7(outputDirectory, "report.html"), renderAssessmentHtml(report));
1772
+ await writeFile(join9(outputDirectory, "report.html"), renderAssessmentHtml(report));
1199
1773
  return report;
1200
1774
  }
1201
1775
  export {