@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.
- package/OPERATING.md +3 -1
- package/README.md +4 -0
- package/bin/accessibility-standards.mjs +1 -1
- package/dist/manifest.json +159 -101
- package/dist/profiles/ai-orchestration/coverage-manifest.json +724 -68
- package/dist/profiles/ai-orchestration/implementation/accessibility.md +825 -10
- package/dist/profiles/ai-orchestration/pattern-contracts.json +1015 -343
- package/dist/profiles/ai-orchestration/patterns/accordion.md +14 -0
- package/dist/profiles/ai-orchestration/patterns/alert.md +14 -0
- package/dist/profiles/ai-orchestration/patterns/carousel.md +14 -0
- package/dist/profiles/ai-orchestration/patterns/checkbox.md +27 -0
- package/dist/profiles/ai-orchestration/patterns/combobox.md +14 -0
- package/dist/profiles/ai-orchestration/patterns/consequential-submission.md +15 -1
- package/dist/profiles/ai-orchestration/patterns/dialog.md +14 -0
- package/dist/profiles/ai-orchestration/patterns/disclosure.md +14 -0
- package/dist/profiles/ai-orchestration/patterns/dynamic-status.md +14 -0
- package/dist/profiles/ai-orchestration/patterns/field.md +15 -0
- package/dist/profiles/ai-orchestration/patterns/image.md +14 -0
- package/dist/profiles/ai-orchestration/patterns/listbox.md +14 -0
- package/dist/profiles/ai-orchestration/patterns/media-player.md +17 -0
- package/dist/profiles/ai-orchestration/patterns/menu-button.md +14 -0
- package/dist/profiles/ai-orchestration/patterns/menu.md +30 -0
- package/dist/profiles/ai-orchestration/patterns/pagination.md +14 -0
- package/dist/profiles/ai-orchestration/patterns/popover.md +15 -0
- package/dist/profiles/ai-orchestration/patterns/progress.md +15 -0
- package/dist/profiles/ai-orchestration/patterns/radio-group.md +27 -0
- package/dist/profiles/ai-orchestration/patterns/slider.md +31 -0
- package/dist/profiles/ai-orchestration/patterns/spinbutton.md +28 -0
- package/dist/profiles/ai-orchestration/patterns/tabs.md +14 -0
- package/dist/profiles/ai-orchestration/patterns/tooltip.md +14 -0
- package/dist/profiles/ai-orchestration/patterns/tree-view.md +31 -0
- package/dist/profiles/ai-orchestration/patterns/validation.md +15 -0
- package/dist/profiles/ai-orchestration/review/accessibility.md +21 -6
- package/dist/profiles/ai-orchestration/semantics/accessible-authentication.md +13 -0
- package/dist/profiles/ai-orchestration/semantics/accessible-name.md +8 -1
- package/dist/profiles/ai-orchestration/semantics/alert.md +7 -0
- package/dist/profiles/ai-orchestration/semantics/consequential-action.md +6 -1
- package/dist/profiles/ai-orchestration/semantics/consistent-help.md +13 -0
- package/dist/profiles/ai-orchestration/semantics/data-table.md +7 -1
- package/dist/profiles/ai-orchestration/semantics/dragging-alternative.md +13 -0
- package/dist/profiles/ai-orchestration/semantics/expanded-state.md +6 -0
- package/dist/profiles/ai-orchestration/semantics/focus.initial.md +4 -0
- package/dist/profiles/ai-orchestration/semantics/focus.return.md +4 -0
- package/dist/profiles/ai-orchestration/semantics/focus.visible.md +8 -1
- package/dist/profiles/ai-orchestration/semantics/form-error.md +8 -1
- package/dist/profiles/ai-orchestration/semantics/form-label.md +7 -0
- package/dist/profiles/ai-orchestration/semantics/form-required.md +7 -0
- package/dist/profiles/ai-orchestration/semantics/headings.md +7 -0
- package/dist/profiles/ai-orchestration/semantics/keyboard.escape.md +4 -0
- package/dist/profiles/ai-orchestration/semantics/keyboard.md +7 -0
- package/dist/profiles/ai-orchestration/semantics/landmarks.md +6 -0
- package/dist/profiles/ai-orchestration/semantics/live-status.md +7 -1
- package/dist/profiles/ai-orchestration/semantics/media-alternatives.md +11 -0
- package/dist/profiles/ai-orchestration/semantics/motion-control.md +8 -1
- package/dist/profiles/ai-orchestration/semantics/name-role-value.md +6 -1
- package/dist/profiles/ai-orchestration/semantics/native-dialog.md +7 -0
- package/dist/profiles/ai-orchestration/semantics/native-elements.md +5 -0
- package/dist/profiles/ai-orchestration/semantics/non-text-alternative.md +5 -0
- package/dist/profiles/ai-orchestration/semantics/redundant-entry.md +13 -0
- package/dist/profiles/ai-orchestration/semantics/roles-states-properties.md +6 -0
- package/dist/profiles/ai-orchestration/semantics/selected-state.md +6 -0
- package/dist/profiles/ai-orchestration/semantics/target-size.md +6 -1
- package/dist/profiles/ai-orchestration/semantics.json +843 -100
- package/dist/profiles/ai-orchestration/testing/accessibility.md +33 -3
- package/dist/profiles/ai-orchestration/ui-design-brain-bindings.json +836 -0
- package/dist/profiles/conductor/a11y-semantics.md +270 -10
- package/dist/profiles/conductor/accessibility-quality.md +557 -1
- package/dist/profiles/conductor/applicability-matrix.json +270 -230
- package/dist/profiles/conductor/coverage-manifest.json +724 -68
- package/dist/profiles/conductor/pattern-contracts.json +1015 -343
- package/dist/profiles/conductor/patterns/accordion.json +42 -24
- package/dist/profiles/conductor/patterns/alert.json +36 -18
- package/dist/profiles/conductor/patterns/carousel.json +43 -25
- package/dist/profiles/conductor/patterns/checkbox.json +67 -0
- package/dist/profiles/conductor/patterns/combobox.json +44 -26
- package/dist/profiles/conductor/patterns/consequential-submission.json +41 -22
- package/dist/profiles/conductor/patterns/dialog.json +44 -26
- package/dist/profiles/conductor/patterns/disclosure.json +40 -22
- package/dist/profiles/conductor/patterns/dynamic-status.json +38 -19
- package/dist/profiles/conductor/patterns/field.json +58 -22
- package/dist/profiles/conductor/patterns/image.json +48 -19
- package/dist/profiles/conductor/patterns/listbox.json +42 -24
- package/dist/profiles/conductor/patterns/media-player.json +76 -26
- package/dist/profiles/conductor/patterns/menu-button.json +41 -23
- package/dist/profiles/conductor/patterns/menu.json +78 -0
- package/dist/profiles/conductor/patterns/pagination.json +41 -22
- package/dist/profiles/conductor/patterns/popover.json +49 -23
- package/dist/profiles/conductor/patterns/progress.json +45 -20
- package/dist/profiles/conductor/patterns/radio-group.json +69 -0
- package/dist/profiles/conductor/patterns/slider.json +84 -0
- package/dist/profiles/conductor/patterns/spinbutton.json +74 -0
- package/dist/profiles/conductor/patterns/tabs.json +41 -23
- package/dist/profiles/conductor/patterns/tooltip.json +38 -20
- package/dist/profiles/conductor/patterns/tree-view.json +77 -0
- package/dist/profiles/conductor/patterns/validation.json +59 -23
- package/dist/profiles/conductor/semantics.json +843 -100
- package/dist/profiles/conductor/ui-design-brain-bindings.json +836 -0
- package/dist/projection-manifest.json +123 -82
- package/dist/requirements-to-implementation.md +8 -0
- package/lib/build-manifest.mjs +12 -6
- package/lib/build.mjs +21 -7
- package/lib/compile-applicability.mjs +131 -10
- package/lib/coverage.mjs +20 -0
- package/lib/digest.mjs +8 -0
- package/lib/index.mjs +1 -1
- package/lib/load.mjs +44 -1
- package/lib/provenance.mjs +51 -0
- package/lib/render.mjs +115 -31
- package/lib/sync.mjs +242 -28
- package/lib/validate.mjs +557 -24
- package/package.json +27 -10
- package/profiles/ai-orchestration/profile.json +1 -1
- package/profiles/ai-orchestration/templates/patterns/README.md +1 -1
- package/profiles/ai-orchestration/templates/review/README.md +1 -1
- package/profiles/ai-orchestration/templates/semantics/README.md +1 -1
- package/profiles/ai-orchestration/templates/testing/README.md +1 -1
- package/profiles/conductor/profile.json +1 -1
- package/profiles/conductor/templates/evidence/README.md +1 -1
- package/profiles/conductor/templates/patterns/README.md +1 -1
- package/profiles/conductor/templates/semantics/README.md +1 -1
- package/schemas/consumer-config.schema.json +1 -1
- package/schemas/contract.schema.json +1 -0
- package/schemas/manifest.schema.json +1 -1
- package/schemas/pattern.schema.json +98 -1
- package/schemas/profile.schema.json +1 -1
- package/schemas/projection-manifest.schema.json +84 -1
- package/schemas/semantic.schema.json +26 -1
- package/schemas/standards-reference.schema.json +105 -0
- package/schemas/ui-design-brain-bindings.schema.json +86 -0
- package/schemas/wcag-coverage.schema.json +93 -0
- package/src/applicability/facts.yml +27 -1
- package/src/applicability/matrix.yml +8 -9
- package/src/applicability/ui-design-brain-bindings.yml +196 -0
- package/src/contract.yml +1 -0
- package/src/coverage/wcag-2.2.yml +68 -0
- package/src/patterns/disclosure/accordion.yml +1 -1
- package/src/patterns/disclosure/disclosure.yml +1 -1
- package/src/patterns/feedback/alert.yml +1 -1
- package/src/patterns/feedback/dynamic-status.yml +1 -1
- package/src/patterns/feedback/progress.yml +1 -1
- package/src/patterns/forms/consequential-submission.yml +1 -1
- package/src/patterns/forms/field.yml +1 -1
- package/src/patterns/forms/spinbutton.yml +1 -0
- package/src/patterns/forms/validation.yml +1 -1
- package/src/patterns/media/carousel.yml +1 -1
- package/src/patterns/media/image.yml +1 -1
- package/src/patterns/media/media-player.yml +1 -1
- package/src/patterns/navigation/menu-button.yml +1 -1
- package/src/patterns/navigation/menu.yml +1 -0
- package/src/patterns/navigation/pagination.yml +1 -1
- package/src/patterns/navigation/tabs.yml +1 -1
- package/src/patterns/navigation/tree-view.yml +1 -0
- package/src/patterns/overlays/dialog.yml +1 -1
- package/src/patterns/overlays/popover.yml +1 -1
- package/src/patterns/overlays/tooltip.yml +1 -1
- package/src/patterns/selection/checkbox.yml +1 -0
- package/src/patterns/selection/combobox.yml +1 -1
- package/src/patterns/selection/listbox.yml +1 -1
- package/src/patterns/selection/radio-group.yml +1 -0
- package/src/patterns/selection/slider.yml +1 -0
- package/src/semantics/accessible-names.yml +1 -1
- package/src/semantics/forms.yml +1 -1
- package/src/semantics/headings-landmarks.yml +1 -1
- package/src/semantics/keyboard-focus.yml +1 -1
- package/src/semantics/live-regions.yml +1 -1
- package/src/semantics/media.yml +1 -1
- package/src/semantics/native-elements.yml +1 -1
- package/src/semantics/roles-states-properties.yml +1 -1
- package/src/semantics/tables.yml +1 -1
- package/src/semantics/wcag-2.2.yml +1 -0
- 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 {
|
|
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
|
|
11
|
-
return `<!-- ${marker} -->\n# ${title}\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('&', '&')
|
|
34
|
+
.replaceAll('\\', '\')
|
|
35
|
+
.replaceAll('<', '<')
|
|
36
|
+
.replaceAll('>', '>')
|
|
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
|
|
27
|
-
const
|
|
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:
|
|
30
|
-
artifacts.set('patternContracts', json({ schema_version:
|
|
31
|
-
artifacts.set('
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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({
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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(
|
|
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
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|