@anhcodo13/lattice 0.4.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
  }
@@ -169,6 +170,51 @@ function parseAssessmentPolicy(value) {
169
170
  if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
170
171
  throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
171
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
+ }
172
218
  evidence = {
173
219
  maximumAgeHours: gates.maximumAgeHours,
174
220
  dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
@@ -179,7 +225,13 @@ function parseAssessmentPolicy(value) {
179
225
  requireTests: codeQuality.requireTests
180
226
  },
181
227
  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") }
228
+ compatibility: {
229
+ requiredProjects: projects,
230
+ minimumTestsPerProject: nonnegative2(compatibility, "minimumTestsPerProject", "assessment.evidence.compatibility"),
231
+ ...minimumTestedBrowserVersions ? { minimumTestedBrowserVersions } : {},
232
+ ...minimumEmulatedOsVersions ? { minimumEmulatedOsVersions } : {}
233
+ },
234
+ ...accessibility ? { accessibility } : {}
183
235
  };
184
236
  }
185
237
  return { architecture: {
@@ -219,10 +271,25 @@ function parsePolicy(raw, source) {
219
271
  }
220
272
  profiles3[name] = versions;
221
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
+ }
222
288
  return {
223
289
  schemaVersion: 2,
224
290
  policyVersion: parsed.policyVersion,
225
291
  profiles: profiles3,
292
+ ...managedPackages ? { managedPackages } : {},
226
293
  ...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
227
294
  source,
228
295
  sha256: createHash("sha256").update(raw).digest("hex")
@@ -246,9 +313,145 @@ async function loadPolicy(source) {
246
313
  }
247
314
 
248
315
  // src/governance/check.ts
249
- 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";
250
319
  import { satisfies, subset, valid as valid2, validRange } from "semver";
251
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
+
252
455
  // src/governance/discover.ts
253
456
  import { readFile as readFile3 } from "fs/promises";
254
457
  import { join as join2, relative } from "path";
@@ -430,11 +633,36 @@ async function readResolution(app, workspaceRoot, name) {
430
633
  }
431
634
 
432
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
+ }
433
659
  async function checkApp(app, workspaceRoot, policy) {
434
660
  const requirements = policy.profiles[app.profile];
435
661
  if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
436
662
  const findings2 = [];
437
- for (const [packageName, expected] of Object.entries(requirements)) {
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 })) {
438
666
  const declared = app.dependencies[packageName];
439
667
  const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
440
668
  const reasons = [];
@@ -484,7 +712,7 @@ async function checkGovernance(options) {
484
712
 
485
713
  // src/governance/assess.ts
486
714
  import { mkdir, writeFile } from "fs/promises";
487
- import { basename, join as join8, relative as relative7, resolve as resolve8, sep as sep6 } from "path";
715
+ import { basename, join as join9, relative as relative7, resolve as resolve8, sep as sep6 } from "path";
488
716
 
489
717
  // src/analysis/graph.ts
490
718
  function buildGraph(_modules) {
@@ -704,153 +932,19 @@ function evaluateRules(graph, config) {
704
932
  }
705
933
 
706
934
  // src/analysis/scan.ts
707
- import { readFile as readFile6 } from "fs/promises";
708
- import { join as join5, resolve as resolve5 } from "path";
709
- import fastGlob2 from "fast-glob";
710
-
711
- // src/analysis/parse.ts
712
- import ts from "typescript";
713
- function hasModifier(node, kind) {
714
- return ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) === true;
715
- }
716
- function containsJsx(node) {
717
- if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true;
718
- return ts.forEachChild(node, containsJsx) === true;
719
- }
720
- function importNames(clause) {
721
- if (!clause) return [];
722
- const names = [];
723
- if (clause.name) names.push("default");
724
- if (clause.namedBindings) {
725
- if (ts.isNamespaceImport(clause.namedBindings)) names.push("*");
726
- else for (const item of clause.namedBindings.elements) names.push(item.propertyName?.text ?? item.name.text);
727
- }
728
- return names;
729
- }
730
- function sourceKind(path) {
731
- if (path.endsWith(".tsx")) return ts.ScriptKind.TSX;
732
- if (path.endsWith(".jsx")) return ts.ScriptKind.JSX;
733
- if (path.endsWith(".js") || path.endsWith(".mjs") || path.endsWith(".cjs")) return ts.ScriptKind.JS;
734
- return ts.ScriptKind.TS;
735
- }
736
- function parseModule(path, contents) {
737
- const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
738
- const imports = [];
739
- const exports = [];
740
- const typeExports = [];
741
- let jsxDepth = 0;
742
- let complexity = 1;
743
- let componentCount = 0;
744
- for (const node of source.statements) {
745
- if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
746
- imports.push({
747
- specifier: node.moduleSpecifier.text,
748
- line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
749
- importedNames: importNames(node.importClause),
750
- isTypeOnly: node.importClause?.isTypeOnly ?? false
751
- });
752
- }
753
- if (ts.isExportDeclaration(node)) {
754
- if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
755
- imports.push({
756
- specifier: node.moduleSpecifier.text,
757
- line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
758
- importedNames: node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements.map((item) => item.propertyName?.text ?? item.name.text) : ["*"],
759
- isTypeOnly: node.isTypeOnly
760
- });
761
- }
762
- if (node.exportClause && ts.isNamedExports(node.exportClause)) {
763
- exports.push(...node.exportClause.elements.map((item) => item.name.text));
764
- if (node.isTypeOnly) typeExports.push(...node.exportClause.elements.map((item) => item.name.text));
765
- }
766
- }
767
- if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) {
768
- if (hasModifier(node, ts.SyntaxKind.DefaultKeyword)) exports.push("default");
769
- else if (ts.isVariableStatement(node)) {
770
- for (const declaration of node.declarationList.declarations) {
771
- if (ts.isIdentifier(declaration.name)) exports.push(declaration.name.text);
772
- }
773
- } else if ("name" in node && node.name && ts.isIdentifier(node.name)) {
774
- const name = node.name.text;
775
- exports.push(name);
776
- if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) typeExports.push(name);
777
- }
778
- }
779
- if (ts.isFunctionDeclaration(node) && node.name && /^[A-Z]/.test(node.name.text) && containsJsx(node)) {
780
- componentCount++;
781
- }
782
- if (ts.isVariableStatement(node)) {
783
- for (const declaration of node.declarationList.declarations) {
784
- if (ts.isIdentifier(declaration.name) && /^[A-Z]/.test(declaration.name.text) && declaration.initializer && containsJsx(declaration.initializer)) {
785
- componentCount++;
786
- }
787
- }
788
- }
789
- }
790
- function visit(node, depth) {
791
- if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
792
- const argument = node.arguments[0];
793
- if (argument && ts.isStringLiteralLike(argument)) {
794
- imports.push({
795
- specifier: argument.text,
796
- line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
797
- importedNames: ["*"],
798
- isTypeOnly: false
799
- });
800
- }
801
- }
802
- const isJsx = ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node);
803
- const nextDepth = depth + (isJsx ? 1 : 0);
804
- jsxDepth = Math.max(jsxDepth, nextDepth);
805
- 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++;
806
- if (ts.isBinaryExpression(node) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(node.operatorToken.kind)) {
807
- complexity++;
808
- }
809
- ts.forEachChild(node, (child) => visit(child, nextDepth));
810
- }
811
- visit(source, 0);
812
- return {
813
- path,
814
- imports,
815
- exports: [...new Set(exports)].sort(),
816
- typeExports: [...new Set(typeExports)].sort(),
817
- lines: contents.split(/\r?\n/).length,
818
- jsxDepth,
819
- componentCount,
820
- complexity,
821
- isRoute: /(^|\/)(app|pages)\/.+(page|layout|route|index)\.[cm]?[jt]sx?$/.test(path) || /(^|\/)app\/(page|layout|route)\.[cm]?[jt]sx?$/.test(path)
822
- };
823
- }
824
- function moduleParseDiagnostics(path, contents) {
825
- const source = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true, sourceKind(path));
826
- const diagnostics = source.parseDiagnostics ?? [];
827
- const messages = diagnostics.map((diagnostic) => {
828
- const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1;
829
- return `${path}:${line}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`;
830
- });
831
- function visit(node) {
832
- if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(node.expression) && node.expression.text === "require")) {
833
- const argument = node.arguments[0];
834
- if (!argument || !ts.isStringLiteralLike(argument)) {
835
- const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
836
- messages.push(`${path}:${line}: nonliteral dynamic import or require cannot be analyzed`);
837
- }
838
- }
839
- ts.forEachChild(node, visit);
840
- }
841
- visit(source);
842
- return messages;
843
- }
935
+ import { readFile as readFile7 } from "fs/promises";
936
+ import { join as join6, resolve as resolve5 } from "path";
937
+ import fastGlob3 from "fast-glob";
844
938
 
