@verndale/accessibility-standards 1.0.0 → 3.0.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.
Files changed (171) hide show
  1. package/OPERATING.md +3 -1
  2. package/README.md +4 -0
  3. package/bin/accessibility-standards.mjs +1 -1
  4. package/dist/manifest.json +159 -101
  5. package/dist/profiles/ai-orchestration/coverage-manifest.json +724 -68
  6. package/dist/profiles/ai-orchestration/implementation/accessibility.md +825 -10
  7. package/dist/profiles/ai-orchestration/pattern-contracts.json +1015 -343
  8. package/dist/profiles/ai-orchestration/patterns/accordion.md +14 -0
  9. package/dist/profiles/ai-orchestration/patterns/alert.md +14 -0
  10. package/dist/profiles/ai-orchestration/patterns/carousel.md +14 -0
  11. package/dist/profiles/ai-orchestration/patterns/checkbox.md +27 -0
  12. package/dist/profiles/ai-orchestration/patterns/combobox.md +14 -0
  13. package/dist/profiles/ai-orchestration/patterns/consequential-submission.md +15 -1
  14. package/dist/profiles/ai-orchestration/patterns/dialog.md +14 -0
  15. package/dist/profiles/ai-orchestration/patterns/disclosure.md +14 -0
  16. package/dist/profiles/ai-orchestration/patterns/dynamic-status.md +14 -0
  17. package/dist/profiles/ai-orchestration/patterns/field.md +15 -0
  18. package/dist/profiles/ai-orchestration/patterns/image.md +14 -0
  19. package/dist/profiles/ai-orchestration/patterns/listbox.md +14 -0
  20. package/dist/profiles/ai-orchestration/patterns/media-player.md +17 -0
  21. package/dist/profiles/ai-orchestration/patterns/menu-button.md +14 -0
  22. package/dist/profiles/ai-orchestration/patterns/menu.md +30 -0
  23. package/dist/profiles/ai-orchestration/patterns/pagination.md +14 -0
  24. package/dist/profiles/ai-orchestration/patterns/popover.md +15 -0
  25. package/dist/profiles/ai-orchestration/patterns/progress.md +15 -0
  26. package/dist/profiles/ai-orchestration/patterns/radio-group.md +27 -0
  27. package/dist/profiles/ai-orchestration/patterns/slider.md +31 -0
  28. package/dist/profiles/ai-orchestration/patterns/spinbutton.md +28 -0
  29. package/dist/profiles/ai-orchestration/patterns/tabs.md +14 -0
  30. package/dist/profiles/ai-orchestration/patterns/tooltip.md +14 -0
  31. package/dist/profiles/ai-orchestration/patterns/tree-view.md +31 -0
  32. package/dist/profiles/ai-orchestration/patterns/validation.md +15 -0
  33. package/dist/profiles/ai-orchestration/review/accessibility.md +21 -6
  34. package/dist/profiles/ai-orchestration/semantics/accessible-authentication.md +13 -0
  35. package/dist/profiles/ai-orchestration/semantics/accessible-name.md +8 -1
  36. package/dist/profiles/ai-orchestration/semantics/alert.md +7 -0
  37. package/dist/profiles/ai-orchestration/semantics/consequential-action.md +6 -1
  38. package/dist/profiles/ai-orchestration/semantics/consistent-help.md +13 -0
  39. package/dist/profiles/ai-orchestration/semantics/data-table.md +7 -1
  40. package/dist/profiles/ai-orchestration/semantics/dragging-alternative.md +13 -0
  41. package/dist/profiles/ai-orchestration/semantics/expanded-state.md +6 -0
  42. package/dist/profiles/ai-orchestration/semantics/focus.initial.md +4 -0
  43. package/dist/profiles/ai-orchestration/semantics/focus.return.md +4 -0
  44. package/dist/profiles/ai-orchestration/semantics/focus.visible.md +8 -1
  45. package/dist/profiles/ai-orchestration/semantics/form-error.md +8 -1
  46. package/dist/profiles/ai-orchestration/semantics/form-label.md +7 -0
  47. package/dist/profiles/ai-orchestration/semantics/form-required.md +7 -0
  48. package/dist/profiles/ai-orchestration/semantics/headings.md +7 -0
  49. package/dist/profiles/ai-orchestration/semantics/keyboard.escape.md +4 -0
  50. package/dist/profiles/ai-orchestration/semantics/keyboard.md +7 -0
  51. package/dist/profiles/ai-orchestration/semantics/landmarks.md +6 -0
  52. package/dist/profiles/ai-orchestration/semantics/live-status.md +7 -1
  53. package/dist/profiles/ai-orchestration/semantics/media-alternatives.md +11 -0
  54. package/dist/profiles/ai-orchestration/semantics/motion-control.md +8 -1
  55. package/dist/profiles/ai-orchestration/semantics/name-role-value.md +6 -1
  56. package/dist/profiles/ai-orchestration/semantics/native-dialog.md +7 -0
  57. package/dist/profiles/ai-orchestration/semantics/native-elements.md +5 -0
  58. package/dist/profiles/ai-orchestration/semantics/non-text-alternative.md +5 -0
  59. package/dist/profiles/ai-orchestration/semantics/redundant-entry.md +13 -0
  60. package/dist/profiles/ai-orchestration/semantics/roles-states-properties.md +6 -0
  61. package/dist/profiles/ai-orchestration/semantics/selected-state.md +6 -0
  62. package/dist/profiles/ai-orchestration/semantics/target-size.md +6 -1
  63. package/dist/profiles/ai-orchestration/semantics.json +843 -100
  64. package/dist/profiles/ai-orchestration/testing/accessibility.md +33 -3
  65. package/dist/profiles/ai-orchestration/ui-design-brain-bindings.json +836 -0
  66. package/dist/profiles/conductor/a11y-semantics.md +270 -10
  67. package/dist/profiles/conductor/accessibility-quality.md +557 -1
  68. package/dist/profiles/conductor/applicability-matrix.json +270 -230
  69. package/dist/profiles/conductor/coverage-manifest.json +724 -68
  70. package/dist/profiles/conductor/pattern-contracts.json +1015 -343
  71. package/dist/profiles/conductor/patterns/accordion.json +42 -24
  72. package/dist/profiles/conductor/patterns/alert.json +36 -18
  73. package/dist/profiles/conductor/patterns/carousel.json +43 -25
  74. package/dist/profiles/conductor/patterns/checkbox.json +67 -0
  75. package/dist/profiles/conductor/patterns/combobox.json +44 -26
  76. package/dist/profiles/conductor/patterns/consequential-submission.json +41 -22
  77. package/dist/profiles/conductor/patterns/dialog.json +44 -26
  78. package/dist/profiles/conductor/patterns/disclosure.json +40 -22
  79. package/dist/profiles/conductor/patterns/dynamic-status.json +38 -19
  80. package/dist/profiles/conductor/patterns/field.json +58 -22
  81. package/dist/profiles/conductor/patterns/image.json +48 -19
  82. package/dist/profiles/conductor/patterns/listbox.json +42 -24
  83. package/dist/profiles/conductor/patterns/media-player.json +76 -26
  84. package/dist/profiles/conductor/patterns/menu-button.json +41 -23
  85. package/dist/profiles/conductor/patterns/menu.json +78 -0
  86. package/dist/profiles/conductor/patterns/pagination.json +41 -22
  87. package/dist/profiles/conductor/patterns/popover.json +49 -23
  88. package/dist/profiles/conductor/patterns/progress.json +45 -20
  89. package/dist/profiles/conductor/patterns/radio-group.json +69 -0
  90. package/dist/profiles/conductor/patterns/slider.json +84 -0
  91. package/dist/profiles/conductor/patterns/spinbutton.json +74 -0
  92. package/dist/profiles/conductor/patterns/tabs.json +41 -23
  93. package/dist/profiles/conductor/patterns/tooltip.json +38 -20
  94. package/dist/profiles/conductor/patterns/tree-view.json +77 -0
  95. package/dist/profiles/conductor/patterns/validation.json +59 -23
  96. package/dist/profiles/conductor/semantics.json +843 -100
  97. package/dist/profiles/conductor/ui-design-brain-bindings.json +836 -0
  98. package/dist/projection-manifest.json +123 -82
  99. package/dist/requirements-to-implementation.md +8 -0
  100. package/lib/build-manifest.mjs +12 -6
  101. package/lib/build.mjs +21 -7
  102. package/lib/compile-applicability.mjs +131 -10
  103. package/lib/coverage.mjs +20 -0
  104. package/lib/digest.mjs +8 -0
  105. package/lib/index.mjs +1 -1
  106. package/lib/load.mjs +44 -1
  107. package/lib/provenance.mjs +51 -0
  108. package/lib/render.mjs +115 -31
  109. package/lib/sync.mjs +242 -28
  110. package/lib/validate.mjs +557 -24
  111. package/package.json +27 -10
  112. package/profiles/ai-orchestration/profile.json +1 -1
  113. package/profiles/ai-orchestration/templates/patterns/README.md +1 -1
  114. package/profiles/ai-orchestration/templates/review/README.md +1 -1
  115. package/profiles/ai-orchestration/templates/semantics/README.md +1 -1
  116. package/profiles/ai-orchestration/templates/testing/README.md +1 -1
  117. package/profiles/conductor/profile.json +1 -1
  118. package/profiles/conductor/templates/evidence/README.md +1 -1
  119. package/profiles/conductor/templates/patterns/README.md +1 -1
  120. package/profiles/conductor/templates/semantics/README.md +1 -1
  121. package/schemas/consumer-config.schema.json +1 -1
  122. package/schemas/contract.schema.json +1 -0
  123. package/schemas/manifest.schema.json +1 -1
  124. package/schemas/pattern.schema.json +98 -1
  125. package/schemas/profile.schema.json +1 -1
  126. package/schemas/projection-manifest.schema.json +84 -1
  127. package/schemas/semantic.schema.json +26 -1
  128. package/schemas/standards-reference.schema.json +105 -0
  129. package/schemas/ui-design-brain-bindings.schema.json +86 -0
  130. package/schemas/wcag-coverage.schema.json +93 -0
  131. package/src/applicability/facts.yml +27 -1
  132. package/src/applicability/matrix.yml +8 -9
  133. package/src/applicability/ui-design-brain-bindings.yml +196 -0
  134. package/src/contract.yml +1 -0
  135. package/src/coverage/wcag-2.2.yml +68 -0
  136. package/src/patterns/disclosure/accordion.yml +1 -1
  137. package/src/patterns/disclosure/disclosure.yml +1 -1
  138. package/src/patterns/feedback/alert.yml +1 -1
  139. package/src/patterns/feedback/dynamic-status.yml +1 -1
  140. package/src/patterns/feedback/progress.yml +1 -1
  141. package/src/patterns/forms/consequential-submission.yml +1 -1
  142. package/src/patterns/forms/field.yml +1 -1
  143. package/src/patterns/forms/spinbutton.yml +1 -0
  144. package/src/patterns/forms/validation.yml +1 -1
  145. package/src/patterns/media/carousel.yml +1 -1
  146. package/src/patterns/media/image.yml +1 -1
  147. package/src/patterns/media/media-player.yml +1 -1
  148. package/src/patterns/navigation/menu-button.yml +1 -1
  149. package/src/patterns/navigation/menu.yml +1 -0
  150. package/src/patterns/navigation/pagination.yml +1 -1
  151. package/src/patterns/navigation/tabs.yml +1 -1
  152. package/src/patterns/navigation/tree-view.yml +1 -0
  153. package/src/patterns/overlays/dialog.yml +1 -1
  154. package/src/patterns/overlays/popover.yml +1 -1
  155. package/src/patterns/overlays/tooltip.yml +1 -1
  156. package/src/patterns/selection/checkbox.yml +1 -0
  157. package/src/patterns/selection/combobox.yml +1 -1
  158. package/src/patterns/selection/listbox.yml +1 -1
  159. package/src/patterns/selection/radio-group.yml +1 -0
  160. package/src/patterns/selection/slider.yml +1 -0
  161. package/src/semantics/accessible-names.yml +1 -1
  162. package/src/semantics/forms.yml +1 -1
  163. package/src/semantics/headings-landmarks.yml +1 -1
  164. package/src/semantics/keyboard-focus.yml +1 -1
  165. package/src/semantics/live-regions.yml +1 -1
  166. package/src/semantics/media.yml +1 -1
  167. package/src/semantics/native-elements.yml +1 -1
  168. package/src/semantics/roles-states-properties.yml +1 -1
  169. package/src/semantics/tables.yml +1 -1
  170. package/src/semantics/wcag-2.2.yml +1 -0
  171. package/src/sources.yml +1 -1
