@pygmalionjs/pygmalion 0.8.1 → 0.9.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/binding-source.d.ts +25 -0
- package/dist-lib/{CameraLayer-B2D29VXQ.js → CameraLayer-BdOko8XE.js} +17 -17
- package/dist-lib/pygmalion.js +24599 -12746
- package/dist-lib/{runtime-BAy8W1mJ.js → runtime-Dxb-IaLN.js} +12414 -11114
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +10 -10
- package/dist-lib/types/binding/catalog.d.ts +10 -0
- package/dist-lib/types/binding/contracts.d.ts +202 -0
- package/dist-lib/types/binding/documentCommands.d.ts +38 -0
- package/dist-lib/types/binding/index.d.ts +6 -0
- package/dist-lib/types/binding/preview.d.ts +37 -0
- package/dist-lib/types/binding/source.d.ts +109 -0
- package/dist-lib/types/binding/useDataProjection.d.ts +9 -0
- package/dist-lib/types/binding/validate.d.ts +8 -0
- package/dist-lib/types/core/runtime.d.ts +6 -0
- package/dist-lib/types/document/appearance.d.ts +112 -0
- package/dist-lib/types/document/assets.d.ts +204 -0
- package/dist-lib/types/document/components.d.ts +169 -0
- package/dist-lib/types/document/contracts.d.ts +171 -0
- package/dist-lib/types/document/engine.d.ts +20 -0
- package/dist-lib/types/document/exportSettings.d.ts +45 -0
- package/dist-lib/types/document/geometry.d.ts +11 -0
- package/dist-lib/types/document/layout.d.ts +53 -0
- package/dist-lib/types/document/prototype.d.ts +424 -0
- package/dist-lib/types/document/prototypeReferences.d.ts +3 -0
- package/dist-lib/types/document/richText.d.ts +185 -0
- package/dist-lib/types/document/styles.d.ts +216 -0
- package/dist-lib/types/document/svgImport.d.ts +17 -0
- package/dist-lib/types/document/vector.d.ts +222 -0
- package/dist-lib/types/editor/designCompiler.d.ts +7 -0
- package/dist-lib/types/editor/designImport.d.ts +7 -0
- package/dist-lib/types/editor/inspect.d.ts +8 -0
- package/dist-lib/types/editor/revisionCatalogInstall.d.ts +1 -1
- package/dist-lib/types/editor/screenInteractions.d.ts +3 -3
- package/dist-lib/types/editor/store.d.ts +1 -1
- package/dist-lib/types/host/project.d.ts +44 -0
- package/dist-lib/types/host/registryConnections.d.ts +11 -0
- package/dist-lib/types/host/sourceFilePlan.d.ts +15 -0
- package/dist-lib/types/host/sourceWriteback.d.ts +50 -0
- package/dist-lib/types/lib.d.ts +101 -3
- package/dist-lib/types/token-library/bindings.d.ts +62 -0
- package/dist-lib/types/token-library/contracts.d.ts +117 -0
- package/dist-lib/types/token-library/engine.d.ts +13 -0
- package/dist-lib/types/workspace/WorkspaceShell.d.ts +9 -0
- package/dist-lib/types/workspace/application/ApplicationWorkspace.d.ts +3 -0
- package/dist-lib/types/workspace/configuration.d.ts +39 -0
- package/dist-lib/types/workspace/contracts.d.ts +64 -0
- package/dist-lib/types/workspace/controller.d.ts +35 -0
- package/dist-lib/types/workspace/documentSessions.d.ts +42 -0
- package/dist-lib/types/workspace/edit/AppearancePanel.d.ts +23 -0
- package/dist-lib/types/workspace/edit/ArrangeLayers.d.ts +10 -0
- package/dist-lib/types/workspace/edit/AssetsPanel.d.ts +16 -0
- package/dist-lib/types/workspace/edit/CanvasGuides.d.ts +19 -0
- package/dist-lib/types/workspace/edit/ComponentPanel.d.ts +11 -0
- package/dist-lib/types/workspace/edit/DataPanel.d.ts +16 -0
- package/dist-lib/types/workspace/edit/DesignWorkspace.d.ts +10 -0
- package/dist-lib/types/workspace/edit/LayerTree.d.ts +14 -0
- package/dist-lib/types/workspace/edit/LayoutPanel.d.ts +13 -0
- package/dist-lib/types/workspace/edit/PrototypePanel.d.ts +12 -0
- package/dist-lib/types/workspace/edit/ScopeTransferDialog.d.ts +40 -0
- package/dist-lib/types/workspace/edit/SelectionHandles.d.ts +52 -0
- package/dist-lib/types/workspace/edit/StylesPanel.d.ts +8 -0
- package/dist-lib/types/workspace/edit/TextEditor.d.ts +50 -0
- package/dist-lib/types/workspace/edit/VectorEditor.d.ts +89 -0
- package/dist-lib/types/workspace/edit/VectorToolbar.d.ts +5 -0
- package/dist-lib/types/workspace/edit/canvasGuideModel.d.ts +102 -0
- package/dist-lib/types/workspace/edit/clipboard.d.ts +35 -0
- package/dist-lib/types/workspace/edit/duplicateMetadata.d.ts +2 -0
- package/dist-lib/types/workspace/edit/geometryHandles.d.ts +224 -0
- package/dist-lib/types/workspace/edit/layerArrangement.d.ts +39 -0
- package/dist-lib/types/workspace/edit/layerMoves.d.ts +44 -0
- package/dist-lib/types/workspace/edit/projection.d.ts +12 -0
- package/dist-lib/types/workspace/edit/scopeTransfer.d.ts +96 -0
- package/dist-lib/types/workspace/edit/textStyles.d.ts +75 -0
- package/dist-lib/types/workspace/edit/vectorTools.d.ts +136 -0
- package/dist-lib/types/workspace/legacyModeAdapter.d.ts +13 -0
- package/dist-lib/types/workspace/source/ResponsiveLayoutPanel.d.ts +20 -0
- package/dist-lib/types/workspace/source/SourceReview.d.ts +11 -0
- package/dist-lib/types/workspace/source/appearanceSource.d.ts +19 -0
- package/dist-lib/types/workspace/source/compiler.d.ts +5 -0
- package/dist-lib/types/workspace/source/contracts.d.ts +156 -0
- package/dist-lib/types/workspace/source/controller.d.ts +50 -0
- package/dist-lib/types/workspace/source/documentRevision.d.ts +2 -0
- package/dist-lib/types/workspace/source/draftPreviewClient.d.ts +13 -0
- package/dist-lib/types/workspace/source/draftRecipe.d.ts +30 -0
- package/dist-lib/types/workspace/source/flowContracts.d.ts +72 -0
- package/dist-lib/types/workspace/source/prototypeSource.d.ts +4 -0
- package/dist-lib/types/workspace/source/responsive.d.ts +13 -0
- package/dist-lib/types/workspace/source/responsiveContracts.d.ts +78 -0
- package/dist-lib/types/workspace/source/structure.d.ts +21 -0
- package/dist-lib/types/workspace/source/textSource.d.ts +28 -0
- package/dist-lib/types/workspace/tokens/TokenLibraryContext.d.ts +11 -0
- package/dist-lib/types/workspace/tokens/TokenLibraryWorkspace.d.ts +14 -0
- package/dist-lib/types/workspace/tokens/contracts.d.ts +74 -0
- package/dist-lib/types/workspace/tokens/controller.d.ts +44 -0
- package/dist-lib/types/workspace/view/PrototypePlayer.d.ts +17 -0
- package/dist-lib/types/workspace/view/StoryboardWorkspace.d.ts +3 -0
- package/dist-lib/types/workspace/view/prototypePlayback.d.ts +46 -0
- package/draft-preview.d.ts +54 -0
- package/inspect.d.ts +2 -0
- package/node/design-session.mjs +3 -0
- package/node/inspect-plugin.mjs +3 -1
- package/node/inspect-writeback.mjs +18 -2
- package/node/preview-artifact-store.mjs +10 -7
- package/node/source-archive.mjs +88 -0
- package/node/source-bindings.mjs +65 -0
- package/node/source-file-lock.mjs +15 -0
- package/node/source-file-plan.mjs +162 -0
- package/node/source-file-proofs.mjs +44 -0
- package/node/source-responsive-codec.d.mts +17 -0
- package/node/source-responsive-codec.mjs +133 -0
- package/node/source-responsive.mjs +189 -0
- package/node/source-structure-reconcile.mjs +186 -0
- package/node/source-structure-wrapper-codec.d.mts +20 -0
- package/node/source-structure-wrapper-codec.mjs +205 -0
- package/node/source-structure.mjs +404 -0
- package/node/token-source-references.mjs +136 -0
- package/node/workspace-draft-preview-plugin.mjs +70 -0
- package/node/workspace-draft-preview.mjs +231 -0
- package/node/workspace-source-identity.d.mts +1 -0
- package/node/workspace-source-identity.mjs +21 -0
- package/node/workspace-source-plan.mjs +195 -0
- package/package.json +52 -6
- package/source-files.d.ts +35 -0
- package/source-responsive.d.ts +10 -0
- package/source-structure.d.ts +155 -0
- package/workspace-source-plan.d.ts +44 -0
- package/docs/coverage-contracts.md +0 -444
- package/docs/screen-state-contract.md +0 -252
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/** Validate the same source bytes for both preview and apply. Legacy inputs omit the proof. */
|
|
6
|
+
export async function validateInspectSourceProofs(root, input, { sourceRevision } = {}) {
|
|
7
|
+
if (input?.sourceProof === undefined) return;
|
|
8
|
+
const proof = input.sourceProof;
|
|
9
|
+
if (!proof || typeof proof !== 'object' || typeof proof.sourceRevision !== 'string' || !proof.sourceRevision.trim()
|
|
10
|
+
|| !Array.isArray(proof.files) || proof.files.length < 1 || proof.files.length > 500) {
|
|
11
|
+
throw new Error('Source proof requires an exact revision and file hashes.');
|
|
12
|
+
}
|
|
13
|
+
if (sourceRevision !== undefined && proof.sourceRevision !== sourceRevision) {
|
|
14
|
+
throw new Error('Source proof does not match the active source revision.');
|
|
15
|
+
}
|
|
16
|
+
const base = await fsp.realpath(root);
|
|
17
|
+
const paths = new Set();
|
|
18
|
+
for (const item of proof.files) {
|
|
19
|
+
if (!item || typeof item.file !== 'string' || !item.file || path.isAbsolute(item.file)
|
|
20
|
+
|| /[\\\0?#]/.test(item.file) || /^[a-z]:/i.test(item.file)
|
|
21
|
+
|| item.file.split('/').some(part => !part || part === '.' || part === '..' || part === '.git')
|
|
22
|
+
|| typeof item.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(item.sha256) || paths.has(item.file)) {
|
|
23
|
+
throw new Error('Source proof contains an invalid or duplicate file.');
|
|
24
|
+
}
|
|
25
|
+
paths.add(item.file);
|
|
26
|
+
const actualPath = await fsp.realpath(path.resolve(base, item.file));
|
|
27
|
+
const relative = path.relative(base, actualPath);
|
|
28
|
+
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
29
|
+
throw new Error('Source proof file escapes the application root.');
|
|
30
|
+
}
|
|
31
|
+
const stat = await fsp.stat(actualPath);
|
|
32
|
+
if (!stat.isFile()) throw new Error('Source proof must identify regular source files.');
|
|
33
|
+
const actual = createHash('sha256').update(await fsp.readFile(actualPath)).digest('hex');
|
|
34
|
+
if (actual !== item.sha256) throw new Error(`Source changed since the document was loaded: ${item.file}`);
|
|
35
|
+
}
|
|
36
|
+
const required = [
|
|
37
|
+
...(input.styles ?? []).flatMap(style => [style.file, style.componentFile]),
|
|
38
|
+
...(input.texts ?? []).map(text => text.componentFile),
|
|
39
|
+
...(input.operations ?? []).map(operation => operation.identity?.componentFile),
|
|
40
|
+
];
|
|
41
|
+
if (required.some(file => typeof file !== 'string' || !paths.has(file))) {
|
|
42
|
+
throw new Error('Source proof must cover every edited file and component source.');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { InspectApplyPayload, InspectMedia } from '../src/editor/inspect';
|
|
2
|
+
import type { ResponsiveLayoutDocument, ResponsiveLayoutProperty, ResponsiveLayoutRange, ResponsiveLayoutSourceTarget } from '../src/workspace/source/responsiveContracts';
|
|
3
|
+
export const RESPONSIVE_PROPERTIES: readonly ResponsiveLayoutProperty[];
|
|
4
|
+
export const RESPONSIVE_COLLAPSED_VALUE: 'none';
|
|
5
|
+
export function responsiveSizingProperty(property: string): boolean;
|
|
6
|
+
export function responsiveFlexControl(property: string): boolean;
|
|
7
|
+
export function sortResponsiveRanges<T extends ResponsiveLayoutRange>(ranges: readonly T[]): T[];
|
|
8
|
+
export function responsiveRangePreviewWidth(ranges: readonly ResponsiveLayoutRange[], rangeId: string, preferred?: number): number;
|
|
9
|
+
export function describeResponsiveValueContract(property: string, permitted?: readonly string[]): string;
|
|
10
|
+
export function responsiveSourcePath(value: unknown): value is string;
|
|
11
|
+
export function responsiveMediaQuery(media: InspectMedia): string;
|
|
12
|
+
export function responsiveSourceHeader(range: ResponsiveLayoutRange): string;
|
|
13
|
+
export function validateResponsiveRanges(ranges: unknown): void;
|
|
14
|
+
export function validResponsiveValue(property: string, value: unknown, permitted?: readonly string[]): boolean;
|
|
15
|
+
export function validateResponsiveTargets(targets: unknown): void;
|
|
16
|
+
export function validateResponsiveState(state: unknown, ranges: readonly ResponsiveLayoutRange[], targets: readonly ResponsiveLayoutSourceTarget[]): asserts state is ResponsiveLayoutDocument;
|
|
17
|
+
export function planResponsiveStyleChanges(before: ResponsiveLayoutDocument, after: ResponsiveLayoutDocument, ranges: readonly ResponsiveLayoutRange[], targets: readonly ResponsiveLayoutSourceTarget[]): InspectApplyPayload['styles'];
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { workspaceSourceFingerprint as fingerprint } from './workspace-source-identity.mjs';
|
|
2
|
+
export const RESPONSIVE_PROPERTIES = Object.freeze(['flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'gap', 'padding', 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', 'display']);
|
|
3
|
+
/** A collapsed state is the only authored display change: a media range declares none and the base keeps the source display. */
|
|
4
|
+
export const RESPONSIVE_COLLAPSED_VALUE = 'none';
|
|
5
|
+
const SIZING_PROPERTY = /^(?:min-|max-)?(?:width|height)$/;
|
|
6
|
+
export const responsiveSizingProperty = property => SIZING_PROPERTY.test(property);
|
|
7
|
+
/** Flex controls need a flex source container; sizing and the collapsed state do not. */
|
|
8
|
+
export const responsiveFlexControl = property => property !== 'display' && !SIZING_PROPERTY.test(property);
|
|
9
|
+
const enums = {
|
|
10
|
+
'flex-direction': ['row', 'row-reverse', 'column', 'column-reverse'], 'flex-wrap': ['nowrap', 'wrap', 'wrap-reverse'],
|
|
11
|
+
'align-items': ['stretch', 'flex-start', 'flex-end', 'center', 'baseline'],
|
|
12
|
+
'justify-content': ['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'space-evenly'],
|
|
13
|
+
display: ['flex', 'inline-flex', 'block', 'inline-block', 'grid', 'inline-grid', RESPONSIVE_COLLAPSED_VALUE],
|
|
14
|
+
};
|
|
15
|
+
const lengthUnits = ['px', 'rem', 'em', '%', 'vw', 'vh'];
|
|
16
|
+
const sizingKeywords = ['auto', 'min-content', 'max-content', 'fit-content'];
|
|
17
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
const exact = (value, keys) => record(value) && Object.keys(value).every(key => keys.includes(key));
|
|
19
|
+
const name = value => typeof value === 'string' && /^[A-Za-z_][\w-]*$/.test(value);
|
|
20
|
+
export const responsiveSourcePath = value => typeof value === 'string' && value.length > 0 && !/[\\\0\r\n?#]/.test(value)
|
|
21
|
+
&& !value.startsWith('/') && !/^[a-z]:/i.test(value) && !value.split('/').some(part => !part || part === '.' || part === '..' || part === '.git');
|
|
22
|
+
export function responsiveMediaQuery(media) {
|
|
23
|
+
return [media.minWidth !== undefined ? `(min-width: ${media.minWidth}px)` : '', media.maxWidth !== undefined ? `(max-width: ${media.maxWidth}px)` : ''].filter(Boolean).join(' and ');
|
|
24
|
+
}
|
|
25
|
+
export function responsiveSourceHeader(range) {
|
|
26
|
+
return range.source?.kind === 'scss-include' ? `@include ${range.source.name}(${range.source.argument})` : `@media ${responsiveMediaQuery(range.media)}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Breakpoint order: the base first, then max-width ranges by descending width, then bounded and
|
|
30
|
+
* min-width ranges by ascending minimum width. Media ranges never overlap, so for a proven catalog
|
|
31
|
+
* this reads from the narrowest viewport to the widest while keeping each cascade family in its
|
|
32
|
+
* conventional source order. The order is the declared order; hosts declare ranges in it.
|
|
33
|
+
*/
|
|
34
|
+
export function sortResponsiveRanges(ranges) {
|
|
35
|
+
const key = range => range.media?.minWidth !== undefined ? [1, range.media.minWidth] : [0, -(range.media?.maxWidth ?? Infinity)];
|
|
36
|
+
return [ranges[0], ...ranges.slice(1).map((range, index) => ({ range, index })).sort((left, right) => {
|
|
37
|
+
const [a, b] = [key(left.range), key(right.range)];
|
|
38
|
+
return a[0] - b[0] || a[1] - b[1] || left.index - right.index;
|
|
39
|
+
}).map(item => item.range)];
|
|
40
|
+
}
|
|
41
|
+
const covered = (ranges, width) => ranges.some(range => range.media && width >= (range.media.minWidth ?? 0) && width <= (range.media.maxWidth ?? Infinity));
|
|
42
|
+
/** A preview width that resolves to the selected range; the base needs a width no media range covers. */
|
|
43
|
+
export function responsiveRangePreviewWidth(ranges, rangeId, preferred = 1024) {
|
|
44
|
+
const range = ranges.find(item => item.id === rangeId);
|
|
45
|
+
if (!range) throw new Error('The responsive preview range is not declared.');
|
|
46
|
+
if (range.media) return Math.min(Math.max(preferred, range.media.minWidth ?? 0, 1), range.media.maxWidth ?? 4000);
|
|
47
|
+
if (!covered(ranges, preferred)) return preferred;
|
|
48
|
+
const candidates = ranges.flatMap(item => item.media ? [(item.media.maxWidth ?? 4000) + 1, (item.media.minWidth ?? 0) - 1] : [])
|
|
49
|
+
.filter(width => width >= 1 && width <= 4000 && !covered(ranges, width));
|
|
50
|
+
return candidates.sort((left, right) => Math.abs(left - preferred) - Math.abs(right - preferred) || right - left)[0] ?? preferred;
|
|
51
|
+
}
|
|
52
|
+
export function validateResponsiveRanges(ranges) {
|
|
53
|
+
if (!Array.isArray(ranges) || !ranges.length || ranges.length > 20) throw new Error('Responsive ranges require one base and at most nineteen media ranges.');
|
|
54
|
+
const ids = new Set(); const intervals = [];
|
|
55
|
+
for (const [index, range] of ranges.entries()) {
|
|
56
|
+
if (!exact(range, ['id', 'label', 'media', 'source']) || !name(range.id) || typeof range.label !== 'string' || !range.label.trim() || ids.has(range.id)) throw new Error('Responsive ranges require unique stable IDs and labels.');
|
|
57
|
+
ids.add(range.id);
|
|
58
|
+
if (!index) { if (range.id !== 'base' || range.media !== undefined || range.source !== undefined) throw new Error('The first responsive range must be base without a media directive.'); continue; }
|
|
59
|
+
const media = range.media;
|
|
60
|
+
if (!exact(media, ['minWidth', 'maxWidth']) || !Object.keys(media).length || Object.values(media).some(value => !Number.isInteger(value) || value < 0 || value > 4000)
|
|
61
|
+
|| (media.minWidth ?? 0) > (media.maxWidth ?? Infinity)) throw new Error('Responsive media requires an exact width interval between zero and four thousand pixels.');
|
|
62
|
+
const interval = [media.minWidth ?? 0, media.maxWidth ?? Infinity];
|
|
63
|
+
if (intervals.some(previous => interval[0] <= previous[1] && previous[0] <= interval[1])) throw new Error('Responsive media ranges must not overlap.');
|
|
64
|
+
intervals.push(interval);
|
|
65
|
+
if (range.source !== undefined && !(exact(range.source, ['kind']) && range.source.kind === 'media')
|
|
66
|
+
&& !(exact(range.source, ['kind', 'name', 'argument', 'proofFile']) && range.source.kind === 'scss-include' && name(range.source.name)
|
|
67
|
+
&& typeof range.source.argument === 'string' && /^\$[A-Za-z_][\w-]*$/.test(range.source.argument) && responsiveSourcePath(range.source.proofFile))) throw new Error('Responsive source directives require an explicit media rule or a proven local mixin and variable.');
|
|
68
|
+
}
|
|
69
|
+
if (sortResponsiveRanges(ranges).some((range, index) => range !== ranges[index])) throw new Error('Responsive media ranges must follow breakpoint order: max-width ranges by descending width, then bounded and min-width ranges by ascending minimum width.');
|
|
70
|
+
}
|
|
71
|
+
export function validResponsiveValue(property, value, permitted) {
|
|
72
|
+
if (typeof value !== 'string' || !value || value.trim() !== value || value.length > 160 || /[;{}\0\r\n]/.test(value) || value.includes('</') || /!important/i.test(value)) return false;
|
|
73
|
+
if (permitted !== undefined) return Array.isArray(permitted) && permitted.includes(value);
|
|
74
|
+
if (enums[property]) return enums[property].includes(value);
|
|
75
|
+
const length = token => token === '0' || /^(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em|%|vw|vh)$/.test(token);
|
|
76
|
+
if (property === 'gap' || property === 'padding') {
|
|
77
|
+
const parts = value.split(/\s+/); return parts.length <= (property === 'gap' ? 2 : 4) && parts.every(length);
|
|
78
|
+
}
|
|
79
|
+
return length(value) || sizingKeywords.includes(value) || (property.startsWith('max-') && value === 'none');
|
|
80
|
+
}
|
|
81
|
+
/** One sentence a panel can show next to a rejected value. */
|
|
82
|
+
export function describeResponsiveValueContract(property, permitted) {
|
|
83
|
+
if (permitted !== undefined) return `${property} accepts only the host-permitted spellings: ${permitted.join(', ')}.`;
|
|
84
|
+
if (property === 'display') return 'display collapses a media range with none; the base range keeps the source display.';
|
|
85
|
+
if (enums[property]) return `${property} accepts ${enums[property].join(', ')}.`;
|
|
86
|
+
const lengths = `a length in ${lengthUnits.join(', ')} or 0`;
|
|
87
|
+
if (property === 'gap') return `gap accepts one or two lengths (${lengths}).`;
|
|
88
|
+
if (property === 'padding') return `padding accepts one to four lengths (${lengths}).`;
|
|
89
|
+
return `${property} accepts ${lengths}, ${sizingKeywords.join(', ')}${property.startsWith('max-') ? ' or none' : ''}.`;
|
|
90
|
+
}
|
|
91
|
+
export function validateResponsiveTargets(targets) {
|
|
92
|
+
if (!Array.isArray(targets) || !targets.length || targets.length > 100) throw new Error('Responsive mappings require explicit source targets.');
|
|
93
|
+
const ids = new Set(); const sources = new Set();
|
|
94
|
+
for (const target of targets) {
|
|
95
|
+
if (!exact(target, ['nodeId', 'file', 'className', 'componentFile', 'affectedFrames', 'properties', 'permittedValues']) || typeof target.nodeId !== 'string' || !target.nodeId || ids.has(target.nodeId)
|
|
96
|
+
|| !responsiveSourcePath(target.file) || !target.file.endsWith('.module.scss') || !responsiveSourcePath(target.componentFile) || !/\.[cm]?[jt]sx?$/.test(target.componentFile) || !name(target.className)
|
|
97
|
+
|| !Array.isArray(target.properties) || !target.properties.length || new Set(target.properties).size !== target.properties.length || target.properties.some(property => !RESPONSIVE_PROPERTIES.includes(property))
|
|
98
|
+
|| !Array.isArray(target.affectedFrames) || !target.affectedFrames.length || target.affectedFrames.some(frame => !exact(frame, ['id', 'name']) || typeof frame.id !== 'string' || !frame.id || typeof frame.name !== 'string' || !frame.name)) throw new Error('Responsive targets require unique node identities, admitted properties, source paths and affected frames.');
|
|
99
|
+
const address = JSON.stringify([target.file, target.className]);
|
|
100
|
+
if (sources.has(address)) throw new Error('A responsive source class must have one canonical owner.');
|
|
101
|
+
sources.add(address); ids.add(target.nodeId);
|
|
102
|
+
if (target.permittedValues !== undefined && (!record(target.permittedValues) || Object.entries(target.permittedValues).some(([property, values]) => !target.properties.includes(property)
|
|
103
|
+
|| !Array.isArray(values) || !values.length || values.some(value => !validResponsiveValue(property, value, values))))) throw new Error('Responsive permitted values must be nonempty safe source spellings for admitted properties.');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function validateResponsiveState(state, ranges, targets) {
|
|
107
|
+
validateResponsiveRanges(ranges); validateResponsiveTargets(targets);
|
|
108
|
+
if (!exact(state, ['version', 'ranges', 'styles']) || state.version !== 1 || !record(state.styles) || fingerprint(state.ranges) !== fingerprint(ranges)) throw new Error('Responsive authored state must preserve the host range catalog.');
|
|
109
|
+
if (Object.keys(state.styles).length !== targets.length || targets.some(target => !Object.hasOwn(state.styles, target.nodeId))) throw new Error('Responsive authored state must preserve every mapped source owner.');
|
|
110
|
+
for (const target of targets) {
|
|
111
|
+
const styles = state.styles[target.nodeId];
|
|
112
|
+
if (!record(styles) || Object.keys(styles).some(id => !ranges.some(range => range.id === id)) || !record(styles.base)
|
|
113
|
+
|| target.properties.some(property => !Object.hasOwn(styles.base, property))) throw new Error('Every responsive property requires an authored base declaration.');
|
|
114
|
+
for (const [id, declarations] of Object.entries(styles)) {
|
|
115
|
+
if (!record(declarations) || Object.entries(declarations).some(([property, value]) => !target.properties.includes(property)
|
|
116
|
+
|| !validResponsiveValue(property, value, target.permittedValues?.[property]))) throw new Error('Responsive values must match the host properties and value contract.');
|
|
117
|
+
if (Object.hasOwn(declarations, 'display') && (id === 'base') === (declarations.display === RESPONSIVE_COLLAPSED_VALUE)) throw new Error('A collapsed state declares display none only inside a media range; the base range keeps the source display.');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function planResponsiveStyleChanges(before, after, ranges, targets) {
|
|
122
|
+
validateResponsiveState(before, ranges, targets); validateResponsiveState(after, ranges, targets);
|
|
123
|
+
const changes = [];
|
|
124
|
+
for (const target of targets) for (const range of ranges) {
|
|
125
|
+
const previous = before.styles[target.nodeId][range.id] ?? {}; const next = after.styles[target.nodeId][range.id] ?? {};
|
|
126
|
+
const declarations = {};
|
|
127
|
+
for (const property of target.properties) if (previous[property] !== next[property]) declarations[property] = next[property] ?? '';
|
|
128
|
+
if (Object.keys(declarations).length) changes.push({ file: target.file, className: target.className, componentFile: target.componentFile,
|
|
129
|
+
declarations, affectedFrames: [...target.affectedFrames], ...(range.media ? { media: { ...range.media } } : {}) });
|
|
130
|
+
}
|
|
131
|
+
if (changes.length > 100) throw new Error('Responsive source changes exceed the inspect operation limit.');
|
|
132
|
+
return changes;
|
|
133
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { writeInspectChanges } from './inspect-writeback.mjs';
|
|
6
|
+
import { validateInspectSourceProofs } from './source-file-proofs.mjs';
|
|
7
|
+
import { workspaceSourceFingerprint as fingerprint } from './workspace-source-identity.mjs';
|
|
8
|
+
import { planResponsiveStyleChanges, responsiveFlexControl, responsiveMediaQuery, responsiveSourceHeader, responsiveSourcePath, validateResponsiveRanges, validateResponsiveState, validateResponsiveTargets } from './source-responsive-codec.mjs';
|
|
9
|
+
|
|
10
|
+
const hash = source => createHash('sha256').update(source).digest('hex');
|
|
11
|
+
const inspectMarker = (target, range) => `/* Pygmalion responsive: .${target.className} @media ${responsiveMediaQuery(range.media)} */`;
|
|
12
|
+
const marker = (target, range) => `/* Pygmalion responsive: .${target.className} ${responsiveSourceHeader(range)} */`;
|
|
13
|
+
const abort = signal => { if (signal?.aborted) throw signal.reason ?? new Error('Responsive source planning was aborted.'); };
|
|
14
|
+
|
|
15
|
+
// A bounded declaration reader, not a Sass evaluator. Unowned cascade rules are rejected.
|
|
16
|
+
function masked(source) {
|
|
17
|
+
return source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, value => value.replace(/[^\n]/g, ' '));
|
|
18
|
+
}
|
|
19
|
+
function blocks(source) {
|
|
20
|
+
const text = masked(source); const stack = []; const result = []; let start = 0;
|
|
21
|
+
for (let index = 0; index < text.length; index++) {
|
|
22
|
+
if (text[index] === '{') {
|
|
23
|
+
const block = { header: text.slice(start, index).trim(), start, open: index, close: -1, parent: stack.at(-1) ?? null, declarations: [] };
|
|
24
|
+
stack.push(block); result.push(block); start = index + 1;
|
|
25
|
+
} else if (text[index] === ';') {
|
|
26
|
+
stack.at(-1)?.declarations.push(source.slice(start, index)); start = index + 1;
|
|
27
|
+
} else if (text[index] === '}') {
|
|
28
|
+
const block = stack.pop(); if (!block) throw new Error('Responsive source contains unmatched braces.');
|
|
29
|
+
if (text.slice(start, index).trim()) block.declarations.push(source.slice(start, index));
|
|
30
|
+
block.close = index; start = index + 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (stack.length) throw new Error('Responsive source contains unmatched braces.');
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
function declarations(block, properties) {
|
|
37
|
+
const result = {};
|
|
38
|
+
for (const raw of block.declarations) {
|
|
39
|
+
const cleaned = raw.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '').trim();
|
|
40
|
+
const match = /^([\w-]+)\s*:\s*([\s\S]+)$/.exec(cleaned);
|
|
41
|
+
if (!match || !properties.includes(match[1])) continue;
|
|
42
|
+
if (Object.hasOwn(result, match[1])) throw new Error('Responsive source contains duplicate declarations.');
|
|
43
|
+
result[match[1]] = match[2].trim();
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
function normalizedState(state) {
|
|
48
|
+
const next = structuredClone(state);
|
|
49
|
+
for (const styles of Object.values(next.styles)) for (const [id, values] of Object.entries(styles)) if (id !== 'base' && !Object.keys(values).length) delete styles[id];
|
|
50
|
+
return next;
|
|
51
|
+
}
|
|
52
|
+
// The existing writer removes declarations one at a time. A managed range block that no longer
|
|
53
|
+
// carries any declaration is removed with its marker so cleared overrides leave no empty rule behind.
|
|
54
|
+
function pruneEmptyManagedBlocks(source, targets, ranges) {
|
|
55
|
+
let content = source;
|
|
56
|
+
const whitespaceOnly = block => block.declarations.every(raw => !raw.trim());
|
|
57
|
+
for (const target of targets) for (const range of ranges.slice(1)) {
|
|
58
|
+
const text = marker(target, range); const index = content.indexOf(text);
|
|
59
|
+
if (index < 0) continue;
|
|
60
|
+
const parsed = blocks(content);
|
|
61
|
+
const directive = parsed.find(block => !block.parent && block.open > index && content.slice(index + text.length, block.open).trim() === responsiveSourceHeader(range));
|
|
62
|
+
if (!directive) continue;
|
|
63
|
+
const children = parsed.filter(block => block.parent === directive);
|
|
64
|
+
if (!whitespaceOnly(directive) || children.length !== 1 || children[0].header !== `.${target.className}` || !whitespaceOnly(children[0]) || parsed.some(block => block.parent === children[0])) continue;
|
|
65
|
+
let start = index; let end = directive.close + 1;
|
|
66
|
+
if (content[end] === '\n') end += 1;
|
|
67
|
+
while (start > 0 && content[start - 1] === '\n' && content[start - 2] === '\n') start -= 1;
|
|
68
|
+
content = content.slice(0, start) + content.slice(end);
|
|
69
|
+
}
|
|
70
|
+
return content;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Read authored declarations only. The host owns mixin semantics and supplies exact file proofs. */
|
|
74
|
+
export function readResponsiveLayoutSource({ files, ranges, targets }) {
|
|
75
|
+
validateResponsiveRanges(ranges); validateResponsiveTargets(targets);
|
|
76
|
+
const sources = new Map();
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
if (!responsiveSourcePath(file.file) || sources.has(file.file) || typeof file.source !== 'string' || !/^[a-f\d]{64}$/.test(file.sha256) || hash(file.source) !== file.sha256) throw new Error('Responsive source inputs require unique paths and exact byte hashes.');
|
|
79
|
+
sources.set(file.file, file.source);
|
|
80
|
+
}
|
|
81
|
+
for (const range of ranges) if (range.source?.kind === 'scss-include') {
|
|
82
|
+
const source = sources.get(range.source.proofFile);
|
|
83
|
+
if (source === undefined) throw new Error('The responsive mixin source proof is missing.');
|
|
84
|
+
const text = masked(source);
|
|
85
|
+
const variable = range.source.argument.slice(1);
|
|
86
|
+
if (!new RegExp(`@mixin\\s+${range.source.name}\\s*\\(`).test(text) || !new RegExp(`\\$${variable}\\s*:`).test(text)) throw new Error('The responsive mixin and breakpoint variable must exist in the proven source.');
|
|
87
|
+
}
|
|
88
|
+
const state = { version: 1, ranges: structuredClone(ranges), styles: {} };
|
|
89
|
+
for (const target of targets) {
|
|
90
|
+
const source = sources.get(target.file);
|
|
91
|
+
if (source === undefined || !sources.has(target.componentFile)) throw new Error('Responsive source requires the stylesheet and importing component.');
|
|
92
|
+
const parsed = blocks(source); const styles = {}; const owned = new Set();
|
|
93
|
+
const base = parsed.filter(block => !block.parent && block.header === `.${target.className}`);
|
|
94
|
+
if (base.length !== 1) throw new Error('Responsive source requires one unambiguous top-level class.');
|
|
95
|
+
if (base[0].declarations.some(raw => /^\s*@(?:include|extend|apply)\b/.test(masked(raw)))) throw new Error('Responsive source cannot infer declarations introduced by an unexpanded directive.');
|
|
96
|
+
styles.base = declarations(base[0], target.properties); owned.add(base[0]);
|
|
97
|
+
// Flex controls need actual source flex semantics; sizing-only and collapse-only owners may omit them.
|
|
98
|
+
if (target.properties.some(responsiveFlexControl)) {
|
|
99
|
+
const display = declarations(base[0], ['display']).display;
|
|
100
|
+
if (display !== 'flex' && display !== 'inline-flex') throw new Error('Responsive layout controls require an existing flex source container.');
|
|
101
|
+
}
|
|
102
|
+
for (const range of ranges.slice(1)) {
|
|
103
|
+
const text = marker(target, range); const index = source.indexOf(text);
|
|
104
|
+
if (index < 0) continue;
|
|
105
|
+
if (source.indexOf(text, index + text.length) >= 0) throw new Error('Responsive source markers must be unique.');
|
|
106
|
+
const header = responsiveSourceHeader(range);
|
|
107
|
+
const directive = parsed.find(block => !block.parent && block.open > index && source.slice(index + text.length, block.open).trim() === header);
|
|
108
|
+
if (!directive) throw new Error('The responsive marker does not match its proven source directive.');
|
|
109
|
+
const matches = parsed.filter(block => block.parent === directive && block.header === `.${target.className}`);
|
|
110
|
+
if (matches.length !== 1) throw new Error('A managed responsive range requires one exact class.');
|
|
111
|
+
const values = declarations(matches[0], target.properties); if (Object.keys(values).length) styles[range.id] = values;
|
|
112
|
+
owned.add(matches[0]);
|
|
113
|
+
}
|
|
114
|
+
const classReference = new RegExp(`\\.${target.className}(?![\\w-])`);
|
|
115
|
+
for (const block of parsed) {
|
|
116
|
+
let ancestor = block.parent; let nested = false;
|
|
117
|
+
while (ancestor) { if (owned.has(ancestor)) nested = true; ancestor = ancestor.parent; }
|
|
118
|
+
if (!owned.has(block) && (classReference.test(block.header) || nested) && Object.keys(declarations(block, target.properties)).length) throw new Error('An unowned selector or media rule also controls this responsive source.');
|
|
119
|
+
}
|
|
120
|
+
state.styles[target.nodeId] = styles;
|
|
121
|
+
}
|
|
122
|
+
validateResponsiveState(state, ranges, targets);
|
|
123
|
+
return state;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Return a complete SCSS file plan using existing inspect writers in a private temporary root. */
|
|
127
|
+
export async function compileResponsiveSourcePlan({ appRoot, sourceRevision, mapping: inputMapping, before: inputBefore, after: inputAfter, additionalStyles: inputStyles = [], signal }) {
|
|
128
|
+
abort(signal);
|
|
129
|
+
const { mapping, before, after, additionalStyles } = JSON.parse(fingerprint({ mapping: inputMapping, before: inputBefore, after: inputAfter, additionalStyles: inputStyles }));
|
|
130
|
+
if (mapping?.version !== 1 || mapping.target?.sourceRevision !== sourceRevision) throw new Error('Responsive source plan requires the exact mapped source revision.');
|
|
131
|
+
const styles = planResponsiveStyleChanges(before, after, mapping.ranges, mapping.targets);
|
|
132
|
+
if (!Array.isArray(additionalStyles)) throw new Error('Additional visual styles must be an array.');
|
|
133
|
+
const addresses = new Set();
|
|
134
|
+
for (const style of [...styles, ...additionalStyles]) for (const property of Object.keys(style.declarations)) {
|
|
135
|
+
const address = fingerprint([style.file, style.className, style.media ?? null, property]);
|
|
136
|
+
if (addresses.has(address)) throw new Error('Responsive and visual operations overlap on one source declaration.');
|
|
137
|
+
addresses.add(address);
|
|
138
|
+
}
|
|
139
|
+
const payload = { styles: [...styles, ...additionalStyles], texts: [], operations: [], sourceProof: { sourceRevision,
|
|
140
|
+
files: mapping.files.filter(proof => proof.sha256 !== null) } };
|
|
141
|
+
await validateInspectSourceProofs(appRoot, payload, { sourceRevision });
|
|
142
|
+
const files = [];
|
|
143
|
+
for (const proof of mapping.files) {
|
|
144
|
+
abort(signal);
|
|
145
|
+
if (!responsiveSourcePath(proof.file)) throw new Error('Invalid responsive proof path.');
|
|
146
|
+
if (proof.sha256 === null) {
|
|
147
|
+
try { await fs.lstat(path.join(appRoot, proof.file)); throw new Error('A responsive absence proof is stale.'); }
|
|
148
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
149
|
+
} else files.push({ ...proof, source: await fs.readFile(path.join(appRoot, proof.file), 'utf8') });
|
|
150
|
+
}
|
|
151
|
+
const baseline = readResponsiveLayoutSource({ files, ranges: mapping.ranges, targets: mapping.targets });
|
|
152
|
+
if (fingerprint(normalizedState(before)) !== fingerprint(baseline)) throw new Error('Responsive baseline does not match the actual source declarations.');
|
|
153
|
+
if (!payload.styles.length) return { version: 1, sourceRevision, proofs: structuredClone(mapping.files), changes: [] };
|
|
154
|
+
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'pygmalion-responsive-'));
|
|
155
|
+
try {
|
|
156
|
+
const temporaryProofs = [];
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
abort(signal);
|
|
159
|
+
let content = file.source;
|
|
160
|
+
for (const target of mapping.targets.filter(target => target.file === file.file)) for (const range of mapping.ranges.slice(1)) {
|
|
161
|
+
if (range.source?.kind === 'scss-include') content = content.replace(marker(target, range), inspectMarker(target, range));
|
|
162
|
+
}
|
|
163
|
+
const destination = path.join(temporary, file.file); await fs.mkdir(path.dirname(destination), { recursive: true }); await fs.writeFile(destination, content);
|
|
164
|
+
temporaryProofs.push({ file: file.file, sha256: hash(content) });
|
|
165
|
+
}
|
|
166
|
+
abort(signal);
|
|
167
|
+
await writeInspectChanges(temporary, { ...payload, sourceProof: { sourceRevision, files: temporaryProofs } }, { sourceRevision });
|
|
168
|
+
const nextFiles = [];
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
let content = await fs.readFile(path.join(temporary, file.file), 'utf8');
|
|
171
|
+
// The existing writer owns declarations; this bridge only translates proven directive spellings.
|
|
172
|
+
for (const target of mapping.targets.filter(target => target.file === file.file)) for (const range of mapping.ranges.slice(1)) {
|
|
173
|
+
if (range.source?.kind !== 'scss-include') continue;
|
|
174
|
+
const mediaHeader = `${inspectMarker(target, range)}\n@media ${responsiveMediaQuery(range.media)} {`;
|
|
175
|
+
if (content.includes(mediaHeader)) content = content.replace(mediaHeader, `${marker(target, range)}\n${responsiveSourceHeader(range)} {`);
|
|
176
|
+
else content = content.replace(inspectMarker(target, range), marker(target, range));
|
|
177
|
+
}
|
|
178
|
+
content = pruneEmptyManagedBlocks(content, mapping.targets.filter(target => target.file === file.file), mapping.ranges);
|
|
179
|
+
nextFiles.push({ file: file.file, source: content, sha256: hash(content) });
|
|
180
|
+
}
|
|
181
|
+
abort(signal);
|
|
182
|
+
const reimported = readResponsiveLayoutSource({ files: nextFiles, ranges: mapping.ranges, targets: mapping.targets });
|
|
183
|
+
if (fingerprint(reimported) !== fingerprint(normalizedState(after))) throw new Error('Responsive source output does not round-trip to the authored draft.');
|
|
184
|
+
return { version: 1, sourceRevision, proofs: structuredClone(mapping.files), changes: nextFiles.flatMap(file => {
|
|
185
|
+
const previous = files.find(input => input.file === file.file);
|
|
186
|
+
return file.source === previous.source ? [] : [{ file: file.file, beforeSha256: previous.sha256, content: file.source }];
|
|
187
|
+
}).sort((a, b) => a.file.localeCompare(b.file)) };
|
|
188
|
+
} finally { await fs.rm(temporary, { recursive: true, force: true }); }
|
|
189
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { compileJsxStructurePlan, createJsxStructureIdentity, reimportJsxStructureSource } from './source-structure.mjs';
|
|
3
|
+
import { workspaceSourceFingerprint } from './workspace-source-identity.mjs';
|
|
4
|
+
|
|
5
|
+
const hash = source => createHash('sha256').update(source, 'utf8').digest('hex');
|
|
6
|
+
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
7
|
+
const revision = value => typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
|
|
8
|
+
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
function freeze(value) {
|
|
10
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(freeze); Object.freeze(value); }
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function failure(diagnostic, action = 'resolve-overlap') {
|
|
14
|
+
return freeze({ status: 'conflict', diagnostics: [{ ...diagnostic, action }] });
|
|
15
|
+
}
|
|
16
|
+
const layoutOf = node => node?.layout ?? null;
|
|
17
|
+
function shape(node) { return node ? { kind: node.kind, parentId: node.parentId, children: [...node.children], layout: layoutOf(node) } : null; }
|
|
18
|
+
function tree(projection) {
|
|
19
|
+
return new Map(projection.nodes.map(node => [node.nodeId, node]));
|
|
20
|
+
}
|
|
21
|
+
function equalTrees(left, right) {
|
|
22
|
+
return left.size === right.size && [...left].every(([id, node]) => same(shape(node), shape(right.get(id))));
|
|
23
|
+
}
|
|
24
|
+
function sourceResult(file, baseline, external, source, identity, projection, payload, stylesheetSource) {
|
|
25
|
+
const result = { status: payload ? 'ready' : 'unchanged', file,
|
|
26
|
+
baselineSourceRevision: baseline.sourceRevision, baselineSourceSha256: baseline.identity.sourceSha256,
|
|
27
|
+
sourceRevision: external.sourceRevision, externalSourceSha256: external.sha256,
|
|
28
|
+
source, identity, anchors: projection.anchors, nodes: projection.nodes, diagnostics: [] };
|
|
29
|
+
if (payload) result.payload = payload;
|
|
30
|
+
if (stylesheetSource !== undefined) result.stylesheetSource = stylesheetSource;
|
|
31
|
+
return freeze(result);
|
|
32
|
+
}
|
|
33
|
+
function updatedIdentity(file, source, rootId, anchors, opaqueNodeIds, wrapper) {
|
|
34
|
+
const present = new Set(anchors.map(anchor => anchor.nodeId));
|
|
35
|
+
return createJsxStructureIdentity({ file, source, rootId, anchors, opaqueNodeIds: opaqueNodeIds.filter(id => present.has(id)), wrapper });
|
|
36
|
+
}
|
|
37
|
+
/** Wrapper groups need the stylesheet at both revisions; its bytes are proved like the JSX. */
|
|
38
|
+
function wrapperInput(wrapper, file) {
|
|
39
|
+
if (wrapper === undefined) return { ok: true };
|
|
40
|
+
if (!record(wrapper) || Object.keys(wrapper).some(key => !['contract', 'baselineSource', 'externalSource', 'externalSha256'].includes(key))
|
|
41
|
+
|| typeof wrapper.baselineSource !== 'string' || typeof wrapper.externalSource !== 'string' || !record(wrapper.contract) || typeof wrapper.contract.stylesheet !== 'string') {
|
|
42
|
+
return { ok: false, failure: failure({ code: 'structure-reconcile-input-invalid', message: 'Wrapper reconciliation requires the host contract and the complete baseline and latest stylesheet bytes.', file }, 'refresh-source') };
|
|
43
|
+
}
|
|
44
|
+
if (typeof wrapper.externalSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(wrapper.externalSha256) || hash(wrapper.externalSource) !== wrapper.externalSha256) {
|
|
45
|
+
return { ok: false, failure: failure({ code: 'structure-source-stale', message: 'The latest stylesheet bytes no longer match their supplied SHA-256 proof. Read and review the latest source again.', file: wrapper.contract.stylesheet }, 'refresh-source') };
|
|
46
|
+
}
|
|
47
|
+
return { ok: true };
|
|
48
|
+
}
|
|
49
|
+
const changedFile = (compiled, file, fallback) => compiled.status === 'ready' ? compiled.payload.changes.find(change => change.file === file)?.content ?? fallback : fallback;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Three-way structure reconciliation with explicit host-supplied current-source identity.
|
|
53
|
+
* IDs are never inferred from snippets, names, runtime values, or positional similarity.
|
|
54
|
+
* Only a new plan against the external bytes is returned; baseline proofs are never relabeled.
|
|
55
|
+
*/
|
|
56
|
+
export function reconcileJsxStructurePlan(input) {
|
|
57
|
+
let captured;
|
|
58
|
+
try { captured = JSON.parse(workspaceSourceFingerprint(input)); }
|
|
59
|
+
catch { return failure({ code: 'structure-reconcile-input-invalid', message: 'Reconciliation requires complete serializable source, identity, and draft operation captures.' }, 'refresh-source'); }
|
|
60
|
+
const { file, baseline, external, operations, allowedFiles, excludedSourcePrefixes = [], wrapper } = captured ?? {};
|
|
61
|
+
if (!record(baseline) || !record(external) || !revision(baseline.sourceRevision) || !revision(external.sourceRevision)
|
|
62
|
+
|| typeof baseline.source !== 'string' || typeof external.source !== 'string' || !Array.isArray(operations)) {
|
|
63
|
+
return failure({ code: 'structure-reconcile-input-invalid', message: 'Provide both exact source revisions, complete source bytes, and the audited draft structure operations.', ...(typeof file === 'string' ? { file } : {}) }, 'refresh-source');
|
|
64
|
+
}
|
|
65
|
+
if (!baseline.identity || !external.identity) {
|
|
66
|
+
return failure({ code: 'structure-reconcile-identity-required', message: 'Provide complete baseline and external identity tables. Explicitly map retained IDs, allocate new IDs, and omit only deleted nodes.', file }, 'map-identities');
|
|
67
|
+
}
|
|
68
|
+
if (typeof external.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(external.sha256) || hash(external.source) !== external.sha256) {
|
|
69
|
+
return failure({ code: 'structure-source-stale', message: 'The latest source bytes no longer match their supplied SHA-256 proof. Read and review the latest source again.', file }, 'refresh-source');
|
|
70
|
+
}
|
|
71
|
+
const checked = wrapperInput(wrapper, file);
|
|
72
|
+
if (!checked.ok) return checked.failure;
|
|
73
|
+
const evidence = source => wrapper ? { contract: wrapper.contract, source } : undefined;
|
|
74
|
+
const stylesheetFiles = source => wrapper ? [{ file: wrapper.contract.stylesheet, source, sha256: hash(source) }] : [];
|
|
75
|
+
const contract = wrapper ? { wrapper: wrapper.contract } : {};
|
|
76
|
+
let before; let current;
|
|
77
|
+
try {
|
|
78
|
+
before = reimportJsxStructureSource({ file, source: baseline.source, identity: baseline.identity, wrapper: evidence(wrapper?.baselineSource) });
|
|
79
|
+
current = reimportJsxStructureSource({ file, source: external.source, identity: external.identity, wrapper: evidence(wrapper?.externalSource) });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return failure(error?.diagnostic ?? { code: 'structure-reconcile-identity-invalid', message: error instanceof Error ? error.message : String(error), file },
|
|
82
|
+
error?.diagnostic?.code?.includes('stale') ? 'refresh-source' : 'map-identities');
|
|
83
|
+
}
|
|
84
|
+
const originalNodes = tree(before); const externalNodes = tree(current);
|
|
85
|
+
if (baseline.identity.rootId !== external.identity.rootId) {
|
|
86
|
+
return failure({ code: 'structure-reconcile-root-conflict', message: 'The external identity must explicitly retain the source island root ID. Select and map the corresponding root before reconciling.', file, nodeId: baseline.identity.rootId }, 'map-identities');
|
|
87
|
+
}
|
|
88
|
+
const originalOpaque = new Set(baseline.identity.opaqueNodeIds); const externalOpaque = new Set(external.identity.opaqueNodeIds);
|
|
89
|
+
const identityConflicts = [];
|
|
90
|
+
for (const [id, node] of originalNodes) {
|
|
91
|
+
const latest = externalNodes.get(id);
|
|
92
|
+
if (latest && (node.kind !== latest.kind || originalOpaque.has(id) !== externalOpaque.has(id))) {
|
|
93
|
+
identityConflicts.push({ code: 'structure-reconcile-boundary-conflict', message: 'A retained ID changed node kind or opaque boundary. Confirm its source ownership and explicit identity mapping.', file, nodeId: id,
|
|
94
|
+
action: 'map-identities', baseline: shape(node), external: shape(latest) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (identityConflicts.length) return freeze({ status: 'conflict', diagnostics: identityConflicts });
|
|
98
|
+
|
|
99
|
+
let draftCompiled;
|
|
100
|
+
try {
|
|
101
|
+
draftCompiled = compileJsxStructurePlan({ sourceRevision: baseline.sourceRevision,
|
|
102
|
+
files: [{ file, source: baseline.source, sha256: baseline.identity.sourceSha256 }, ...stylesheetFiles(wrapper?.baselineSource)],
|
|
103
|
+
anchors: before.anchors, operations, allowedFiles, excludedSourcePrefixes, ...contract });
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return failure({ code: 'structure-reconcile-draft-invalid', message: error instanceof Error ? error.message : String(error), file });
|
|
106
|
+
}
|
|
107
|
+
if (draftCompiled.status === 'blocked') return freeze({ status: 'conflict', diagnostics: draftCompiled.diagnostics.map(diagnostic => ({ ...diagnostic, action: 'resolve-overlap' })) });
|
|
108
|
+
const draftSource = changedFile(draftCompiled, file, baseline.source);
|
|
109
|
+
const draftStylesheet = wrapper ? changedFile(draftCompiled, wrapper.contract.stylesheet, wrapper.baselineSource) : undefined;
|
|
110
|
+
let draftIdentity; let draft;
|
|
111
|
+
try {
|
|
112
|
+
draftIdentity = updatedIdentity(file, draftSource, baseline.identity.rootId, draftCompiled.anchors, baseline.identity.opaqueNodeIds, evidence(draftStylesheet));
|
|
113
|
+
draft = reimportJsxStructureSource({ file, source: draftSource, identity: draftIdentity, wrapper: evidence(draftStylesheet) });
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return failure(error?.diagnostic ?? { code: 'structure-reconcile-draft-invalid', message: error instanceof Error ? error.message : String(error), file });
|
|
116
|
+
}
|
|
117
|
+
const draftNodes = tree(draft);
|
|
118
|
+
// An unchanged draft accepts the explicitly mapped latest tree. If the entire draft tree is
|
|
119
|
+
// already present externally, keep its latest bytes without replaying groups or moves twice.
|
|
120
|
+
if (draftCompiled.status === 'unchanged' || equalTrees(draftNodes, externalNodes)) {
|
|
121
|
+
return sourceResult(file, baseline, external, external.source, external.identity, current, undefined, wrapper?.externalSource);
|
|
122
|
+
}
|
|
123
|
+
const conflicts = [];
|
|
124
|
+
const conflict = (code, message, id) => conflicts.push({ code, message, file, nodeId: id, action: 'resolve-overlap',
|
|
125
|
+
baseline: shape(originalNodes.get(id)), external: shape(externalNodes.get(id)), draft: shape(draftNodes.get(id)) });
|
|
126
|
+
for (const [id, original] of originalNodes) {
|
|
127
|
+
const latest = externalNodes.get(id); const desired = draftNodes.get(id);
|
|
128
|
+
if (!desired) {
|
|
129
|
+
if (latest && (!same(original.children, latest.children) || original.parentId !== latest.parentId)) {
|
|
130
|
+
conflict('structure-reconcile-delete-conflict', 'The draft removes a node whose external parent or children changed. Resolve that removal against the latest hierarchy.', id);
|
|
131
|
+
}
|
|
132
|
+
} else if (!latest) {
|
|
133
|
+
if (!same(original.children, desired.children) || original.parentId !== desired.parentId) {
|
|
134
|
+
conflict('structure-reconcile-missing-node', 'An externally deleted node is still changed by the draft. Restore its explicit identity or revise the draft move.', id);
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
if (!same(original.children, desired.children) && !same(original.children, latest.children)) {
|
|
138
|
+
conflict('structure-reconcile-children-conflict', 'Both sources changed this direct-child list. Choose an explicit final order before reconciling.', id);
|
|
139
|
+
}
|
|
140
|
+
if (original.parentId !== desired.parentId && original.parentId !== latest.parentId) {
|
|
141
|
+
conflict('structure-reconcile-parent-conflict', 'Both sources moved this node. Choose its final parent before reconciling.', id);
|
|
142
|
+
}
|
|
143
|
+
if (!same(layoutOf(original), layoutOf(desired)) && !same(layoutOf(original), layoutOf(latest))) {
|
|
144
|
+
conflict('structure-reconcile-layout-conflict', 'Both sources changed this wrapper layout. Choose its final layout before reconciling.', id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const id of draftNodes.keys()) {
|
|
149
|
+
if (!originalNodes.has(id) && externalNodes.has(id)) conflict('structure-reconcile-new-id-conflict', 'The draft and external source both introduced this ID. Map their identities or allocate a distinct draft ID.', id);
|
|
150
|
+
}
|
|
151
|
+
if (conflicts.length) return freeze({ status: 'conflict', diagnostics: conflicts });
|
|
152
|
+
|
|
153
|
+
let merged;
|
|
154
|
+
try {
|
|
155
|
+
merged = compileJsxStructurePlan({ sourceRevision: external.sourceRevision,
|
|
156
|
+
files: [{ file, source: external.source, sha256: external.sha256 }, ...stylesheetFiles(wrapper?.externalSource)], anchors: current.anchors,
|
|
157
|
+
operations, allowedFiles, excludedSourcePrefixes, ...contract });
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return failure({ code: 'structure-reconcile-merge-invalid', message: error instanceof Error ? error.message : String(error), file });
|
|
160
|
+
}
|
|
161
|
+
if (merged.status === 'blocked') return freeze({ status: 'conflict', diagnostics: merged.diagnostics.map(diagnostic => ({ ...diagnostic, action: 'resolve-overlap' })) });
|
|
162
|
+
const source = changedFile(merged, file, external.source);
|
|
163
|
+
const stylesheetSource = wrapper ? changedFile(merged, wrapper.contract.stylesheet, wrapper.externalSource) : undefined;
|
|
164
|
+
let mergedIdentity; let projection;
|
|
165
|
+
try {
|
|
166
|
+
mergedIdentity = updatedIdentity(file, source, external.identity.rootId, merged.anchors, external.identity.opaqueNodeIds, evidence(stylesheetSource));
|
|
167
|
+
projection = reimportJsxStructureSource({ file, source, identity: mergedIdentity, wrapper: evidence(stylesheetSource) });
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return failure(error?.diagnostic ?? { code: 'structure-reconcile-merge-invalid', message: error instanceof Error ? error.message : String(error), file });
|
|
170
|
+
}
|
|
171
|
+
const mergedNodes = tree(projection);
|
|
172
|
+
const expected = new Map();
|
|
173
|
+
for (const [id, node] of externalNodes) {
|
|
174
|
+
const original = originalNodes.get(id); const desired = draftNodes.get(id);
|
|
175
|
+
if (original && !desired) continue;
|
|
176
|
+
expected.set(id, { ...node,
|
|
177
|
+
parentId: original && desired.parentId !== original.parentId ? desired.parentId : node.parentId,
|
|
178
|
+
children: original && !same(desired.children, original.children) ? desired.children : node.children,
|
|
179
|
+
layout: original && !same(layoutOf(desired), layoutOf(original)) ? layoutOf(desired) : layoutOf(node) });
|
|
180
|
+
}
|
|
181
|
+
for (const [id, node] of draftNodes) if (!originalNodes.has(id)) expected.set(id, node);
|
|
182
|
+
if (!equalTrees(expected, mergedNodes)) {
|
|
183
|
+
return failure({ code: 'structure-reconcile-result-conflict', message: 'The replayed operations do not match the combined explicit hierarchies. Resolve the final structure before applying.', file });
|
|
184
|
+
}
|
|
185
|
+
return sourceResult(file, baseline, external, source, mergedIdentity, projection, merged.status === 'ready' ? merged.payload : undefined, stylesheetSource);
|
|
186
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { WorkspaceStructureGroupLayout, WorkspaceStructureWrapperDeclaration } from '../src/workspace/source/contracts';
|
|
2
|
+
|
|
3
|
+
export interface WrapperBlock { start: number; end: number; declarations: Record<string, string> }
|
|
4
|
+
export const VOID_ELEMENTS: ReadonlySet<string>;
|
|
5
|
+
export const WRAPPER_ALIGNMENTS: readonly ['start', 'center', 'end', 'stretch'];
|
|
6
|
+
export function wrapperContractProblem(contract: unknown): string | null;
|
|
7
|
+
export function wrapperTagName(contract: WorkspaceStructureWrapperDeclaration): string;
|
|
8
|
+
export function wrapperOpening(contract: WorkspaceStructureWrapperDeclaration, className: string): string;
|
|
9
|
+
export function wrapperClosing(contract: WorkspaceStructureWrapperDeclaration): string;
|
|
10
|
+
export function wrapperMarker(groupId: string): string;
|
|
11
|
+
export function wrapperClassName(groupId: string): string | null;
|
|
12
|
+
export function wrapperLayoutProblem(layout: unknown, contract: WorkspaceStructureWrapperDeclaration): string | null;
|
|
13
|
+
export function wrapperLayout(layout: WorkspaceStructureGroupLayout & { sizing?: string; crossSizing?: string }): WorkspaceStructureGroupLayout;
|
|
14
|
+
export function wrapperDeclarations(layout: WorkspaceStructureGroupLayout, contract: WorkspaceStructureWrapperDeclaration): Record<string, string>;
|
|
15
|
+
export function wrapperLayoutFromDeclarations(declarations: Record<string, string>, contract: WorkspaceStructureWrapperDeclaration): WorkspaceStructureGroupLayout;
|
|
16
|
+
export function wrapperBlock(groupId: string, className: string, layout: WorkspaceStructureGroupLayout, contract: WorkspaceStructureWrapperDeclaration, eol?: string): string;
|
|
17
|
+
export function findWrapperBlock(source: string, groupId: string, className: string): WrapperBlock | null;
|
|
18
|
+
export function wrapperClassReferenced(source: string, className: string, ignore?: readonly { start: number; end: number }[]): boolean;
|
|
19
|
+
export function readWrapperLayout(source: string, groupId: string, className: string, contract: WorkspaceStructureWrapperDeclaration): WorkspaceStructureGroupLayout | null;
|
|
20
|
+
export function applyWrapperEdits(source: string, edits: ReadonlyMap<string, WorkspaceStructureGroupLayout | null>, contract: WorkspaceStructureWrapperDeclaration): string;
|