@verndale/accessibility-standards 1.0.0 → 2.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 +2 -0
- package/README.md +4 -0
- package/bin/accessibility-standards.mjs +1 -1
- package/dist/manifest.json +39 -21
- package/dist/profiles/ai-orchestration/coverage-manifest.json +109 -2
- package/dist/profiles/ai-orchestration/implementation/accessibility.md +334 -0
- package/dist/profiles/ai-orchestration/pattern-contracts.json +125 -39
- package/dist/profiles/ai-orchestration/patterns/accordion.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/alert.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/carousel.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/combobox.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/consequential-submission.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/dialog.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/disclosure.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/dynamic-status.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/field.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/image.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/listbox.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/media-player.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/menu-button.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/pagination.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/popover.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/progress.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/tabs.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/tooltip.md +9 -0
- package/dist/profiles/ai-orchestration/patterns/validation.md +9 -0
- package/dist/profiles/ai-orchestration/review/accessibility.md +8 -1
- package/dist/profiles/ai-orchestration/semantics/accessible-name.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/alert.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/consequential-action.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/data-table.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/expanded-state.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/focus.initial.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/focus.return.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/focus.visible.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/form-error.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/form-label.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/form-required.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/headings.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/keyboard.escape.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/keyboard.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/landmarks.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/live-status.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/media-alternatives.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/motion-control.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/name-role-value.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/native-dialog.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/native-elements.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/non-text-alternative.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/roles-states-properties.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/selected-state.md +1 -0
- package/dist/profiles/ai-orchestration/semantics/target-size.md +1 -0
- package/dist/profiles/ai-orchestration/semantics.json +48 -1
- package/dist/profiles/ai-orchestration/testing/accessibility.md +1 -1
- package/dist/profiles/ai-orchestration/ui-design-brain-bindings.json +856 -0
- package/dist/profiles/conductor/a11y-semantics.md +22 -1
- package/dist/profiles/conductor/accessibility-quality.md +314 -0
- package/dist/profiles/conductor/applicability-matrix.json +111 -28
- package/dist/profiles/conductor/coverage-manifest.json +109 -2
- package/dist/profiles/conductor/pattern-contracts.json +125 -39
- package/dist/profiles/conductor/patterns/accordion.json +13 -3
- package/dist/profiles/conductor/patterns/alert.json +13 -3
- package/dist/profiles/conductor/patterns/carousel.json +13 -3
- package/dist/profiles/conductor/patterns/combobox.json +13 -3
- package/dist/profiles/conductor/patterns/consequential-submission.json +13 -3
- package/dist/profiles/conductor/patterns/dialog.json +13 -3
- package/dist/profiles/conductor/patterns/disclosure.json +13 -3
- package/dist/profiles/conductor/patterns/dynamic-status.json +13 -3
- package/dist/profiles/conductor/patterns/field.json +23 -3
- package/dist/profiles/conductor/patterns/image.json +23 -3
- package/dist/profiles/conductor/patterns/listbox.json +13 -3
- package/dist/profiles/conductor/patterns/media-player.json +23 -3
- package/dist/profiles/conductor/patterns/menu-button.json +13 -3
- package/dist/profiles/conductor/patterns/pagination.json +13 -3
- package/dist/profiles/conductor/patterns/popover.json +13 -3
- package/dist/profiles/conductor/patterns/progress.json +13 -3
- package/dist/profiles/conductor/patterns/tabs.json +13 -3
- package/dist/profiles/conductor/patterns/tooltip.json +13 -3
- package/dist/profiles/conductor/patterns/validation.json +23 -3
- package/dist/profiles/conductor/semantics.json +48 -1
- package/dist/profiles/conductor/ui-design-brain-bindings.json +856 -0
- package/dist/projection-manifest.json +103 -78
- package/dist/requirements-to-implementation.md +2 -0
- package/lib/build-manifest.mjs +12 -6
- package/lib/build.mjs +19 -5
- package/lib/compile-applicability.mjs +131 -10
- package/lib/coverage.mjs +20 -0
- package/lib/index.mjs +1 -1
- package/lib/load.mjs +31 -1
- package/lib/provenance.mjs +49 -0
- package/lib/render.mjs +69 -29
- package/lib/sync.mjs +240 -26
- package/lib/validate.mjs +255 -24
- package/package.json +24 -11
- package/profiles/ai-orchestration/profile.json +1 -1
- package/profiles/conductor/profile.json +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/projection-manifest.schema.json +84 -1
- package/schemas/ui-design-brain-bindings.schema.json +86 -0
- package/src/applicability/facts.yml +29 -1
- package/src/applicability/matrix.yml +3 -2
- package/src/applicability/ui-design-brain-bindings.yml +210 -0
- package/src/contract.yml +1 -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/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/pagination.yml +1 -1
- package/src/patterns/navigation/tabs.yml +1 -1
- 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/combobox.yml +1 -1
- package/src/patterns/selection/listbox.yml +1 -1
package/lib/render.mjs
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { compileApplicability, resolutionStates, triggerStates } from './compile-applicability.mjs';
|
|
2
|
+
import { buildLaneCoverage } from './coverage.mjs';
|
|
2
3
|
import { 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({ _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);
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
function semanticSection(item) {
|
|
@@ -16,39 +22,81 @@ function semanticSection(item) {
|
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
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(', ')}.`;
|
|
25
|
+
return `## ${item.id}\n\nRequires: ${item.requires.join(', ')}.\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(', ')}.`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requirementSection(item) {
|
|
29
|
+
return `## ${item.id}\n\n${item.requirement}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function applicabilitySection(item) {
|
|
33
|
+
return `## ${item.id}\n\nWhen: \`${JSON.stringify(item.when)}\`\n\nOutcomes: ${item.outcomes.join(', ')}.\n\nEvidence: ${item.evidence.join(', ')}.`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function evidenceSection(data) {
|
|
37
|
+
return `## Evidence routing\n\n${data.evidence.rule}\n\n${Object.entries(data.evidence.proof_kinds).map(([id, route]) => `- ${id}: ${route.purpose}; capability: ${route.required_capability}`).join('\n')}`;
|
|
20
38
|
}
|
|
21
39
|
|
|
22
40
|
export function buildArtifacts(data, profile) {
|
|
41
|
+
const provenance = projectionProvenance(data, profile);
|
|
42
|
+
const foundations = [...data.foundations].sort(byId);
|
|
23
43
|
const semantics = [...data.semantics].sort(byId);
|
|
24
44
|
const patterns = [...data.patterns].sort(byId);
|
|
45
|
+
const policies = [...data.policies].sort(byId);
|
|
25
46
|
const compiled = compileApplicability(data.matrix, data.facts);
|
|
26
|
-
const
|
|
27
|
-
const
|
|
47
|
+
const bindingDigest = digest(data.uiDesignBrainBindings);
|
|
48
|
+
const uiPatternIds = data.uiDesignBrainBindings.bindings.map(({ ui_pattern_id }) => ui_pattern_id);
|
|
49
|
+
const ids = [...foundations, ...semantics, ...patterns, ...policies, ...data.matrix.rows].map(({ id }) => id).sort();
|
|
50
|
+
const laneCoverage = buildLaneCoverage(data);
|
|
28
51
|
const artifacts = new Map();
|
|
29
|
-
artifacts.set('semanticsJson', json({ schema_version:
|
|
30
|
-
artifacts.set('patternContracts', json({ schema_version:
|
|
31
|
-
artifacts.set('
|
|
52
|
+
artifacts.set('semanticsJson', json({ schema_version: data.contract.schema_version, foundations, semantics, policies }, provenance));
|
|
53
|
+
artifacts.set('patternContracts', json({ schema_version: data.contract.schema_version, patterns }, provenance));
|
|
54
|
+
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));
|
|
55
|
+
artifacts.set('coverageManifest', json({
|
|
56
|
+
schema_version: data.contract.schema_version,
|
|
57
|
+
projected_ids: ids,
|
|
58
|
+
ui_pattern_ids: uiPatternIds,
|
|
59
|
+
ui_design_brain: { ...data.uiDesignBrainBindings.source, binding_digest: bindingDigest },
|
|
60
|
+
lane_coverage: laneCoverage
|
|
61
|
+
}, provenance));
|
|
32
62
|
if (profile === 'conductor') {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
63
|
+
const authoritySections = [
|
|
64
|
+
...foundations.map(requirementSection),
|
|
65
|
+
...semantics.map(semanticSection),
|
|
66
|
+
...policies.map(requirementSection),
|
|
67
|
+
].join('\n\n');
|
|
68
|
+
const qualitySections = [
|
|
69
|
+
...patterns.map(patternSection),
|
|
70
|
+
...compiled.map(applicabilitySection),
|
|
71
|
+
evidenceSection(data),
|
|
72
|
+
].join('\n\n');
|
|
73
|
+
artifacts.set('semanticsMarkdown', markdownDocument('Accessibility foundations, semantics, and policies', authoritySections, provenance));
|
|
74
|
+
artifacts.set('qualityMarkdown', markdownDocument('Accessibility quality contract', qualitySections, provenance));
|
|
75
|
+
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));
|
|
76
|
+
for (const pattern of patterns) artifacts.set(`pattern:${pattern.id}`, json({ schema_version: data.contract.schema_version, pattern }, provenance));
|
|
37
77
|
} 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
|
-
|
|
78
|
+
for (const semantic of semantics) artifacts.set(`semantic:${semantic.id}`, markdown(semantic.id, [semantic], semanticSection, provenance));
|
|
79
|
+
for (const pattern of patterns) artifacts.set(`pattern:${pattern.id}`, markdown(pattern.id, [pattern], patternSection, provenance));
|
|
80
|
+
const implementationSections = [
|
|
81
|
+
...foundations.map(requirementSection),
|
|
82
|
+
...semantics.map(semanticSection),
|
|
83
|
+
...patterns.map(patternSection),
|
|
84
|
+
...compiled.map(applicabilitySection),
|
|
85
|
+
...policies.map(requirementSection),
|
|
86
|
+
evidenceSection(data),
|
|
87
|
+
].join('\n\n');
|
|
88
|
+
artifacts.set('implementation', markdownDocument('Accessibility implementation contract', implementationSections, provenance));
|
|
89
|
+
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));
|
|
90
|
+
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
91
|
} else throw new Error(`Unknown profile: ${profile}`);
|
|
44
92
|
return artifacts;
|
|
45
93
|
}
|
|
46
94
|
|
|
47
95
|
export function defaultPath(key, profile) {
|
|
48
96
|
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'
|
|
97
|
+
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
98
|
} : {
|
|
51
|
-
semanticsJson: 'semantics.json', patternContracts: 'pattern-contracts.json', coverageManifest: 'coverage-manifest.json', implementation: 'implementation/accessibility.md', testing: 'testing/accessibility.md', review: 'review/accessibility.md'
|
|
99
|
+
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
100
|
};
|
|
53
101
|
if (fixed[key]) return fixed[key];
|
|
54
102
|
if (key.startsWith('pattern:')) return `patterns/${key.slice(8).replace('pattern.', '')}.${profile === 'conductor' ? 'json' : 'md'}`;
|
|
@@ -60,12 +108,4 @@ export function artifactDigestMap(artifacts) {
|
|
|
60
108
|
return Object.fromEntries([...artifacts].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => [key, digest(value)]));
|
|
61
109
|
}
|
|
62
110
|
|
|
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
111
|
const byId = (a, b) => a.id.localeCompare(b.id);
|
package/lib/sync.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
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
4
|
import { digest } from './digest.mjs';
|
|
6
5
|
import { loadStandards, packageRoot } from './load.mjs';
|
|
@@ -8,52 +7,112 @@ 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
|
-
|
|
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);
|
|
27
63
|
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))]));
|
|
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
|
+
}
|