package/lib/load.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
- import { dirname, join } from 'node:path';
2
+ import { dirname, join, relative } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
5
  export const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -17,6 +17,35 @@ async function filesUnder(root) {
17
17
  return nested.flat();
18
18
  }
19
19
 
20
+ async function markdownFilesUnder(root) {
21
+ const entries = await readdir(root, { withFileTypes: true });
22
+ const nested = await Promise.all(entries.sort((a, b) => a.name.localeCompare(b.name)).map(async (entry) => {
23
+ const path = join(root, entry.name);
24
+ return entry.isDirectory() ? markdownFilesUnder(path) : path.endsWith('.md') ? [path] : [];
25
+ }));
26
+ return nested.flat();
27
+ }
28
+
29
+ async function loadSchemas(root) {
30
+ const schemasRoot = join(root, 'schemas');
31
+ const names = (await readdir(schemasRoot))
32
+ .filter((name) => name.endsWith('.schema.json'))
33
+ .sort();
34
+ return Object.fromEntries(await Promise.all(names.map(async (name) => [
35
+ name,
36
+ JSON.parse(await readFile(join(schemasRoot, name), 'utf8')),
37
+ ])));
38
+ }
39
+
40
+ async function loadProfileTemplates(root, profile) {
41
+ const templatesRoot = join(root, 'profiles', profile, 'templates');
42
+ const templates = {};
43
+ for (const file of await markdownFilesUnder(templatesRoot)) {
44
+ templates[relative(templatesRoot, file).replaceAll('\\', '/')] = await readFile(file, 'utf8');
45
+ }
46
+ return templates;
47
+ }
48
+
20
49
  export async function loadStandards(root = packageRoot) {
21
50
  const semantics = [];
22
51
  for (const file of await filesUnder(join(root, 'src', 'semantics'))) {
@@ -29,12 +58,26 @@ export async function loadStandards(root = packageRoot) {
29
58
  for (const file of await filesUnder(join(root, 'src', 'foundations'))) foundations.push(await readJsonYaml(file));
30
59
  const policies = [];
31
60
  for (const file of await filesUnder(join(root, 'src', 'policies'))) policies.push(await readJsonYaml(file));
61
+ const profiles = {
62
+ conductor: JSON.parse(await readFile(join(root, 'profiles', 'conductor', 'profile.json'), 'utf8')),
63
+ 'ai-orchestration': JSON.parse(await readFile(join(root, 'profiles', 'ai-orchestration', 'profile.json'), 'utf8')),
64
+ };
65
+ const profileTemplates = Object.fromEntries(await Promise.all(Object.keys(profiles).sort().map(async (profile) => [
66
+ profile,
67
+ await loadProfileTemplates(root, profile),
68
+ ])));
32
69
  return {
33
70
  package: JSON.parse(await readFile(join(root, 'package.json'), 'utf8')),
71
+ contract: await readJsonYaml(join(root, 'src', 'contract.yml')),
34
72
  sources: await readJsonYaml(join(root, 'src', 'sources.yml')),
73
+ wcagCoverage: await readJsonYaml(join(root, 'src', 'coverage', 'wcag-2.2.yml')),
35
74
  facts: await readJsonYaml(join(root, 'src', 'applicability', 'facts.yml')),
36
75
  matrix: await readJsonYaml(join(root, 'src', 'applicability', 'matrix.yml')),
76
+ uiDesignBrainBindings: await readJsonYaml(join(root, 'src', 'applicability', 'ui-design-brain-bindings.yml')),
37
77
  evidence: await readJsonYaml(join(root, 'src', 'evidence', 'proof-routing.yml')),
78
+ schemas: await loadSchemas(root),
79
+ profiles,
80
+ profileTemplates,
38
81
  foundations, semantics, patterns, policies
39
82
  };
40
83
  }
@@ -0,0 +1,51 @@
1
+ import { digest } from './digest.mjs';
2
+
3
+ const byId = (left, right) => left.id.localeCompare(right.id);
4
+
5
+ function profileContractPayload(data, profile) {
6
+ const contract = data.profiles?.[profile];
7
+ const templates = data.profileTemplates?.[profile];
8
+ if (!contract || !templates) throw new Error(`Unknown profile contract: ${profile}`);
9
+ return { contract, templates };
10
+ }
11
+
12
+ export function sourceContractPayload(data) {
13
+ return {
14
+ package: { name: data.package.name, version: data.package.version },
15
+ contract: data.contract,
16
+ sources: data.sources,
17
+ wcag_2_2: data.wcagCoverage,
18
+ foundations: [...data.foundations].sort(byId),
19
+ semantics: [...data.semantics].sort(byId),
20
+ patterns: [...data.patterns].sort(byId),
21
+ policies: [...data.policies].sort(byId),
22
+ facts: data.facts,
23
+ matrix: data.matrix,
24
+ evidence: data.evidence,
25
+ schemas: data.schemas,
26
+ ui_design_brain_bindings: data.uiDesignBrainBindings,
27
+ profiles: Object.fromEntries(Object.keys(data.profiles)
28
+ .sort()
29
+ .map((profile) => [profile, profileContractPayload(data, profile)])),
30
+ };
31
+ }
32
+
33
+ export function sourceContractDigest(data) {
34
+ return digest(sourceContractPayload(data));
35
+ }
36
+
37
+ export function profileContractDigest(data, profile) {
38
+ return digest(profileContractPayload(data, profile));
39
+ }
40
+
41
+ export function projectionProvenance(data, profile) {
42
+ const profileContract = data.profiles[profile];
43
+ return {
44
+ package: `@verndale/accessibility-standards@${data.contract.package_version}`,
45
+ profile,
46
+ profile_version: profileContract.version,
47
+ profile_digest: profileContractDigest(data, profile),
48
+ schema_version: data.contract.schema_version,
49
+ source_digest: sourceContractDigest(data),
50
+ };
51
+ }
package/lib/render.mjs CHANGED
@@ -1,54 +1,146 @@
1
1
  import { compileApplicability, resolutionStates, triggerStates } from './compile-applicability.mjs';
2
- import { digest } from './digest.mjs';
2
+ import { buildLaneCoverage } from './coverage.mjs';
3
+ import { canonicalize, digest } from './digest.mjs';
4
+ import { projectionProvenance } from './provenance.mjs';
3
5
 
4
6
  export const marker = 'GENERATED by @verndale/accessibility-standards; DO NOT EDIT';
5
7
 
6
- function json(payload) {
7
- return `${JSON.stringify({ _generated: marker, ...payload }, null, 2)}\n`;
8
+ function json(payload, provenance) {
9
+ return `${JSON.stringify(canonicalize({ _generated: marker, _provenance: provenance, ...payload }), null, 2)}\n`;
8
10
  }
9
11
 
10
- function markdown(title, records, body) {
11
- return `<!-- ${marker} -->\n# ${title}\n\n${records.map(body).join('\n\n')}\n`;
12
+ function markdownDocument(title, content, provenance) {
13
+ return `<!-- ${marker} -->\n<!-- package: ${provenance.package}; profile: ${provenance.profile}@${provenance.profile_version}; schema: ${provenance.schema_version}; source-digest: ${provenance.source_digest}; profile-digest: ${provenance.profile_digest} -->\n# ${title}\n\n${content}\n`;
14
+ }
15
+
16
+ function markdown(title, records, body, provenance) {
17
+ return markdownDocument(title, records.map(body).join('\n\n'), provenance);
18
+ }
19
+
20
+ function markdownText(value) {
21
+ return String(value)
22
+ .replaceAll('\\', '\\\\')
23
+ .replace(/([\[\]`*_{}])/g, '\\$1')
24
+ .replace(/\r?\n/g, ' ');
25
+ }
26
+
27
+ function markdownUrl(value) {
28
+ return `<${String(value).replace(/[<>\s]/g, (character) => encodeURIComponent(character))}>`;
29
+ }
30
+
31
+ function tableCell(value) {
32
+ return String(value)
33
+ .replaceAll('&', '&amp;')
34
+ .replaceAll('\\', '&#92;')
35
+ .replaceAll('<', '&lt;')
36
+ .replaceAll('>', '&gt;')
37
+ .replace(/\r\n?|\n/g, '<br>')
38
+ .replaceAll('|', '\\|');
39
+ }
40
+
41
+ function embeddedJson(value) {
42
+ return JSON.stringify(canonicalize(value)).replaceAll('`', '\\u0060');
43
+ }
44
+
45
+ function standardsReferences(refs) {
46
+ return refs.map((ref) => `- [${markdownText(`${ref.authority} ${ref.identifier}`)}](${markdownUrl(ref.url)})${ref.level ? ` (Level ${ref.level})` : ''} — ${ref.normative ? 'normative' : 'informative'}`).join('\n');
12
47
  }
13
48
 
14
49
  function semanticSection(item) {
15
- return `## ${item.id}\n\n${item.requirement}\n\nProof: ${item.proof.join(', ')}.`;
50
+ return `## ${item.id}\n\n${item.requirement}\n\nStandards:\n${standardsReferences(item.standards_refs)}\n\nProof: ${item.proof.join(', ')}.`;
16
51
  }
17
52
 
18
53
  function patternSection(item) {
19
- return `## ${item.id}\n\nRequires: ${item.requires.join(', ')}.\n\nBehavior:\n${item.behavior.map((line) => `- ${line}`).join('\n')}\n\nEvidence: ${item.evidence_routes.join(', ')}.`;
54
+ return `## ${item.id}\n\nStandards:\n${standardsReferences(item.standards_refs)}\n\nRequires: ${item.requires.join(', ')}.\n\nActivation: \`${embeddedJson(item.activation)}\`\n\nScope: ${item.scope}.\n\nBehavior:\n${item.behavior.map((line) => `- ${line}`).join('\n')}\n\nProduct decisions: ${item.product_decisions.join(', ')}.\n\nFunctional Spec bindings: ${item.functional_spec_bindings.join(', ')}.\n\nImplementation outcomes: ${item.implementation_outcomes.join(', ')}.\n\nEvidence: ${item.evidence_routes.join(', ')}.`;
55
+ }
56
+
57
+ function requirementSection(item) {
58
+ return `## ${item.id}\n\n${item.requirement}`;
59
+ }
60
+
61
+ function applicabilitySection(item) {
62
+ return `## ${item.id}\n\nWhen: \`${embeddedJson(item.when)}\`\n\nOutcomes: ${item.outcomes.join(', ')}.\n\nEvidence: ${item.evidence.join(', ')}.`;
63
+ }
64
+
65
+ function evidenceSection(data) {
66
+ const routes = Object.entries(data.evidence.proof_kinds).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
67
+ return `## Evidence routing\n\n${data.evidence.rule}\n\n${routes.map(([id, route]) => `- ${id}: ${route.purpose}; capability: ${route.required_capability}`).join('\n')}`;
68
+ }
69
+
70
+ function wcagCoverageSection(coverage) {
71
+ const counts = Object.fromEntries(['covered', 'partial', 'gap'].map((status) => [status, coverage.criteria.filter((criterion) => criterion.status === status).length]));
72
+ const rows = coverage.criteria.map((criterion) => {
73
+ const semantics = criterion.semantic_ids.length ? criterion.semantic_ids.join(', ') : '—';
74
+ const note = criterion.note ?? '—';
75
+ return `| ${[criterion.id, criterion.title, criterion.level, criterion.status, semantics, note].map(tableCell).join(' | ')} |`;
76
+ });
77
+ const deferred = coverage.deferred_patterns.map(({ id, reason }) => `- ${markdownText(id)}: ${markdownText(reason)}`).join('\n');
78
+ return `## WCAG 2.2 Level A/AA coverage\n\nCoverage is criterion-level traceability, not a conformance claim: ${counts.covered} covered, ${counts.partial} partial, and ${counts.gap} gaps.\n\n| Criterion | Title | Level | Status | Semantic IDs | Note |\n| --- | --- | --- | --- | --- | --- |\n${rows.join('\n')}\n\n### Deferred APG patterns\n\n${deferred}`;
20
79
  }
21
80
 
22
81
  export function buildArtifacts(data, profile) {
82
+ const provenance = projectionProvenance(data, profile);
83
+ const foundations = [...data.foundations].sort(byId);
23
84
  const semantics = [...data.semantics].sort(byId);
24
85
  const patterns = [...data.patterns].sort(byId);
86
+ const policies = [...data.policies].sort(byId);
25
87
  const compiled = compileApplicability(data.matrix, data.facts);
26
- const ids = [...semantics.map(({ id }) => id), ...patterns.map(({ id }) => id), ...data.matrix.rows.map(({ id }) => id)].sort();
27
- const laneCoverage = Object.fromEntries(Object.keys(data.evidence.proof_kinds).sort().map((lane) => [lane, idsForLane(data, lane)]));
88
+ const bindingDigest = digest(data.uiDesignBrainBindings);
89
+ const uiPatternIds = data.uiDesignBrainBindings.bindings.map(({ ui_pattern_id }) => ui_pattern_id);
90
+ const ids = [...foundations, ...semantics, ...patterns, ...policies, ...data.matrix.rows].map(({ id }) => id).sort();
91
+ const laneCoverage = buildLaneCoverage(data);
28
92
  const artifacts = new Map();
29
- artifacts.set('semanticsJson', json({ schema_version: 1, semantics }));
30
- artifacts.set('patternContracts', json({ schema_version: 1, patterns }));
31
- artifacts.set('coverageManifest', json({ schema_version: 1, projected_ids: ids, lane_coverage: laneCoverage }));
93
+ artifacts.set('semanticsJson', json({ schema_version: data.contract.schema_version, foundations, semantics, policies }, provenance));
94
+ artifacts.set('patternContracts', json({ schema_version: data.contract.schema_version, patterns }, provenance));
95
+ artifacts.set('uiDesignBrainBindings', json({ schema_version: data.contract.schema_version, binding_schema_version: data.uiDesignBrainBindings.version, source: data.uiDesignBrainBindings.source, binding_digest: bindingDigest, bindings: data.uiDesignBrainBindings.bindings }, provenance));
96
+ artifacts.set('coverageManifest', json({
97
+ schema_version: data.contract.schema_version,
98
+ projected_ids: ids,
99
+ ui_pattern_ids: uiPatternIds,
100
+ ui_design_brain: { ...data.uiDesignBrainBindings.source, binding_digest: bindingDigest },
101
+ lane_coverage: laneCoverage,
102
+ wcag_2_2: data.wcagCoverage
103
+ }, provenance));
32
104
  if (profile === 'conductor') {
33
- artifacts.set('semanticsMarkdown', markdown('Accessibility semantics', semantics, semanticSection));
34
- artifacts.set('qualityMarkdown', markdown('Accessibility quality contract', patterns, patternSection));
35
- artifacts.set('applicabilityMatrix', json({ schema_version: 1, trigger_states: triggerStates, resolution_states: resolutionStates, facts: data.facts.facts, rows: compiled }));
36
- for (const pattern of patterns) artifacts.set(`pattern:${pattern.id}`, json({ schema_version: 1, pattern }));
105
+ const authoritySections = [
106
+ ...foundations.map(requirementSection),
107
+ ...semantics.map(semanticSection),
108
+ ...policies.map(requirementSection),
109
+ wcagCoverageSection(data.wcagCoverage),
110
+ ].join('\n\n');
111
+ const qualitySections = [
112
+ ...patterns.map(patternSection),
113
+ ...compiled.map(applicabilitySection),
114
+ evidenceSection(data),
115
+ ].join('\n\n');
116
+ artifacts.set('semanticsMarkdown', markdownDocument('Accessibility foundations, semantics, and policies', authoritySections, provenance));
117
+ artifacts.set('qualityMarkdown', markdownDocument('Accessibility quality contract', qualitySections, provenance));
118
+ artifacts.set('applicabilityMatrix', json({ schema_version: data.contract.schema_version, trigger_states: triggerStates, resolution_states: resolutionStates, facts: data.facts.facts, rows: compiled, evidence: data.evidence }, provenance));
119
+ for (const pattern of patterns) artifacts.set(`pattern:${pattern.id}`, json({ schema_version: data.contract.schema_version, pattern }, provenance));
37
120
  } else if (profile === 'ai-orchestration') {
38
- for (const semantic of semantics) artifacts.set(`semantic:${semantic.id}`, markdown(semantic.id, [semantic], semanticSection));
39
- for (const pattern of patterns) artifacts.set(`pattern:${pattern.id}`, markdown(pattern.id, [pattern], patternSection));
40
- artifacts.set('implementation', markdown('Accessibility implementation contract', [...semantics, ...patterns], (item) => item.kind === 'semantic' ? semanticSection(item) : patternSection(item)));
41
- artifacts.set('testing', `<!-- ${marker} -->\n# Accessibility testing contract\n\n${Object.entries(laneCoverage).map(([lane, laneIds]) => `## ${lane}\n\n${laneIds.map((id) => `- ${id}`).join('\n')}`).join('\n\n')}\n`);
42
- artifacts.set('review', `<!-- ${marker} -->\n# Accessibility review contract\n\nReview in this order: source compatibility, semantic dependencies, pattern behavior, implementation outcomes, test lanes, human evidence.\n\n${ids.map((id) => `- ${id}`).join('\n')}\n`);
121
+ for (const semantic of semantics) artifacts.set(`semantic:${semantic.id}`, markdown(semantic.id, [semantic], semanticSection, provenance));
122
+ for (const pattern of patterns) artifacts.set(`pattern:${pattern.id}`, markdown(pattern.id, [pattern], patternSection, provenance));
123
+ const implementationSections = [
124
+ ...foundations.map(requirementSection),
125
+ ...semantics.map(semanticSection),
126
+ ...patterns.map(patternSection),
127
+ ...compiled.map(applicabilitySection),
128
+ ...policies.map(requirementSection),
129
+ wcagCoverageSection(data.wcagCoverage),
130
+ evidenceSection(data),
131
+ ].join('\n\n');
132
+ artifacts.set('implementation', markdownDocument('Accessibility implementation contract', implementationSections, provenance));
133
+ artifacts.set('testing', markdownDocument('Accessibility testing contract', Object.entries(laneCoverage).map(([lane, laneIds]) => `## ${lane}\n\n${laneIds.map((id) => `- ${id}`).join('\n')}`).join('\n\n'), provenance));
134
+ artifacts.set('review', markdownDocument('Accessibility review contract', `Review in this order: source compatibility, foundations, semantic dependencies, pattern behavior, applicability outcomes, implementation outcomes, test lanes, human evidence.\n\n${ids.map((id) => `- ${id}`).join('\n')}`, provenance));
43
135
  } else throw new Error(`Unknown profile: ${profile}`);
44
136
  return artifacts;
45
137
  }
46
138
 
47
139
  export function defaultPath(key, profile) {
48
140
  const fixed = profile === 'conductor' ? {
49
- semanticsMarkdown: 'a11y-semantics.md', qualityMarkdown: 'accessibility-quality.md', semanticsJson: 'semantics.json', patternContracts: 'pattern-contracts.json', applicabilityMatrix: 'applicability-matrix.json', coverageManifest: 'coverage-manifest.json'
141
+ semanticsMarkdown: 'a11y-semantics.md', qualityMarkdown: 'accessibility-quality.md', semanticsJson: 'semantics.json', patternContracts: 'pattern-contracts.json', applicabilityMatrix: 'applicability-matrix.json', uiDesignBrainBindings: 'ui-design-brain-bindings.json', coverageManifest: 'coverage-manifest.json'
50
142
  } : {
51
- semanticsJson: 'semantics.json', patternContracts: 'pattern-contracts.json', coverageManifest: 'coverage-manifest.json', implementation: 'implementation/accessibility.md', testing: 'testing/accessibility.md', review: 'review/accessibility.md'
143
+ semanticsJson: 'semantics.json', patternContracts: 'pattern-contracts.json', uiDesignBrainBindings: 'ui-design-brain-bindings.json', coverageManifest: 'coverage-manifest.json', implementation: 'implementation/accessibility.md', testing: 'testing/accessibility.md', review: 'review/accessibility.md'
52
144
  };
53
145
  if (fixed[key]) return fixed[key];
54
146
  if (key.startsWith('pattern:')) return `patterns/${key.slice(8).replace('pattern.', '')}.${profile === 'conductor' ? 'json' : 'md'}`;
@@ -60,12 +152,4 @@ export function artifactDigestMap(artifacts) {
60
152
  return Object.fromEntries([...artifacts].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => [key, digest(value)]));
61
153
  }
62
154
 
63
- function idsForLane(data, lane) {
64
- return [...new Set([
65
- ...data.semantics.filter((item) => item.proof.includes(lane)).map(({ id }) => id),
66
- ...data.patterns.filter((item) => item.evidence_routes.includes(lane)).map(({ id }) => id),
67
- ...data.matrix.rows.filter((item) => item.evidence.includes(lane)).flatMap((item) => item.outcomes)
68
- ])].sort();
69
- }
70
-
71
155
  const byId = (a, b) => a.id.localeCompare(b.id);
package/lib/sync.mjs CHANGED
@@ -1,59 +1,118 @@
1
- import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
- import { dirname, isAbsolute, join, resolve } from 'node:path';
3
- import { tmpdir } from 'node:os';
1
+ import { access, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
3
  import { buildProjectionManifest } from './build-manifest.mjs';
5
- import { digest } from './digest.mjs';
4
+ import { canonicalize, digest } from './digest.mjs';
6
5
  import { loadStandards, packageRoot } from './load.mjs';
7
6
  import { buildArtifacts, defaultPath, marker } from './render.mjs';
8
7
  import { validateStandards } from './validate.mjs';
9
8
 
10
9
  const exists = async (path) => access(path).then(() => true, () => false);
10
+ const configKeys = new Set(['package', 'profile', 'routes', 'outputRoot']);
11
+ const routeKeys = new Set(['version', 'outputs']);
11
12
 
12
- export async function projection({ configPath, check = false, ifNeeded = false }) {
13
+ export async function projection({
14
+ configPath,
15
+ check = false,
16
+ ifNeeded = false,
17
+ promotionHooks,
18
+ }) {
13
19
  const absoluteConfig = resolve(configPath);
14
20
  const consumerRoot = dirname(absoluteConfig);
15
21
  const config = JSON.parse(await readFile(absoluteConfig, 'utf8'));
16
- const expectedPackage = '@verndale/accessibility-standards@1.0.0';
17
- if (config.package !== expectedPackage) throw new Error(`Exact package pin required: ${expectedPackage}`);
18
- const routesPath = resolve(consumerRoot, config.routes);
22
+ validateConfig(config);
23
+ const routesPath = resolveInside(consumerRoot, config.routes, 'routes');
24
+ await assertNoSymlinkPath(consumerRoot, routesPath, 'routes');
19
25
  const routes = JSON.parse(await readFile(routesPath, 'utf8'));
26
+ validateRoutes(routes);
20
27
  const data = await loadStandards(packageRoot);
21
28
  validateStandards(data);
22
- const outputRoot = resolve(consumerRoot, config.outputRoot ?? '.');
29
+ const expectedPackage = `@verndale/accessibility-standards@${data.contract.package_version}`;
30
+ if (config.package !== expectedPackage) throw new Error(`Exact installed package pin required: ${expectedPackage}`);
31
+ const outputRoot = resolveInside(consumerRoot, config.outputRoot ?? '.', 'outputRoot', {
32
+ allowRoot: true,
33
+ });
34
+ await assertNoSymlinkPath(consumerRoot, outputRoot, 'outputRoot');
23
35
  const artifacts = buildArtifacts(data, config.profile);
24
36
  const sourceKey = 'source';
25
- const sourcePath = resolveOutput(outputRoot, routes.outputs[sourceKey] ?? 'accessibility.source.json');
26
- const manifest = buildProjectionManifest({ data, profile: config.profile, config, routes, artifacts });
27
- artifacts.set(sourceKey, `${JSON.stringify(manifest, null, 2)}\n`);
28
- const paths = new Map([...artifacts].map(([key]) => [key, resolveOutput(outputRoot, routeFor(key, routes, config.profile))]));
37
+ const artifactKeys = [...artifacts.keys(), sourceKey];
38
+ validateRouteKeys(routes, artifactKeys);
39
+ const paths = new Map(
40
+ artifactKeys.map((key) => [
41
+ key,
42
+ resolveOutput(outputRoot, routeFor(key, routes, config.profile)),
43
+ ]),
44
+ );
45
+ assertUniqueTargets(paths);
46
+ for (const [key, path] of paths) {
47
+ await assertNoSymlinkPath(outputRoot, path, `output ${key}`);
48
+ }
49
+ const sourcePath = paths.get(sourceKey);
50
+ const manifest = buildProjectionManifest({
51
+ data,
52
+ profile: config.profile,
53
+ config,
54
+ routes,
55
+ artifacts,
56
+ });
57
+ manifest.output_paths = Object.fromEntries(
58
+ [...paths]
59
+ .map(([key, path]) => [key, relative(outputRoot, path).split(sep).join('/')])
60
+ .sort(([left], [right]) => left.localeCompare(right)),
61
+ );
62
+ manifest.manifest_digest = digest(manifest);
63
+ artifacts.set(sourceKey, `${JSON.stringify(canonicalize(manifest), null, 2)}\n`);
29
64
  if (check) return checkProjection(artifacts, paths);
30
65
  if (ifNeeded && await projectionMatches(artifacts, paths)) return { changed: false, files: paths.size };
31
- await protectManagedFiles(paths, sourcePath);
32
- const stage = join(tmpdir(), `a11y-sync-${process.pid}-${Date.now()}`);
33
- await mkdir(stage, { recursive: true });
66
+ const removals = await protectManagedFiles(paths, sourcePath, outputRoot);
67
+ const stage = await mkdtemp(join(consumerRoot, '.accessibility-sync-'));
34
68
  try {
35
69
  let index = 0;
36
- const staged = [];
70
+ const writes = [];
37
71
  for (const [key, content] of artifacts) {
38
- const temp = join(stage, String(index++));
72
+ const temp = join(stage, 'candidate', String(index++));
73
+ await mkdir(dirname(temp), { recursive: true });
39
74
  await writeFile(temp, content);
40
- staged.push([temp, paths.get(key)]);
41
- }
42
- for (const [temp, target] of staged) {
43
- await mkdir(dirname(target), { recursive: true });
44
- await rename(temp, target);
75
+ const staged = await readFile(temp, 'utf8');
76
+ if (staged !== content || !staged.includes(marker)) {
77
+ throw new Error(`Staged projection validation failed for ${key}`);
78
+ }
79
+ writes.push({ key, temp, target: paths.get(key) });
45
80
  }
46
- } finally { await rm(stage, { recursive: true, force: true }); }
47
- await checkProjection(artifacts, paths);
81
+ await promoteProjectionTransaction({
82
+ writes,
83
+ removals,
84
+ stage,
85
+ validate: () => checkProjection(artifacts, paths),
86
+ hooks: promotionHooks,
87
+ });
88
+ } finally {
89
+ await rm(stage, { recursive: true, force: true });
90
+ }
48
91
  return { changed: true, files: paths.size };
49
92
  }
50
93
 
51
- async function protectManagedFiles(paths, sourcePath) {
94
+ async function protectManagedFiles(paths, sourcePath, outputRoot) {
52
95
  let prior = null;
53
96
  if (await exists(sourcePath)) {
54
97
  const raw = await readFile(sourcePath, 'utf8');
55
98
  if (!raw.includes(marker)) throw new Error(`Refusing to overwrite unmarked provenance: ${sourcePath}`);
56
- prior = JSON.parse(raw);
99
+ try {
100
+ prior = JSON.parse(raw);
101
+ } catch {
102
+ throw new Error(`Refusing to overwrite invalid generated provenance: ${sourcePath}`);
103
+ }
104
+ const { manifest_digest: manifestDigest, ...unsignedManifest } = prior;
105
+ if (!/^[a-f0-9]{64}$/.test(manifestDigest ?? '') || digest(unsignedManifest) !== manifestDigest) {
106
+ throw new Error(`Refusing to overwrite locally modified generated provenance: ${sourcePath}`);
107
+ }
108
+ } else {
109
+ const existing = [];
110
+ for (const path of paths.values()) if (await exists(path)) existing.push(path);
111
+ if (existing.length > 0) {
112
+ throw new Error(
113
+ `Refusing to overwrite generated targets without their provenance: ${existing.join(', ')}`,
114
+ );
115
+ }
57
116
  }
58
117
  for (const [key, path] of paths) {
59
118
  if (!await exists(path)) continue;
@@ -62,6 +121,82 @@ async function protectManagedFiles(paths, sourcePath) {
62
121
  const expected = prior?.output_digests?.[key];
63
122
  if (expected && digest(raw) !== expected) throw new Error(`Refusing to overwrite locally modified generated target: ${path}`);
64
123
  }
124
+ const removals = [];
125
+ for (const [key, priorRelativePath] of Object.entries(prior?.output_paths ?? {})) {
126
+ const priorPath = resolveOutput(outputRoot, priorRelativePath);
127
+ if ([...paths.values()].includes(priorPath) || !await exists(priorPath)) continue;
128
+ const raw = await readFile(priorPath, 'utf8');
129
+ if (!raw.includes(marker)) {
130
+ throw new Error(`Refusing to remove unmarked prior generated target: ${priorPath}`);
131
+ }
132
+ const expected = prior?.output_digests?.[key];
133
+ if (expected && digest(raw) !== expected) {
134
+ throw new Error(`Refusing to remove locally modified prior generated target: ${priorPath}`);
135
+ }
136
+ removals.push({ key, target: priorPath });
137
+ }
138
+ return removals;
139
+ }
140
+
141
+ export async function promoteProjectionTransaction({
142
+ writes,
143
+ removals = [],
144
+ stage,
145
+ validate,
146
+ hooks = {},
147
+ }) {
148
+ const records = [];
149
+ const operations = [
150
+ ...writes.map((write) => ({ ...write, kind: 'write' })),
151
+ ...removals.map((removal) => ({ ...removal, kind: 'remove', temp: null })),
152
+ ];
153
+ const backupRoot = join(stage, 'backup');
154
+ await mkdir(backupRoot, { recursive: true });
155
+ try {
156
+ for (let index = 0; index < operations.length; index += 1) {
157
+ const operation = operations[index];
158
+ await hooks.beforePromote?.({ index, key: operation.key, target: operation.target });
159
+ await mkdir(dirname(operation.target), { recursive: true });
160
+ const record = {
161
+ ...operation,
162
+ backup: join(backupRoot, String(index)),
163
+ backedUp: false,
164
+ placed: false,
165
+ };
166
+ records.push(record);
167
+ if (await exists(operation.target)) {
168
+ await rename(operation.target, record.backup);
169
+ record.backedUp = true;
170
+ }
171
+ if (operation.kind === 'write') {
172
+ await rename(operation.temp, operation.target);
173
+ record.placed = true;
174
+ }
175
+ }
176
+ await validate();
177
+ } catch (error) {
178
+ const rollbackErrors = [];
179
+ for (const record of [...records].reverse()) {
180
+ try {
181
+ if (record.placed && await exists(record.target)) {
182
+ await rm(record.target, { force: true });
183
+ }
184
+ if (record.backedUp && await exists(record.backup)) {
185
+ await mkdir(dirname(record.target), { recursive: true });
186
+ await rename(record.backup, record.target);
187
+ }
188
+ } catch (rollbackError) {
189
+ rollbackErrors.push(`${record.target}: ${rollbackError.message}`);
190
+ }
191
+ }
192
+ if (rollbackErrors.length > 0) {
193
+ throw new Error(
194
+ `${error.message}; projection rollback failed: ${rollbackErrors.join('; ')}`,
195
+ { cause: error },
196
+ );
197
+ }
198
+ throw error;
199
+ }
65
200
  }
66
201
 
67
202
  async function projectionMatches(artifacts, paths) {
@@ -89,6 +224,85 @@ function routeFor(key, routes, profile) {
89
224
  }
90
225
 
91
226
  function resolveOutput(root, path) {
92
- const target = isAbsolute(path) ? path : resolve(root, path);
227
+ return resolveInside(root, path, 'output');
228
+ }
229
+
230
+ function validateConfig(config) {
231
+ if (!isPlainObject(config)) throw new Error('Consumer config must be an object');
232
+ rejectUnknownKeys(config, configKeys, 'consumer config');
233
+ if (typeof config.package !== 'string' || !config.package) {
234
+ throw new Error('Consumer config package is required');
235
+ }
236
+ if (!['conductor', 'ai-orchestration'].includes(config.profile)) {
237
+ throw new Error(`Unsupported accessibility profile: ${config.profile}`);
238
+ }
239
+ for (const key of ['routes', ...(config.outputRoot === undefined ? [] : ['outputRoot'])]) {
240
+ if (typeof config[key] !== 'string' || !config[key].trim()) {
241
+ throw new Error(`Consumer config ${key} must be a non-empty relative path`);
242
+ }
243
+ }
244
+ }
245
+
246
+ function validateRoutes(routes) {
247
+ if (!isPlainObject(routes)) throw new Error('Consumer routes must be an object');
248
+ rejectUnknownKeys(routes, routeKeys, 'consumer routes');
249
+ if (routes.version !== 1) throw new Error('Consumer routes version must be 1');
250
+ if (!isPlainObject(routes.outputs)) throw new Error('Consumer routes outputs must be an object');
251
+ for (const [key, value] of Object.entries(routes.outputs)) {
252
+ if (typeof value !== 'string' || !value.trim()) {
253
+ throw new Error(`Consumer route ${key} must be a non-empty relative path`);
254
+ }
255
+ }
256
+ }
257
+
258
+ function validateRouteKeys(routes, artifactKeys) {
259
+ const allowed = new Set([...artifactKeys, 'patterns', 'semantics']);
260
+ const unknown = Object.keys(routes.outputs).filter((key) => !allowed.has(key));
261
+ if (unknown.length > 0) throw new Error(`Unsupported consumer route keys: ${unknown.join(', ')}`);
262
+ }
263
+
264
+ function assertUniqueTargets(paths) {
265
+ const owners = new Map();
266
+ for (const [key, path] of paths) {
267
+ const prior = owners.get(path);
268
+ if (prior) throw new Error(`Consumer routes collide at ${path}: ${prior}, ${key}`);
269
+ owners.set(path, key);
270
+ }
271
+ }
272
+
273
+ function resolveInside(root, candidate, label, { allowRoot = false } = {}) {
274
+ if (typeof candidate !== 'string' || !candidate.trim() || isAbsolute(candidate)) {
275
+ throw new Error(`${label} must be a non-empty relative path`);
276
+ }
277
+ const target = resolve(root, candidate);
278
+ const rel = relative(root, target);
279
+ if ((!allowRoot && rel === '') || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
280
+ throw new Error(`${label} escapes its allowed root: ${candidate}`);
281
+ }
93
282
  return target;
94
283
  }
284
+
285
+ async function assertNoSymlinkPath(root, target, label) {
286
+ const rel = relative(root, target);
287
+ if (rel === '') return;
288
+ let cursor = root;
289
+ for (const part of rel.split(sep)) {
290
+ cursor = join(cursor, part);
291
+ try {
292
+ const stats = await lstat(cursor);
293
+ if (stats.isSymbolicLink()) throw new Error(`${label} traverses a symbolic link: ${cursor}`);
294
+ } catch (error) {
295
+ if (error?.code === 'ENOENT') return;
296
+ throw error;
297
+ }
298
+ }
299
+ }
300
+
301
+ function rejectUnknownKeys(value, allowed, label) {
302
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
303
+ if (unknown.length > 0) throw new Error(`${label} has unknown keys: ${unknown.join(', ')}`);
304
+ }
305
+
306
+ function isPlainObject(value) {
307
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
308
+ }