@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/cli.js CHANGED
@@ -4,13 +4,13 @@
4
4
  import { writeFile as writeFile4 } from "fs/promises";
5
5
  import { realpathSync } from "fs";
6
6
  import { fileURLToPath } from "url";
7
- import { dirname as dirname5, join as join12, resolve as resolve11 } from "path";
7
+ import { dirname as dirname6, join as join13, resolve as resolve11 } from "path";
8
8
  import { Command, CommanderError } from "commander";
9
9
 
10
10
  // package.json
11
11
  var package_default = {
12
12
  name: "@anhcodo13/lattice",
13
- version: "0.4.0",
13
+ version: "0.5.0",
14
14
  private: false,
15
15
  packageManager: "pnpm@10.30.3",
16
16
  type: "module",
@@ -1159,7 +1159,9 @@ async function analyzeProject(projectRoot, options = {}) {
1159
1159
  }
1160
1160
 
1161
1161
  // src/governance/check.ts
1162
- import { resolve as resolve5, relative as relative4, sep as sep3 } from "path";
1162
+ import { readFile as readFile7 } from "fs/promises";
1163
+ import { resolve as resolve5, relative as relative4, sep as sep3, join as join8, dirname as dirname3 } from "path";
1164
+ import fastGlob3 from "fast-glob";
1163
1165
  import { satisfies, subset, valid, validRange } from "semver";
1164
1166
 
1165
1167
  // src/governance/discover.ts
@@ -1343,11 +1345,36 @@ async function readResolution(app, workspaceRoot, name) {
1343
1345
  }
1344
1346
 
1345
1347
  // src/governance/check.ts
1348
+ var scanIgnore = ["**/node_modules/**", "**/.git/**", "**/.next/**", "**/dist/**", "**/build/**", "**/out/**", "**/coverage/**", "**/.turbo/**", "**/.vercel/**", "**/.astro/**", "**/.nklattice-report/**", "**/.nklattice-governance/**", "**/*.d.ts"];
1349
+ async function importedManagedPackages(app, managed) {
1350
+ if (Object.keys(managed).length === 0) return /* @__PURE__ */ new Set();
1351
+ const nestedManifests = await fastGlob3("**/package.json", { cwd: app.directory, onlyFiles: true, dot: true, followSymbolicLinks: false, ignore: scanIgnore });
1352
+ const nestedIgnore = nestedManifests.filter((path) => path !== "package.json").map((path) => `${dirname3(path)}/**`);
1353
+ const files = await fastGlob3("**/*.{ts,tsx,js,jsx,mts,mjs,cts,cjs}", {
1354
+ cwd: app.directory,
1355
+ onlyFiles: true,
1356
+ dot: true,
1357
+ followSymbolicLinks: false,
1358
+ ignore: [...scanIgnore, ...nestedIgnore]
1359
+ });
1360
+ const found = /* @__PURE__ */ new Set();
1361
+ await Promise.all(files.map(async (path) => {
1362
+ const module = parseModule(path, await readFile7(join8(app.directory, path), "utf8"));
1363
+ for (const imported of module.imports) {
1364
+ for (const name of Object.keys(managed)) {
1365
+ if (imported.specifier === name || imported.specifier.startsWith(`${name}/`)) found.add(name);
1366
+ }
1367
+ }
1368
+ }));
1369
+ return found;
1370
+ }
1346
1371
  async function checkApp(app, workspaceRoot, policy) {
1347
1372
  const requirements = policy.profiles[app.profile];
1348
1373
  if (!requirements) throw new Error(`Policy does not define profile ${app.profile}`);
1349
1374
  const findings2 = [];
1350
- for (const [packageName, expected] of Object.entries(requirements)) {
1375
+ const imported = await importedManagedPackages(app, policy.managedPackages ?? {});
1376
+ const managed = Object.fromEntries(Object.entries(policy.managedPackages ?? {}).filter(([packageName]) => app.dependencies[packageName] !== void 0 || imported.has(packageName)));
1377
+ for (const [packageName, expected] of Object.entries({ ...requirements, ...managed })) {
1351
1378
  const declared = app.dependencies[packageName];
1352
1379
  const resolution = declared === void 0 ? { specifier: void 0, version: void 0 } : await readResolution(app, workspaceRoot, packageName);
1353
1380
  const reasons = [];
@@ -1397,12 +1424,12 @@ async function checkGovernance(options) {
1397
1424
 
1398
1425
  // src/governance/assess.ts
1399
1426
  import { mkdir as mkdir3, writeFile as writeFile2 } from "fs/promises";
1400
- import { basename as basename2, join as join10, relative as relative7, resolve as resolve9, sep as sep6 } from "path";
1427
+ import { basename as basename2, join as join11, relative as relative7, resolve as resolve9, sep as sep6 } from "path";
1401
1428
 
1402
1429
  // src/governance/inventory.ts
1403
1430
  import { createHash } from "crypto";
1404
- import { readFile as readFile7, realpath } from "fs/promises";
1405
- import { isAbsolute, join as join8, relative as relative5, resolve as resolve6, sep as sep4 } from "path";
1431
+ import { readFile as readFile8, realpath } from "fs/promises";
1432
+ import { isAbsolute, join as join9, relative as relative5, resolve as resolve6, sep as sep4 } from "path";
1406
1433
  var profiles2 = /* @__PURE__ */ new Set(["next", "vite-react", "react"]);
1407
1434
  async function loadInventory(source, scanRoot) {
1408
1435
  const absolute = resolve6(source);
@@ -1410,7 +1437,7 @@ async function loadInventory(source, scanRoot) {
1410
1437
  const inventoryPath = await realpath(absolute);
1411
1438
  const inventoryRelative = relative5(root, inventoryPath);
1412
1439
  if (inventoryRelative === ".." || inventoryRelative.startsWith(`..${sep4}`)) throw new Error("Inventory must be inside scan root");
1413
- const raw = await readFile7(inventoryPath, "utf8");
1440
+ const raw = await readFile8(inventoryPath, "utf8");
1414
1441
  let value;
1415
1442
  try {
1416
1443
  value = JSON.parse(raw);
@@ -1435,7 +1462,7 @@ async function loadInventory(source, scanRoot) {
1435
1462
  if (seen.has(path)) throw new Error(`Duplicate inventory app ${path}`);
1436
1463
  seen.add(path);
1437
1464
  try {
1438
- const actual = await realpath(join8(root, path));
1465
+ const actual = await realpath(join9(root, path));
1439
1466
  const rel = relative5(root, actual);
1440
1467
  if (rel === ".." || rel.startsWith(`..${sep4}`)) throw new Error(`App path escapes scan root: ${path}`);
1441
1468
  } catch (error) {
@@ -1461,8 +1488,8 @@ function compareInventory(inventory, discovered, scanRoot) {
1461
1488
  import { createHash as createHash2 } from "crypto";
1462
1489
  import { execFile } from "child_process";
1463
1490
  import { createReadStream } from "fs";
1464
- import { lstat, readFile as readFile8, realpath as realpath2 } from "fs/promises";
1465
- import { dirname as dirname3, join as join9, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
1491
+ import { lstat, readFile as readFile9, realpath as realpath2 } from "fs/promises";
1492
+ import { dirname as dirname4, join as join10, relative as relative6, resolve as resolve7, sep as sep5 } from "path";
1466
1493
  import { promisify } from "util";
1467
1494
  import fg from "fast-glob";
1468
1495
  var execFileAsync = promisify(execFile);
@@ -1494,7 +1521,11 @@ function validateApp(value) {
1494
1521
  if (!object(quality) || !object(quality.lint) || !string(quality.lint.tool) || !string(quality.lint.version) || typeof quality.lint.complete !== "boolean" || !base(quality.typecheck) || !base(quality.tests) || !base(quality.coverage)) return false;
1495
1522
  if (!nonnegative(quality.lint.filesScanned) || !nonnegative(quality.lint.errorCount) || !nonnegative(quality.lint.warnings) || !errors(quality.lint.errorsDetail) || typeof quality.typecheck.passed !== "boolean" || quality.typecheck.applicable !== void 0 && typeof quality.typecheck.applicable !== "boolean" || !nonnegative(quality.tests.passed) || !nonnegative(quality.tests.failed) || typeof quality.coverage.linesPercent !== "number" || !Number.isFinite(quality.coverage.linesPercent) || quality.coverage.linesPercent < 0 || quality.coverage.linesPercent > 100) return false;
1496
1523
  if (!object(security) || !base(security.sast) || !base(security.secrets) || !nonnegative(security.sast.filesScanned) || !nonnegative(security.secrets.filesScanned) || !findings(security.sast.findings) || !findings(security.secrets.findings)) return false;
1497
- if (!base(compatibility2) || !Array.isArray(compatibility2.projects) || !compatibility2.projects.every((project) => object(project) && string(project.name) && nonnegative(project.passed) && nonnegative(project.failed) && nonnegative(project.skipped) && (project.browserVersion === void 0 || string(project.browserVersion)) && (project.engine === void 0 || string(project.engine)) && (project.mobile === void 0 || typeof project.mobile === "boolean") && (project.device === void 0 || string(project.device)) && (project.viewportWidths === void 0 || Array.isArray(project.viewportWidths) && project.viewportWidths.every(nonnegative)))) return false;
1524
+ if (!base(compatibility2) || !Array.isArray(compatibility2.projects) || !compatibility2.projects.every((project) => object(project) && string(project.name) && nonnegative(project.passed) && nonnegative(project.failed) && nonnegative(project.skipped) && (project.browserVersion === void 0 || string(project.browserVersion)) && (project.engine === void 0 || string(project.engine)) && (project.mobile === void 0 || typeof project.mobile === "boolean") && (project.device === void 0 || string(project.device)) && (project.emulatedOs === void 0 || string(project.emulatedOs)) && (project.emulatedOsVersion === void 0 || string(project.emulatedOsVersion)) && (project.viewportWidths === void 0 || Array.isArray(project.viewportWidths) && project.viewportWidths.every(nonnegative)))) return false;
1525
+ if (value.accessibility !== void 0) {
1526
+ const accessibility = value.accessibility;
1527
+ if (!base(accessibility) || !Array.isArray(accessibility.scans) || !accessibility.scans.every((scan) => object(scan) && string(scan.project) && string(scan.route) && /^\/[^?#]*$/.test(scan.route) && string(scan.axeVersion) && /^\d+\.\d+\.\d+$/.test(scan.axeVersion) && Array.isArray(scan.tags) && scan.tags.length > 0 && scan.tags.every(string) && new Set(scan.tags).size === scan.tags.length && nonnegative(scan.checks) && scan.checks > 0 && nonnegative(scan.needsReview) && findings(scan.violations))) return false;
1528
+ }
1498
1529
  return true;
1499
1530
  }
1500
1531
  async function hashFile(hash, path) {
@@ -1539,7 +1570,7 @@ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
1539
1570
  hash.update("\0");
1540
1571
  }
1541
1572
  let ancestor = root;
1542
- while (ancestor === workspace || ancestor !== dirname3(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
1573
+ while (ancestor === workspace || ancestor !== dirname4(ancestor) && !relative6(workspace, ancestor).startsWith("..")) {
1543
1574
  for (const name of [
1544
1575
  "package.json",
1545
1576
  "package-lock.json",
@@ -1564,7 +1595,7 @@ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
1564
1595
  "semgrep.yml",
1565
1596
  "semgrep.yaml"
1566
1597
  ]) {
1567
- const path = join9(ancestor, name);
1598
+ const path = join10(ancestor, name);
1568
1599
  try {
1569
1600
  const stat2 = await lstat(path);
1570
1601
  if (stat2.isSymbolicLink()) throw new Error(`Symlinked scanner configuration is unsupported: ${path}`);
@@ -1578,7 +1609,7 @@ async function hashAssessmentInputs(scanRoot, workspaceRoot = scanRoot) {
1578
1609
  }
1579
1610
  }
1580
1611
  if (ancestor === workspace) break;
1581
- ancestor = dirname3(ancestor);
1612
+ ancestor = dirname4(ancestor);
1582
1613
  }
1583
1614
  return hash.digest("hex");
1584
1615
  }
@@ -1595,7 +1626,7 @@ async function loadScanEvidence(path, scanRoot, workspaceRoot, policySha256, max
1595
1626
  if (actualRelative !== ".." && !actualRelative.startsWith(`..${sep5}`)) throw new Error("Evidence file resolves inside the scan root");
1596
1627
  let parsed;
1597
1628
  try {
1598
- parsed = JSON.parse(await readFile8(source, "utf8"));
1629
+ parsed = JSON.parse(await readFile9(source, "utf8"));
1599
1630
  } catch {
1600
1631
  throw new Error("Evidence is not valid JSON");
1601
1632
  }
@@ -1616,10 +1647,28 @@ async function validateScanEvidence(parsed, scanRoot, workspaceRoot, policySha25
1616
1647
  return parsed;
1617
1648
  }
1618
1649
  var incomplete = (issues, tool, version2, scanned) => ({ status: "incomplete", issues, tool, version: version2, scanned });
1650
+ function numericVersion(value) {
1651
+ if (!value || !/^(0|[1-9]\d*)(?:\.(0|[1-9]\d*)){0,3}$/.test(value)) return void 0;
1652
+ const parts = value.split(".").map(Number);
1653
+ return parts.every(Number.isSafeInteger) ? parts : void 0;
1654
+ }
1655
+ function belowVersion(actual, minimum) {
1656
+ for (let index = 0; index < Math.max(actual.length, minimum.length); index++) {
1657
+ if ((actual[index] ?? 0) < (minimum[index] ?? 0)) return true;
1658
+ if ((actual[index] ?? 0) > (minimum[index] ?? 0)) return false;
1659
+ }
1660
+ return false;
1661
+ }
1619
1662
  function evaluateAppEvidence(app, policy) {
1620
1663
  if (!app) {
1621
1664
  const missing = incomplete(["No fresh scan evidence for this app"]);
1622
- return { dependencySecurity: { ...missing }, codeQuality: { ...missing }, codeSecurity: { ...missing }, compatibility: { ...missing } };
1665
+ return {
1666
+ dependencySecurity: { ...missing },
1667
+ codeQuality: { ...missing },
1668
+ codeSecurity: { ...missing },
1669
+ compatibility: { ...missing },
1670
+ ...policy.accessibility ? { accessibility: { ...missing } } : {}
1671
+ };
1623
1672
  }
1624
1673
  const dependency = app.dependencySecurity;
1625
1674
  let dependencySecurity;
@@ -1709,6 +1758,28 @@ function evaluateAppEvidence(app, policy) {
1709
1758
  const invalidEngines = compatibility2.projects.filter((project) => expectedEngines[project.name] && project.engine !== expectedEngines[project.name]);
1710
1759
  const invalidDevices = compatibility2.projects.filter((project) => (project.name === "android-chrome" || project.name === "iphone-safari") && (project.mobile !== true || !project.device));
1711
1760
  const missingViewports = compatibility2.projects.filter((project) => new Set(project.viewportWidths ?? []).size < 2);
1761
+ const versionEvidenceMissing = [];
1762
+ const versionFailures = [];
1763
+ for (const [name, minimum] of Object.entries(policy.compatibility.minimumTestedBrowserVersions ?? {})) {
1764
+ const project = compatibility2.projects.find((item) => item.name === name);
1765
+ if (!project) continue;
1766
+ const actual = numericVersion(project.browserVersion);
1767
+ if (!actual) versionEvidenceMissing.push(`${name} did not record a valid measured browser version`);
1768
+ else if (belowVersion(actual, numericVersion(minimum))) {
1769
+ versionFailures.push(`${name} measured browser version ${project.browserVersion} is below minimum tested browser version ${minimum}`);
1770
+ }
1771
+ }
1772
+ for (const [name, minimum] of Object.entries(policy.compatibility.minimumEmulatedOsVersions ?? {})) {
1773
+ const project = compatibility2.projects.find((item) => item.name === name);
1774
+ if (!project) continue;
1775
+ const expectedOs = name === "android-chrome" ? "Android" : "iOS";
1776
+ const actual = numericVersion(project.emulatedOsVersion);
1777
+ if (project.emulatedOs !== expectedOs || !actual) {
1778
+ versionEvidenceMissing.push(`${name} did not record a valid emulated ${expectedOs} version`);
1779
+ } else if (belowVersion(actual, numericVersion(minimum))) {
1780
+ versionFailures.push(`${name} emulated ${expectedOs} version ${project.emulatedOsVersion} is below minimum emulated ${expectedOs} version ${minimum}`);
1781
+ }
1782
+ }
1712
1783
  const compatibilityMissing = [
1713
1784
  ...!compatibility2.complete ? ["Compatibility test run did not complete", ...compatibility2.errors] : [],
1714
1785
  ...missingProjects.length ? [`Missing Playwright projects: ${missingProjects.join(", ")}`] : [],
@@ -1716,27 +1787,82 @@ function evaluateAppEvidence(app, policy) {
1716
1787
  ...missingBrowserVersions.map((project) => `${project.name} did not record its browser engine version`),
1717
1788
  ...invalidEngines.map((project) => `${project.name} used ${project.engine ?? "an unverified engine"} instead of ${expectedEngines[project.name]}`),
1718
1789
  ...invalidDevices.map((project) => `${project.name} did not verify mobile emulation settings`),
1719
- ...missingViewports.map((project) => `${project.name} did not test two viewport widths`)
1790
+ ...missingViewports.map((project) => `${project.name} did not test two viewport widths`),
1791
+ ...versionEvidenceMissing
1720
1792
  ];
1721
1793
  const failedProjects = compatibility2.projects.filter((project) => project.failed > 0);
1722
- const compatibilityDomain = compatibilityMissing.length ? { ...incomplete([...compatibilityMissing, ...failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`)], compatibility2.tool, compatibility2.version), matrix: compatibility2.projects } : {
1723
- status: failedProjects.length ? "fail" : "pass",
1724
- issues: failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`),
1794
+ const testFailures = failedProjects.map((project) => `${project.name}: ${project.failed} tests failed`);
1795
+ const matrix = compatibility2.projects.map((project) => ({
1796
+ ...project,
1797
+ minimumTestedBrowserVersion: policy.compatibility.minimumTestedBrowserVersions?.[project.name],
1798
+ minimumEmulatedOsVersion: policy.compatibility.minimumEmulatedOsVersions?.[project.name]
1799
+ }));
1800
+ const compatibilityDomain = compatibilityMissing.length ? { ...incomplete([...compatibilityMissing, ...testFailures, ...versionFailures], compatibility2.tool, compatibility2.version), matrix } : {
1801
+ status: testFailures.length || versionFailures.length ? "fail" : "pass",
1802
+ issues: [...testFailures, ...versionFailures],
1725
1803
  tool: compatibility2.tool,
1726
1804
  version: compatibility2.version,
1727
1805
  scanned: compatibility2.projects.reduce((count, project) => count + project.passed + project.failed, 0),
1728
- matrix: compatibility2.projects
1806
+ matrix
1807
+ };
1808
+ let accessibilityDomain;
1809
+ if (policy.accessibility) {
1810
+ const rules = policy.accessibility;
1811
+ const result = app.accessibility;
1812
+ if (!result) accessibilityDomain = incomplete(["Accessibility scan evidence is missing"]);
1813
+ else {
1814
+ const missing = [...!result.complete || result.errors.length ? ["Accessibility scan did not complete", ...result.errors] : []];
1815
+ for (const project of rules.requiredProjects) {
1816
+ const scans = result.scans.filter((scan) => scan.project === project);
1817
+ if (scans.length < rules.minimumScansPerProject) missing.push(`${project} has fewer than ${rules.minimumScansPerProject} accessibility scans`);
1818
+ }
1819
+ for (const scan of result.scans) {
1820
+ if (scan.checks < rules.minimumChecksPerScan) missing.push(`${scan.project} ${scan.route} checked fewer than ${rules.minimumChecksPerScan} axe rules`);
1821
+ if (rules.requiredTags.some((tag) => !scan.tags.includes(tag))) missing.push(`${scan.project} ${scan.route} omitted required WCAG tags`);
1822
+ const measuredVersion2 = numericVersion(scan.axeVersion);
1823
+ if (!measuredVersion2) missing.push(`${scan.project} ${scan.route} did not record a valid axe-core version`);
1824
+ else if (belowVersion(measuredVersion2, numericVersion(rules.minimumAxeVersion))) {
1825
+ missing.push(`${scan.project} ${scan.route} used axe-core ${scan.axeVersion} below ${rules.minimumAxeVersion}`);
1826
+ }
1827
+ }
1828
+ const findings2 = result.scans.flatMap((scan) => scan.violations);
1829
+ const needsReview = result.scans.reduce((sum, scan) => sum + scan.needsReview, 0);
1830
+ if (needsReview > rules.maximumNeedsReview) missing.push(`${needsReview} axe checks need manual review (maximum ${rules.maximumNeedsReview})`);
1831
+ const failures = findings2.length > rules.maximumViolations ? [`${findings2.length} accessibility violations exceed ${rules.maximumViolations}`] : [];
1832
+ const details = {
1833
+ tool: result.tool,
1834
+ version: result.version,
1835
+ scanned: result.scans.length,
1836
+ findings: findings2,
1837
+ metrics: { scans: result.scans.length, axeChecks: result.scans.reduce((sum, scan) => sum + scan.checks, 0), needsReview },
1838
+ routes: result.scans.map((scan) => ({
1839
+ project: scan.project,
1840
+ route: scan.route,
1841
+ axeVersion: scan.axeVersion,
1842
+ checks: scan.checks,
1843
+ needsReview: scan.needsReview
1844
+ }))
1845
+ };
1846
+ accessibilityDomain = failures.length ? { status: "fail", issues: [...failures, ...missing], ...details } : missing.length ? { ...incomplete(missing, result.tool, result.version, result.scans.length), ...details } : { status: "pass", issues: [], ...details };
1847
+ }
1848
+ }
1849
+ return {
1850
+ dependencySecurity,
1851
+ codeQuality,
1852
+ codeSecurity,
1853
+ compatibility: compatibilityDomain,
1854
+ ...accessibilityDomain ? { accessibility: accessibilityDomain } : {}
1729
1855
  };
1730
- return { dependencySecurity, codeQuality, codeSecurity, compatibility: compatibilityDomain };
1731
1856
  }
1732
1857
 
1733
1858
  // src/governance/policy.ts
1734
1859
  import { createHash as createHash3 } from "crypto";
1735
- import { readFile as readFile9 } from "fs/promises";
1860
+ import { readFile as readFile10 } from "fs/promises";
1736
1861
  import { resolve as resolve8 } from "path";
1737
1862
  import { valid as valid2 } from "semver";
1738
1863
  var exactVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
1739
1864
  var minimumVersion = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
1865
+ var measuredVersion = /^(0|[1-9]\d*)(?:\.(0|[1-9]\d*)){0,3}$/;
1740
1866
  function record3(value) {
1741
1867
  return typeof value === "object" && value !== null && !Array.isArray(value);
1742
1868
  }
@@ -1805,6 +1931,51 @@ function parseAssessmentPolicy(value) {
1805
1931
  if (!Array.isArray(projects) || projects.length === 0 || !projects.every((item) => typeof item === "string" && item.length > 0) || new Set(projects).size !== projects.length) {
1806
1932
  throw new Error("assessment.evidence.compatibility.requiredProjects must be a unique nonempty string array");
1807
1933
  }
1934
+ const versionFloors = (key) => {
1935
+ const raw = compatibility2[key];
1936
+ if (raw === void 0) return void 0;
1937
+ if (!record3(raw)) throw new Error(`assessment.evidence.compatibility.${key} must be an object`);
1938
+ for (const [project, version2] of Object.entries(raw)) {
1939
+ if (!projects.includes(project)) throw new Error(`${key} references unknown required project ${project}`);
1940
+ if (key === "minimumEmulatedOsVersions" && project !== "android-chrome" && project !== "iphone-safari") {
1941
+ throw new Error(`${key} requires an Android Chrome or iPhone Safari project`);
1942
+ }
1943
+ if (typeof version2 !== "string" || !measuredVersion.test(version2) || !version2.split(".").map(Number).every(Number.isSafeInteger)) {
1944
+ throw new Error(`${key}.${project} must be a numeric version with one to four components`);
1945
+ }
1946
+ }
1947
+ return raw;
1948
+ };
1949
+ const minimumTestedBrowserVersions = versionFloors("minimumTestedBrowserVersions");
1950
+ const minimumEmulatedOsVersions = versionFloors("minimumEmulatedOsVersions");
1951
+ let accessibility;
1952
+ if (gates.accessibility !== void 0) {
1953
+ if (!record3(gates.accessibility)) throw new Error("assessment.evidence.accessibility must be an object");
1954
+ const source = gates.accessibility;
1955
+ const requiredProjects = source.requiredProjects;
1956
+ if (!Array.isArray(requiredProjects) || requiredProjects.length === 0 || !requiredProjects.every((project) => typeof project === "string" && projects.includes(project)) || new Set(requiredProjects).size !== requiredProjects.length) {
1957
+ throw new Error("assessment.evidence.accessibility projects must be unique members of compatibility.requiredProjects");
1958
+ }
1959
+ const requiredTags = source.requiredTags;
1960
+ if (!Array.isArray(requiredTags) || requiredTags.length === 0 || !requiredTags.every((tag) => typeof tag === "string" && /^wcag(?:2|21|22)(?:a|aa)$/.test(tag)) || new Set(requiredTags).size !== requiredTags.length) {
1961
+ throw new Error("assessment.evidence.accessibility.requiredTags must list unique supported WCAG A/AA tags");
1962
+ }
1963
+ if (typeof source.minimumAxeVersion !== "string" || !minimumVersion.test(`>=${source.minimumAxeVersion}`) || valid2(source.minimumAxeVersion) === null) {
1964
+ throw new Error("assessment.evidence.accessibility.minimumAxeVersion must be an exact stable version");
1965
+ }
1966
+ accessibility = {
1967
+ requiredProjects,
1968
+ requiredTags,
1969
+ minimumScansPerProject: nonnegative2(source, "minimumScansPerProject", "assessment.evidence.accessibility"),
1970
+ minimumChecksPerScan: nonnegative2(source, "minimumChecksPerScan", "assessment.evidence.accessibility"),
1971
+ minimumAxeVersion: source.minimumAxeVersion,
1972
+ maximumViolations: nonnegative2(source, "maximumViolations", "assessment.evidence.accessibility"),
1973
+ maximumNeedsReview: nonnegative2(source, "maximumNeedsReview", "assessment.evidence.accessibility")
1974
+ };
1975
+ if (accessibility.minimumScansPerProject === 0 || accessibility.minimumChecksPerScan === 0) {
1976
+ throw new Error("assessment.evidence.accessibility minimum scans and checks must be positive");
1977
+ }
1978
+ }
1808
1979
  evidence = {
1809
1980
  maximumAgeHours: gates.maximumAgeHours,
1810
1981
  dependencySecurity: { maximumHigh: nonnegative2(dependencySecurity, "maximumHigh", "assessment.evidence.dependencySecurity"), maximumCritical: nonnegative2(dependencySecurity, "maximumCritical", "assessment.evidence.dependencySecurity") },
@@ -1815,7 +1986,13 @@ function parseAssessmentPolicy(value) {
1815
1986
  requireTests: codeQuality.requireTests
1816
1987
  },
1817
1988
  codeSecurity: { maximumHigh: nonnegative2(codeSecurity, "maximumHigh", "assessment.evidence.codeSecurity"), maximumCritical: nonnegative2(codeSecurity, "maximumCritical", "assessment.evidence.codeSecurity"), maximumSecrets: nonnegative2(codeSecurity, "maximumSecrets", "assessment.evidence.codeSecurity") },
1818
- compatibility: { requiredProjects: projects, minimumTestsPerProject: nonnegative2(compatibility2, "minimumTestsPerProject", "assessment.evidence.compatibility") }
1989
+ compatibility: {
1990
+ requiredProjects: projects,
1991
+ minimumTestsPerProject: nonnegative2(compatibility2, "minimumTestsPerProject", "assessment.evidence.compatibility"),
1992
+ ...minimumTestedBrowserVersions ? { minimumTestedBrowserVersions } : {},
1993
+ ...minimumEmulatedOsVersions ? { minimumEmulatedOsVersions } : {}
1994
+ },
1995
+ ...accessibility ? { accessibility } : {}
1819
1996
  };
1820
1997
  }
1821
1998
  return { architecture: {
@@ -1855,10 +2032,25 @@ function parsePolicy(raw, source) {
1855
2032
  }
1856
2033
  profiles3[name] = versions;
1857
2034
  }
2035
+ let managedPackages;
2036
+ if (parsed.managedPackages !== void 0) {
2037
+ if (!record3(parsed.managedPackages)) throw new Error("Policy managedPackages must be an object");
2038
+ managedPackages = {};
2039
+ for (const [packageName, version2] of Object.entries(parsed.managedPackages)) {
2040
+ if (typeof version2 !== "string" || !minimumVersion.test(version2) || valid2(version2.slice(2)) === null) {
2041
+ throw new Error(`Policy minimum version (>=X.Y.Z) required for managed package ${packageName}`);
2042
+ }
2043
+ if (Object.values(profiles3).some((profile) => packageName in profile)) {
2044
+ throw new Error(`Package ${packageName} appears in both profiles and managedPackages`);
2045
+ }
2046
+ managedPackages[packageName] = version2;
2047
+ }
2048
+ }
1858
2049
  return {
1859
2050
  schemaVersion: 2,
1860
2051
  policyVersion: parsed.policyVersion,
1861
2052
  profiles: profiles3,
2053
+ ...managedPackages ? { managedPackages } : {},
1862
2054
  ...parsed.assessment === void 0 ? {} : { assessment: parseAssessmentPolicy(parsed.assessment) },
1863
2055
  source,
1864
2056
  sha256: createHash3("sha256").update(raw).digest("hex")
@@ -1878,7 +2070,7 @@ async function loadPolicy(source) {
1878
2070
  }
1879
2071
  return parsePolicy(await response.text(), source);
1880
2072
  }
1881
- return parsePolicy(await readFile9(resolve8(source), "utf8"), source);
2073
+ return parsePolicy(await readFile10(resolve8(source), "utf8"), source);
1882
2074
  }
1883
2075
 
1884
2076
  // src/governance/assess.ts
@@ -1908,7 +2100,8 @@ function renderAssessmentHtml(report) {
1908
2100
  dependencySecurity: "Dependency security",
1909
2101
  codeQuality: "Code quality",
1910
2102
  codeSecurity: "Code security",
1911
- compatibility: "Browser compatibility"
2103
+ compatibility: "Browser compatibility",
2104
+ accessibility: "Accessibility"
1912
2105
  };
1913
2106
  const domainNames = report.apps.length ? Object.keys(report.apps[0].domains) : [];
1914
2107
  const overview = domainNames.map((name) => {
@@ -1923,8 +2116,9 @@ function renderAssessmentHtml(report) {
1923
2116
  const identity = evidence.tool ? `<p><small>${escapeHtml2(evidence.tool)} ${escapeHtml2(evidence.version ?? "")} \xB7 scanned ${escapeHtml2(evidence.scanned ?? "unknown")}</small></p>` : "";
1924
2117
  const metrics = evidence.metrics ? `<p><small>${Object.entries(evidence.metrics).map(([key, value]) => `${escapeHtml2(key)}: ${escapeHtml2(value)}`).join(" \xB7 ")}</small></p>` : "";
1925
2118
  const findings3 = evidence.findings?.length ? `<details><summary>${evidence.findings.length} finding(s)</summary><ul>${evidence.findings.map((item) => `<li><strong>${escapeHtml2(item.severity)}</strong> ${escapeHtml2(item.id)} \xB7 <code>${escapeHtml2(item.subject)}</code> \u2014 ${escapeHtml2(item.message)}${item.remediation ? ` \xB7 ${escapeHtml2(item.remediation)}` : ""}</li>`).join("")}</ul></details>` : "";
1926
- const matrix = evidence.matrix?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>Browser / device</th><th>Widths</th></tr></thead><tbody>${evidence.matrix.map((item) => `<tr><th scope="row">${escapeHtml2(item.name)}</th><td>${item.passed}</td><td>${item.failed}</td><td>${item.skipped}</td><td>${escapeHtml2([item.browserVersion, item.device].filter(Boolean).join(" \xB7 ") || "Not recorded")}</td><td>${escapeHtml2(item.viewportWidths?.join(", ") ?? "Not recorded")}</td></tr>`).join("")}</tbody></table></div>` : "";
1927
- return `<tr><th scope="row">${escapeHtml2(labels[name] ?? name)}</th><td class="${domain.status}">${escapeHtml2(domain.status)}</td><td>${identity}${metrics}${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : "No issues"}${findings3}${matrix}</td></tr>`;
2119
+ const matrix = evidence.matrix?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Passed</th><th>Failed</th><th>Skipped</th><th>Measured browser / minimum test environment</th><th>Mobile UA emulation / minimum</th><th>Widths</th></tr></thead><tbody>${evidence.matrix.map((item) => `<tr><th scope="row">${escapeHtml2(item.name)}</th><td>${item.passed}</td><td>${item.failed}</td><td>${item.skipped}</td><td>${escapeHtml2(item.browserVersion ?? "Not recorded")} / ${escapeHtml2(item.minimumTestedBrowserVersion ?? "Not set")}</td><td>${escapeHtml2(item.device ?? "None")} / ${escapeHtml2(item.minimumEmulatedOsVersion ?? "Not set")}</td><td>${escapeHtml2(item.viewportWidths?.join(", ") ?? "Not recorded")}</td></tr>`).join("")}</tbody></table><p><small>Version floors check the tested environment. Mobile OS values come from user-agent emulation, not physical devices or a promise of support for older versions.</small></p></div>` : "";
2120
+ const routes = evidence.routes?.length ? `<div class="scroll"><table><thead><tr><th>Project</th><th>Scanned route</th><th>axe-core</th><th>Checks</th><th>Needs review</th></tr></thead><tbody>${evidence.routes.map((item) => `<tr><th scope="row">${escapeHtml2(item.project)}</th><td><code>${escapeHtml2(item.route)}</code></td><td>${escapeHtml2(item.axeVersion)}</td><td>${item.checks}</td><td>${item.needsReview}</td></tr>`).join("")}</tbody></table><p><small>Only these visited routes were scanned automatically. Manual accessibility review remains outside this result.</small></p></div>` : "";
2121
+ return `<tr><th scope="row">${escapeHtml2(labels[name] ?? name)}</th><td class="${domain.status}">${escapeHtml2(domain.status)}</td><td>${identity}${metrics}${domain.issues.length ? `<ul>${domain.issues.map((issue) => `<li>${escapeHtml2(issue)}</li>`).join("")}</ul>` : "No issues"}${findings3}${matrix}${routes}</td></tr>`;
1928
2122
  }).join("");
1929
2123
  const architecture = app.domains.architecture;
1930
2124
  const categoryRows = architecture.scoreBreakdown?.categories.map((category) => `<tr><th scope="row">${escapeHtml2(category.id)}</th><td>${escapeHtml2(category.weight)}%</td><td>${escapeHtml2(category.score)}</td><td>${escapeHtml2(category.findingCount)}</td></tr>`).join("") ?? "";
@@ -1943,8 +2137,8 @@ async function assessGovernance(options) {
1943
2137
  if (scanRelative === ".." || scanRelative.startsWith(`..${sep6}`)) throw new Error("Scan root must be inside workspace root");
1944
2138
  if (!options.policy.assessment) throw new Error("Policy assessment.architecture is required");
1945
2139
  const assessment = parseAssessmentPolicy(options.policy.assessment);
1946
- const outputDirectory = resolve9(options.outputDirectory ?? join10(scanRoot, ".nklattice-governance"));
1947
- const inventorySource = resolve9(options.inventory ?? join10(scanRoot, ".nklattice-apps.json"));
2140
+ const outputDirectory = resolve9(options.outputDirectory ?? join11(scanRoot, ".nklattice-governance"));
2141
+ const inventorySource = resolve9(options.inventory ?? join11(scanRoot, ".nklattice-apps.json"));
1948
2142
  let inventory;
1949
2143
  const issues = [];
1950
2144
  try {
@@ -2043,25 +2237,48 @@ async function assessGovernance(options) {
2043
2237
  ...evidence ? { evidence: { source: options.evidence ? resolve9(options.evidence) : "in-process collector", generatedAt: evidence.generatedAt, commit: evidence.commit, inputSha256: evidence.inputSha256 } } : {},
2044
2238
  inventory: { source: inventorySource, sha256: inventory?.sha256, expectedApps: inventory?.apps.length ?? 0, discoveredApps: apps.length },
2045
2239
  scope: {
2046
- domains: ["explicit app inventory", "dependency minimums", "source architecture", ...assessment.evidence ? ["known dependency advisories", "source quality", "source security", "browser and mobile emulation"] : []],
2047
- notEvaluated: assessment.evidence ? ["licenses", "physical Android/iOS devices", "runtime performance", "cryptographic evidence attestation", "branch protection", "repository access controls", "cross-repository rollout status"] : notEvaluated
2240
+ domains: [
2241
+ "explicit app inventory",
2242
+ "dependency minimums",
2243
+ ...options.policy.managedPackages ? ["conditional managed-library minimums"] : [],
2244
+ "source architecture",
2245
+ ...assessment.evidence ? [
2246
+ "known dependency advisories",
2247
+ "source quality",
2248
+ "source security",
2249
+ "browser and mobile emulation",
2250
+ "tested browser and emulated OS version floors",
2251
+ ...assessment.evidence.accessibility ? ["automated accessibility"] : []
2252
+ ] : []
2253
+ ],
2254
+ notEvaluated: assessment.evidence ? [
2255
+ "licenses",
2256
+ "physical Android/iOS devices",
2257
+ "older browser-version support",
2258
+ "runtime performance",
2259
+ ...assessment.evidence.accessibility ? ["manual accessibility review and unvisited routes"] : ["accessibility"],
2260
+ "cryptographic evidence attestation",
2261
+ "branch protection",
2262
+ "repository access controls",
2263
+ "cross-repository rollout status"
2264
+ ] : notEvaluated
2048
2265
  },
2049
2266
  issues,
2050
2267
  apps: results
2051
2268
  };
2052
2269
  await mkdir3(outputDirectory, { recursive: true });
2053
- await writeFile2(join10(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
2270
+ await writeFile2(join11(outputDirectory, "assessment.json"), `${JSON.stringify(report, null, 2)}
2054
2271
  `);
2055
- await writeFile2(join10(outputDirectory, "report.html"), renderAssessmentHtml(report));
2272
+ await writeFile2(join11(outputDirectory, "report.html"), renderAssessmentHtml(report));
2056
2273
  return report;
2057
2274
  }
2058
2275
 
2059
2276
  // src/governance/collect.ts
2060
2277
  import { execFile as execFile2 } from "child_process";
2061
2278
  import { createHash as createHash4 } from "crypto";
2062
- import { mkdtemp, mkdir as mkdir4, readFile as readFile10, rm, writeFile as writeFile3 } from "fs/promises";
2279
+ import { mkdtemp, mkdir as mkdir4, readFile as readFile11, rm, writeFile as writeFile3 } from "fs/promises";
2063
2280
  import { tmpdir } from "os";
2064
- import { dirname as dirname4, join as join11, relative as relative8, resolve as resolve10, sep as sep7 } from "path";
2281
+ import { dirname as dirname5, join as join12, relative as relative8, resolve as resolve10, sep as sep7 } from "path";
2065
2282
  import { promisify as promisify2 } from "util";
2066
2283
  import fg2 from "fast-glob";
2067
2284
  import YAML2 from "yaml";
@@ -2088,14 +2305,14 @@ function inside(parent, child) {
2088
2305
  async function binary(name, app, workspaceRoot) {
2089
2306
  let directory = app;
2090
2307
  while (inside(workspaceRoot, directory)) {
2091
- const candidate = join11(directory, "node_modules", ".bin", name);
2308
+ const candidate = join12(directory, "node_modules", ".bin", name);
2092
2309
  try {
2093
- await readFile10(candidate);
2310
+ await readFile11(candidate);
2094
2311
  return candidate;
2095
2312
  } catch {
2096
2313
  }
2097
2314
  if (directory === workspaceRoot) break;
2098
- directory = dirname4(directory);
2315
+ directory = dirname5(directory);
2099
2316
  }
2100
2317
  return name;
2101
2318
  }
@@ -2154,7 +2371,7 @@ async function dependencyAudit(app, workspaceRoot, cache) {
2154
2371
  }
2155
2372
  const cached = cache.get(lock.path);
2156
2373
  if (cached) return cached;
2157
- const raw = await readFile10(lock.path, "utf8");
2374
+ const raw = await readFile11(lock.path, "utf8");
2158
2375
  const parsedLock = lock.kind === "npm" ? json(raw) : YAML2.parse(raw);
2159
2376
  const packages = obj(parsedLock) && obj(parsedLock.packages) ? Object.keys(parsedLock.packages).filter((key) => key !== "").length : 0;
2160
2377
  const executable = lock.kind === "npm" ? "npm" : "pnpm";
@@ -2263,7 +2480,7 @@ async function typecheck(app, workspaceRoot) {
2263
2480
  const file = await binary("tsc", app.directory, workspaceRoot);
2264
2481
  const toolVersion = await version(file, app.directory);
2265
2482
  if (!applicable) return { tool: "TypeScript", version: toolVersion, applicable: false, complete: true, passed: true, errors: [] };
2266
- const config = await readFile10(join11(app.directory, "tsconfig.json"), "utf8").catch(() => "");
2483
+ const config = await readFile11(join12(app.directory, "tsconfig.json"), "utf8").catch(() => "");
2267
2484
  if (!config) return {
2268
2485
  tool: "TypeScript",
2269
2486
  version: toolVersion,
@@ -2285,8 +2502,8 @@ async function typecheck(app, workspaceRoot) {
2285
2502
  async function testAndCoverage(app, workspaceRoot, temp) {
2286
2503
  const file = await binary("vitest", app.directory, workspaceRoot);
2287
2504
  const toolVersion = await version(file, app.directory);
2288
- const resultPath = join11(temp, "vitest.json");
2289
- const coverageDirectory = join11(temp, "coverage");
2505
+ const resultPath = join12(temp, "vitest.json");
2506
+ const coverageDirectory = join12(temp, "coverage");
2290
2507
  const result = await run(file, [
2291
2508
  "run",
2292
2509
  "--reporter=json",
@@ -2297,9 +2514,9 @@ async function testAndCoverage(app, workspaceRoot, temp) {
2297
2514
  "--coverage.reportsDirectory",
2298
2515
  coverageDirectory
2299
2516
  ], app.directory);
2300
- const output = await readFile10(resultPath, "utf8").catch(() => "");
2517
+ const output = await readFile11(resultPath, "utf8").catch(() => "");
2301
2518
  const parsed = json(output);
2302
- const summary = json(await readFile10(join11(coverageDirectory, "coverage-summary.json"), "utf8").catch(() => ""));
2519
+ const summary = json(await readFile11(join12(coverageDirectory, "coverage-summary.json"), "utf8").catch(() => ""));
2303
2520
  const tests = obj(parsed) && typeof parsed.numPassedTests === "number" && typeof parsed.numFailedTests === "number";
2304
2521
  const lineCoverage = obj(summary) && obj(summary.total) && obj(summary.total.lines) ? summary.total.lines.pct : void 0;
2305
2522
  const expectedFiles = await productionSources(app.directory);
@@ -2330,7 +2547,7 @@ async function testAndCoverage(app, workspaceRoot, temp) {
2330
2547
  async function sast(app, temp) {
2331
2548
  const file = "semgrep";
2332
2549
  const toolVersion = await version(file, app.directory);
2333
- const outputPath = join11(temp, "semgrep.json");
2550
+ const outputPath = join12(temp, "semgrep.json");
2334
2551
  const result = await run(file, [
2335
2552
  "scan",
2336
2553
  "--config",
@@ -2349,7 +2566,7 @@ async function sast(app, temp) {
2349
2566
  outputPath,
2350
2567
  app.directory
2351
2568
  ], app.directory);
2352
- const parsed = json(await readFile10(outputPath, "utf8").catch(() => result.stdout));
2569
+ const parsed = json(await readFile11(outputPath, "utf8").catch(() => result.stdout));
2353
2570
  const expectedSources = await sourcePaths(app.directory);
2354
2571
  if (!obj(parsed) || !Array.isArray(parsed.results) || result.error) {
2355
2572
  return {
@@ -2385,8 +2602,8 @@ async function sast(app, temp) {
2385
2602
  async function secrets(app, temp) {
2386
2603
  const file = "gitleaks";
2387
2604
  const toolVersion = await version(file, app.directory);
2388
- const outputPath = join11(temp, "gitleaks.json");
2389
- const configPath = join11(temp, "gitleaks.toml");
2605
+ const outputPath = join12(temp, "gitleaks.json");
2606
+ const configPath = join12(temp, "gitleaks.toml");
2390
2607
  await writeFile3(configPath, '[extend]\nuseDefault = true\n\n[allowlist]\npaths = ["(^|/)(node_modules|\\\\.next|dist|build|coverage|test-results|playwright-report)/"]\n');
2391
2608
  const result = await run(file, [
2392
2609
  "dir",
@@ -2400,7 +2617,7 @@ async function secrets(app, temp) {
2400
2617
  "--redact",
2401
2618
  "--no-banner"
2402
2619
  ], app.directory);
2403
- const parsed = json(await readFile10(outputPath, "utf8").catch(() => ""));
2620
+ const parsed = json(await readFile11(outputPath, "utf8").catch(() => ""));
2404
2621
  const count = await sourceCount(app.directory);
2405
2622
  if (!Array.isArray(parsed) || result.error) {
2406
2623
  return {
@@ -2462,12 +2679,59 @@ function playwrightProjects(value) {
2462
2679
  for (const suite of value.suites) visit(suite);
2463
2680
  return [...totals.values()].map((project) => ({ ...project, viewportWidths: [...project.viewportWidths].sort((a, b) => a - b) }));
2464
2681
  }
2682
+ function parsePlaywrightAccessibility(value) {
2683
+ const scans = [];
2684
+ const errors2 = [];
2685
+ const visit = (suite) => {
2686
+ if (!obj(suite)) return;
2687
+ for (const spec of Array.isArray(suite.specs) ? suite.specs : []) {
2688
+ if (!obj(spec) || !Array.isArray(spec.tests)) continue;
2689
+ for (const test of spec.tests) {
2690
+ if (!obj(test) || !str(test.projectName)) continue;
2691
+ for (const annotation of Array.isArray(test.annotations) ? test.annotations : []) {
2692
+ if (!obj(annotation) || annotation.type !== "lattice-axe") continue;
2693
+ const measured = json(str(annotation.description));
2694
+ const validFindings = obj(measured) && Array.isArray(measured.violations) && measured.violations.every((item) => obj(item) && str(item.id) && str(item.help) && str(item.selector) && ["minor", "moderate", "serious", "critical"].includes(str(item.impact)));
2695
+ if (!obj(measured) || !/^\/[^?#]*$/.test(str(measured.route)) || !/^\d+\.\d+\.\d+$/.test(str(measured.axeVersion)) || !Array.isArray(measured.tags) || measured.tags.length === 0 || !measured.tags.every((item) => typeof item === "string" && item.length > 0) || new Set(measured.tags).size !== measured.tags.length || !Number.isSafeInteger(measured.checks) || measured.checks <= 0 || !Number.isSafeInteger(measured.needsReview) || measured.needsReview < 0 || !validFindings) {
2696
+ errors2.push(`Malformed axe accessibility annotation for ${test.projectName}`);
2697
+ continue;
2698
+ }
2699
+ scans.push({
2700
+ project: str(test.projectName),
2701
+ route: str(measured.route),
2702
+ axeVersion: str(measured.axeVersion),
2703
+ tags: measured.tags,
2704
+ checks: measured.checks,
2705
+ needsReview: measured.needsReview,
2706
+ violations: measured.violations.map((item) => ({
2707
+ id: str(item.id),
2708
+ severity: str(item.impact),
2709
+ subject: `${measured.route} ${item.selector}`,
2710
+ message: str(item.help)
2711
+ }))
2712
+ });
2713
+ }
2714
+ }
2715
+ }
2716
+ for (const child of Array.isArray(suite.suites) ? suite.suites : []) visit(child);
2717
+ };
2718
+ if (obj(value) && Array.isArray(value.suites)) for (const suite of value.suites) visit(suite);
2719
+ if (scans.length === 0 && errors2.length === 0) errors2.push("No axe accessibility annotations were recorded");
2720
+ const versions = new Set(scans.map((scan) => scan.axeVersion));
2721
+ return {
2722
+ tool: "axe-core via Playwright",
2723
+ version: versions.size === 1 ? scans[0].axeVersion : "mixed or unavailable",
2724
+ complete: errors2.length === 0 && scans.length > 0,
2725
+ scans,
2726
+ errors: errors2
2727
+ };
2728
+ }
2465
2729
  async function compatibility(app, workspaceRoot, temp) {
2466
2730
  const file = await binary("playwright", app.directory, workspaceRoot);
2467
2731
  const toolVersion = await version(file, app.directory);
2468
- const outputPath = join11(temp, "playwright.json");
2469
- const configPath = join11(temp, "playwright-projects.json");
2470
- const reporterPath = join11(temp, "lattice-playwright-reporter.cjs");
2732
+ const outputPath = join12(temp, "playwright.json");
2733
+ const configPath = join12(temp, "playwright-projects.json");
2734
+ const reporterPath = join12(temp, "lattice-playwright-reporter.cjs");
2471
2735
  await writeFile3(reporterPath, 'module.exports=class { onBegin(config) { require("node:fs").writeFileSync(process.env.LATTICE_PLAYWRIGHT_CONFIG_OUT, JSON.stringify(config.projects.map(p => ({ name: p.name, engine: p.use.browserName || p.use.defaultBrowserType, mobile: p.use.isMobile === true, touch: p.use.hasTouch === true, userAgent: p.use.userAgent || "" })))); } };\n');
2472
2736
  const result = await run(
2473
2737
  file,
@@ -2475,9 +2739,9 @@ async function compatibility(app, workspaceRoot, temp) {
2475
2739
  app.directory,
2476
2740
  { ...process.env, PLAYWRIGHT_JSON_OUTPUT_FILE: outputPath, LATTICE_PLAYWRIGHT_CONFIG_OUT: configPath }
2477
2741
  );
2478
- const parsed = json(await readFile10(outputPath, "utf8").catch(() => result.stdout));
2742
+ const parsed = json(await readFile11(outputPath, "utf8").catch(() => result.stdout));
2479
2743
  const projects = playwrightProjects(parsed);
2480
- const config = json(await readFile10(configPath, "utf8").catch(() => ""));
2744
+ const config = json(await readFile11(configPath, "utf8").catch(() => ""));
2481
2745
  const configurations = Array.isArray(config) && config.every(obj) ? config : [];
2482
2746
  for (const project of projects) {
2483
2747
  const configured = configurations.find((item) => item.name === project.name);
@@ -2487,17 +2751,28 @@ async function compatibility(app, workspaceRoot, temp) {
2487
2751
  const agent = str(configured.userAgent);
2488
2752
  const android = agent.match(/Android\s+([\d.]+)/i);
2489
2753
  const iphone = agent.match(/iPhone OS\s+([\d_]+)/i);
2490
- if (project.name === "android-chrome" && android && project.mobile) project.device = `Android ${android[1]} UA emulation`;
2491
- if (project.name === "iphone-safari" && iphone && project.mobile) project.device = `iOS ${iphone[1].replaceAll("_", ".")} UA emulation`;
2754
+ if (project.name === "android-chrome" && android && project.mobile) {
2755
+ project.emulatedOs = "Android";
2756
+ project.emulatedOsVersion = android[1];
2757
+ project.device = `Android ${android[1]} UA emulation`;
2758
+ }
2759
+ if (project.name === "iphone-safari" && iphone && project.mobile) {
2760
+ project.emulatedOs = "iOS";
2761
+ project.emulatedOsVersion = iphone[1].replaceAll("_", ".");
2762
+ project.device = `iOS ${project.emulatedOsVersion} UA emulation`;
2763
+ }
2492
2764
  }
2493
2765
  const reportedErrors = obj(parsed) && Array.isArray(parsed.errors) ? parsed.errors : [];
2494
2766
  const infrastructureFailure = reportedErrors.length > 0 || configurations.length === 0 || typeof result.stderr === "string" && /browser executable.*doesn't exist|failed to launch browser|install playwright browsers/i.test(result.stderr);
2495
2767
  return {
2496
- tool: "Playwright",
2497
- version: toolVersion,
2498
- complete: Boolean(parsed) && !result.error && !infrastructureFailure && projects.length > 0,
2499
- projects,
2500
- errors: infrastructureFailure ? ["Playwright browser infrastructure or configuration failed"] : projects.length ? [] : [result.error ?? "Playwright JSON results are unavailable; configure required browser and mobile projects"]
2768
+ compatibility: {
2769
+ tool: "Playwright",
2770
+ version: toolVersion,
2771
+ complete: Boolean(parsed) && !result.error && !infrastructureFailure && projects.length > 0,
2772
+ projects,
2773
+ errors: infrastructureFailure ? ["Playwright browser infrastructure or configuration failed"] : projects.length ? [] : [result.error ?? "Playwright JSON results are unavailable; configure required browser and mobile projects"]
2774
+ },
2775
+ accessibility: parsePlaywrightAccessibility(parsed)
2501
2776
  };
2502
2777
  }
2503
2778
  async function collectGovernanceEvidence(options) {
@@ -2519,7 +2794,7 @@ async function collectGovernanceEvidence(options) {
2519
2794
  const auditCache = /* @__PURE__ */ new Map();
2520
2795
  const scans = [];
2521
2796
  for (const app of apps) {
2522
- const temp = await mkdtemp(join11(tmpdir(), "lattice-scanners-"));
2797
+ const temp = await mkdtemp(join12(tmpdir(), "lattice-scanners-"));
2523
2798
  try {
2524
2799
  const dependencySecurity = await dependencyAudit(app, workspaceRoot, auditCache);
2525
2800
  await assertStableInputs("after dependency audit");
@@ -2541,7 +2816,7 @@ async function collectGovernanceEvidence(options) {
2541
2816
  dependencySecurity,
2542
2817
  codeQuality: { lint: qualityLint, typecheck: qualityTypecheck, ...qualityTests },
2543
2818
  codeSecurity: { sast: securitySast, secrets: securitySecrets },
2544
- compatibility: compatibilityResult
2819
+ ...compatibilityResult
2545
2820
  });
2546
2821
  } finally {
2547
2822
  await rm(temp, { recursive: true, force: true });
@@ -2556,7 +2831,7 @@ async function collectGovernanceEvidence(options) {
2556
2831
  commit,
2557
2832
  apps: scans
2558
2833
  };
2559
- await mkdir4(dirname4(destination), { recursive: true });
2834
+ await mkdir4(dirname5(destination), { recursive: true });
2560
2835
  await writeFile3(destination, `${JSON.stringify(report, null, 2)}
2561
2836
  `, { flag: "w" });
2562
2837
  return report;
@@ -2586,11 +2861,11 @@ async function runCli(arguments_, output = defaultOutput) {
2586
2861
  program.exitOverride();
2587
2862
  program.command("analyze [path]").description("Analyze local architecture and write offline reports").option("--output <directory>", "Output directory").action(async (path, flags) => {
2588
2863
  const root = resolve11(path ?? ".");
2589
- const destination = resolve11(flags.output ?? join12(root, ".nklattice-report"));
2864
+ const destination = resolve11(flags.output ?? join13(root, ".nklattice-report"));
2590
2865
  const report = await analyzeProject(root, { outputDirectory: destination });
2591
2866
  output.out(`NKLattice analyzed ${report.graph.modules.length} modules. Score ${report.score.score} (${report.score.grade}); ${report.findings.length} findings.
2592
2867
  `);
2593
- output.out(`Reports: ${join12(destination, "report.html")} and ${join12(destination, "analysis.json")}
2868
+ output.out(`Reports: ${join13(destination, "report.html")} and ${join13(destination, "analysis.json")}
2594
2869
  `);
2595
2870
  });
2596
2871
  program.command("doctor [path]").description("Check whether a directory contains supported source files").action(async (path) => {
@@ -2600,7 +2875,7 @@ async function runCli(arguments_, output = defaultOutput) {
2600
2875
  `);
2601
2876
  });
2602
2877
  program.command("init [path]").description("Create a project configuration file").action(async (path) => {
2603
- const destination = join12(resolve11(path ?? "."), ".nklattice.json");
2878
+ const destination = join13(resolve11(path ?? "."), ".nklattice.json");
2604
2879
  await writeFile4(destination, '{\n "analysis": {},\n "policies": []\n}\n', { flag: "wx" });
2605
2880
  output.out(`Created ${destination}
2606
2881
  `);
@@ -2613,7 +2888,7 @@ async function runCli(arguments_, output = defaultOutput) {
2613
2888
  expectedApps = Number(flags.expectApps);
2614
2889
  if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
2615
2890
  }
2616
- const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
2891
+ const policyPath = flags.policy ?? resolve11(dirname6(fileURLToPath(import.meta.url)), "../governance/policy.json");
2617
2892
  const policy = await loadPolicy(policyPath);
2618
2893
  const result = await checkGovernance({
2619
2894
  scanRoot: path ?? ".",
@@ -2647,7 +2922,7 @@ async function runCli(arguments_, output = defaultOutput) {
2647
2922
  if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
2648
2923
  }
2649
2924
  const scanRoot = resolve11(path ?? ".");
2650
- const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
2925
+ const policyPath = flags.policy ?? resolve11(dirname6(fileURLToPath(import.meta.url)), "../governance/policy.json");
2651
2926
  const report = await assessGovernance({
2652
2927
  scanRoot,
2653
2928
  workspaceRoot: flags.workspaceRoot,
@@ -2670,14 +2945,14 @@ async function runCli(arguments_, output = defaultOutput) {
2670
2945
  for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
2671
2946
  `);
2672
2947
  }
2673
- output.out(`Reports: ${resolve11(flags.output ?? join12(scanRoot, ".nklattice-governance"))}
2948
+ output.out(`Reports: ${resolve11(flags.output ?? join13(scanRoot, ".nklattice-governance"))}
2674
2949
  `);
2675
2950
  }
2676
2951
  exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;
2677
2952
  });
2678
2953
  governance.command("collect [path]").description("Run project scanners and write fresh machine-readable evidence").requiredOption("--evidence <file>", "Evidence JSON outside the scan root").option("--policy <source>", "Local file or HTTPS policy URL").option("--workspace-root <path>", "Repository root containing a shared lockfile").action(async (path, flags) => {
2679
2954
  const scanRoot = resolve11(path ?? ".");
2680
- const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
2955
+ const policyPath = flags.policy ?? resolve11(dirname6(fileURLToPath(import.meta.url)), "../governance/policy.json");
2681
2956
  const result = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy: await loadPolicy(policyPath) });
2682
2957
  output.out(`Collected scanner evidence for ${result.apps.length} app(s) at ${resolve11(flags.evidence)}.
2683
2958
  `);
@@ -2690,7 +2965,7 @@ async function runCli(arguments_, output = defaultOutput) {
2690
2965
  if (!Number.isSafeInteger(expectedApps)) throw new Error("--expect-apps must be a positive integer");
2691
2966
  }
2692
2967
  const scanRoot = resolve11(path ?? ".");
2693
- const policyPath = flags.policy ?? resolve11(dirname5(fileURLToPath(import.meta.url)), "../governance/policy.json");
2968
+ const policyPath = flags.policy ?? resolve11(dirname6(fileURLToPath(import.meta.url)), "../governance/policy.json");
2694
2969
  const policy = await loadPolicy(policyPath);
2695
2970
  const collected = await collectGovernanceEvidence({ scanRoot, workspaceRoot: flags.workspaceRoot, evidence: flags.evidence, policy });
2696
2971
  const report = await assessGovernance({
@@ -2716,7 +2991,7 @@ async function runCli(arguments_, output = defaultOutput) {
2716
2991
  for (const [name, domain] of Object.entries(app.domains)) for (const issue of domain.issues) output.out(` ${name}: ${issue}
2717
2992
  `);
2718
2993
  }
2719
- output.out(`Reports: ${resolve11(flags.output ?? join12(scanRoot, ".nklattice-governance"))}
2994
+ output.out(`Reports: ${resolve11(flags.output ?? join13(scanRoot, ".nklattice-governance"))}
2720
2995
  `);
2721
2996
  }
2722
2997
  exitCode = report.status === "pass" ? 0 : report.status === "fail" ? 7 : 3;