845
939
  // src/analysis/resolve.ts
846
- import { readFile as readFile5, stat } from "fs/promises";
847
- 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";
848
942
  import ts2 from "typescript";
849
943
  async function compilerOptions(root) {
850
- const path = join4(root, "tsconfig.json");
944
+ const path = join5(root, "tsconfig.json");
851
945
  let content;
852
946
  try {
853
- content = await readFile5(path, "utf8");
947
+ content = await readFile6(path, "utf8");
854
948
  } catch (error) {
855
949
  if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return { options: {}, diagnostics: [] };
856
950
  throw error;
@@ -927,7 +1021,7 @@ async function viteAliasDiagnostics(root, options, aliases) {
927
1021
  var visit = visit2;
928
1022
  let sourceText;
929
1023
  try {
930
- sourceText = await readFile5(join4(root, file), "utf8");
1024
+ sourceText = await readFile6(join5(root, file), "utf8");
931
1025
  } catch (error) {
932
1026
  if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue;
933
1027
  throw error;
@@ -958,12 +1052,12 @@ async function resolveImportsDetailed(root, modules, aliases = {}) {
958
1052
  diagnostics.push(...await viteAliasDiagnostics(root, options, aliases));
959
1053
  const files = new Set(modules.map((module) => module.path));
960
1054
  for (const module of modules) {
961
- const from = join4(root, module.path);
1055
+ const from = join5(root, module.path);
962
1056
  for (const imported of module.imports) {
963
1057
  const specifier = imported.specifier.split("?")[0];
964
1058
  const matchingAlias = Object.keys(aliases).sort((a, b) => b.length - a.length).find((prefix) => specifier.startsWith(prefix));
965
1059
  const aliasTarget = matchingAlias ? resolve4(root, aliases[matchingAlias], specifier.slice(matchingAlias.length)) : void 0;
966
- const candidate = aliasTarget ? relative4(dirname2(from), aliasTarget).replaceAll("\\", "/") : specifier;
1060
+ const candidate = aliasTarget ? relative4(dirname3(from), aliasTarget).replaceAll("\\", "/") : specifier;
967
1061
  const lookup = aliasTarget && !candidate.startsWith(".") ? `./${candidate}` : candidate;
968
1062
  const result = ts2.resolveModuleName(lookup, from, options, ts2.sys).resolvedModule;
969
1063
  const localAlias = Object.keys(options.paths ?? {}).some((pattern) => {
@@ -972,7 +1066,7 @@ async function resolveImportsDetailed(root, modules, aliases = {}) {
972
1066
  });
973
1067
  const isLocal = specifier.startsWith(".") || localAlias || aliasTarget !== void 0 || /^[~@#]\//.test(specifier);
974
1068
  if (!result) {
975
- const assetPath = aliasTarget ?? (specifier.startsWith(".") ? resolve4(dirname2(from), specifier) : void 0);
1069
+ const assetPath = aliasTarget ?? (specifier.startsWith(".") ? resolve4(dirname3(from), specifier) : void 0);
976
1070
  const assetRelative = assetPath ? relative4(root, assetPath) : void 0;
977
1071
  if (assetRelative && (assetRelative === ".." || assetRelative.startsWith(`..${sep3}`))) {
978
1072
  diagnostics.push(`${module.path}:${imported.line}: local import escapes app root ${imported.specifier}`);
@@ -998,7 +1092,7 @@ async function resolveImportsDetailed(root, modules, aliases = {}) {
998
1092
  // src/analysis/scan.ts
999
1093
  async function scanProjectDetailed(projectRoot, aliases = {}) {
1000
1094
  const root = resolve5(projectRoot);
1001
- 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}", {
1002
1096
  cwd: root,
1003
1097
  onlyFiles: true,
1004
1098
  unique: true,
@@ -1008,7 +1102,7 @@ async function scanProjectDetailed(projectRoot, aliases = {}) {
1008
1102
  });
1009
1103
  const parsed = await Promise.all(files.sort().map(async (path) => {
1010
1104
  const normalized = path.replaceAll("\\", "/");
1011
- const contents = await readFile6(join5(root, path), "utf8");
1105
+ const contents = await readFile7(join6(root, path), "utf8");
1012
1106
  return { module: parseModule(normalized, contents), diagnostics: moduleParseDiagnostics(normalized, contents) };
1013
1107
  }));
1014
1108
  const modules = parsed.map((item) => item.module);
@@ -1052,8 +1146,8 @@ function scoreFindings(findings2, config) {
1052
1146
 
1053
1147
  // src/governance/inventory.ts
1054
1148
  import { createHash as createHash2 } from "crypto";
1055
- import { readFile as readFile7, realpath } from "fs/promises";
1056
- 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";
1057
1151
  var profiles2 = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
1058
1152
  async function loadInventory(source, scanRoot) {
1059
1153
  const absolute = resolve6(source);
@@ -1061,7 +1155,7 @@ async function loadInventory(source, scanRoot) {
1061
1155
  const inventoryPath = await realpath(absolute);
1062
1156
  const inventoryRelative = relative5(root, inventoryPath);
1063
1157
  if (inventoryRelative === ".." || inventoryRelative.startsWith(`..${sep4}`)) throw new Error("Inventory must be inside scan root");
1064
- const raw = await readFile7(inventoryPath, "utf8");
1158
+ const raw = await readFile8(inventoryPath, "utf8");
1065
1159
  let value;
1066
1160
  try {
1067
1161
  value = JSON.parse(raw);
@@ -1086,7 +1180,7 @@ async function loadInventory(source, scanRoot) {
1086
1180
  if (seen.has(path)) throw new Error(`Duplicate inventory app ${path}`);
1087
1181
  seen.add(path);
1088
1182
  try {
1089
- const actual = await realpath(join6(root, path));
1183
+ const actual = await realpath(join7(root, path));
1090
1184
  const rel = relative5(root, actual);
1091
1185
  if (rel === ".." || rel.startsWith(`..${sep4}`)) throw new Error(`App path escapes scan root: ${path}`);
1092
1186
  } catch (error) {
@@ -1112,8 +1206,8 @@ function compareInventory(inventory, discovered, scanRoot) {
1112
1206
  import { createHash as createHash3 } from "crypto";
1113
1207
  import { execFile } from "child_process";
1114
1208
  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";
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";
1117
1211
  import { promisify } from "util";
1118
1212
  import fg from "fast-glob";
1119
1213
  var execFileAsync = promisify(execFile);
@@ -1145,7 +1239,11 @@ function validateApp(value) {
1145
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;
1146
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;
1147
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;
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;
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
+ }
1149
1247
  return true;
1150
1248
  }
1151
1249
  async function hashFile(hash, path) {
@@ -1190,7 +1288,7 @@ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
1190
1288
  hash.update("\0");
1191
1289
  }
1192
1290
  let ancestor = root;
1193
- while (ancestor === workspace || ancestor !== dirname3(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
1291
+ while (ancestor === workspace || ancestor !== dirname4(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
1194
1292
  for (const name of [
1195
1293
  "package.json",
1196
1294
  "package-lock.json",
@@ -1215,7 +1313,7 @@ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
1215
1313
  "semgrep.yml",
1216
1314
  "semgrep.yaml"
1217
1315
  ]) {
1218
- const path = join7(ancestor, name);
1316
+ const path = join8(ancestor, name);
1219
1317
  try {
1220
1318
  const stat2 = await lstat(path);
1221
1319
  if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
@@ -1229,7 +1327,7 @@ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
1229
1327
  }
1230
1328
  }
1231
1329
  if (ancestor === workspace) break;
1232
- ancestor = dirname3(ancestor);
1330
+ ancestor = dirname4(ancestor);
1233
1331
  }
1234
1332
  return hash.digest("hex");
1235
1333
  }
@@ -1246,7 +1344,7 @@ async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, max
1246
1344
  if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
1247
1345
  let parsed;
1248
1346
  try {
1249
- parsed = JSON.parse(await readFile8(source, "utf8"));
1347
+ parsed = JSON.parse(await readFile9(source, "utf8"));
1250
1348
  } catch {
1251
1349
  throw new Error("Evidence is not valid JSON");
1252
1350
  }
@@ -1267,10 +1365,28 @@ async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha25
1267
1365
  return parsed;
1268
1366
  }
1269
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
+ }
1270
1380
  function evaluateAppEvidence(app, policy) {
1271
1381
  if (!app) {
1272
1382
  const missing = incomplete(["No fresh scan evidence for this app"]);
1273
- return { dependencySecurity: { ...missing }, codeQuality: { ...missing }, codeSecurity: { ...missing }, compatibility: { ...missing } };
1383
+ return {
1384
+ dependencySecurity: { ...missing },
1385
+ codeQuality: { ...missing },
1386
+ codeSecurity: { ...missing },
1387
+ compatibility: { ...missing },
1388
+ ...policy.accessibility ? { accessibility: { ...missing } } : {}
1389
+ };
1274
1390
  }
1275
1391
  const dependency = app.dependencySecurity;
1276
1392
  let dependencySecurity;
@@ -1360,6 +1476,28 @@ function evaluateAppEvidence(app, policy) {
1360
1476
  const invalidEngines = compatibility.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
1361
1477
  const invalidDevices = compatibility.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
1362
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
+ }
1363
1501
  const compatibilityMissing = [
1364
1502
  ...!compatibility.complete ? ["Compatibility test run did not complete", ...compatibility.errors] : [],
1365
1503
  ...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
@@ -1367,18 +1505,72 @@ function evaluateAppEvidence(app, policy) {
1367
1505
  ...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
1368
1506
  ...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
1369
1507
  ...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
1370
- ...missingViewports.map((project) => `${project.name} did not test two viewport widths`)
1508
+ ...missingViewports.map((project) => `${project.name} did not test two viewport widths`),
1509
+ ...versionEvidenceMissing
1371
1510
  ];
1372
1511
  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`),
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],
1376
1521
  tool: compatibility.tool,
1377
1522
  version: compatibility.version,
1378
1523
  scanned: compatibility.projects.reduce((count, project) => count + project.passed + project.failed, 0),
1379
- matrix: compatibility.projects
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 } : {}
1380
1573
  };
1381
- return { dependencySecurity, codeQuality, codeSecurity, compatibility: compatibilityDomain };
1382
1574
  }
1383
1575
 
1384
1576
  // src/governance/assess.ts
@@ -1408,7 +1600,8 @@ function renderAssessmentHtml(report) {
1408
1600
  dependencySecurity: "Dependency security",
1409
1601
  codeQuality: "Code quality",
1410
1602
  codeSecurity: "Code security",
1411
- compatibility: "Browser compatibility"
1603
+ compatibility: "Browser compatibility",
1604
+ accessibility: "Accessibility"
1412
1605
  };
1413
1606
  const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
1414
1607
  const overview = domainNames.map((name) => {
@@ -1423,8 +1616,9 @@ function renderAssessmentHtml(report) {
1423
1616
  const identity = evidence.tool ? `<p><small>${escapeHtml(evidence.tool)} ${escapeHtml(evidence.version ?? "")} \xB7 scanned ${escapeHtml(evidence.scanned ?? "unknown")}</small></p>` : "";
1424
1617
  const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml(key)}: ${escapeHtml(value)}`).join(" \xB7 ")}</small></p>` : "";
1425
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>` : "";
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>`;
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>`;
1428
1622
  }).join("");
1429
1623
  const architecture = app.domains.architecture;
1430
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("") ?? "";
@@ -1443,8 +1637,8 @@ async function assessGovernance(options) {
1443
1637
  if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
1444
1638
  if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
1445
1639
  const assessment = parseAssessmentPolicy(options.policy.assessment);
1446
- const outputDirectory = resolve8(options.outputDirectory ?? join8(scanRoot, ".nklattice-governance"));
1447
- const inventorySource = resolve8(options.inventory ?? join8(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"));
1448
1642
  let inventory;
1449
1643
  const issues = [];
1450
1644
  try {
@@ -1543,16 +1737,39 @@ async function assessGovernance(options) {
1543
1737
  ...evidence ? { evidence: { source: options.evidence ? resolve8(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
1544
1738
  inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
1545
1739
  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
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
1548
1765
  },
1549
1766
  issues,
1550
1767
  apps: results
1551
1768
  };
1552
1769
  await mkdir(outputDirectory, { recursive: true });
1553
- await writeFile(join8(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
1770
+ await writeFile(join9(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
1554
1771
  `);
1555
- await writeFile(join8(outputDirectory, "report.html"), renderAssessmentHtml(report));
1772
+ await writeFile(join9(outputDirectory, "report.html"), renderAssessmentHtml(report));
1556
1773
  return report;
1557
1774
  }
1558
1775
  export {