@pygmalionjs/pygmalion 0.8.1 → 0.10.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/README.md +34 -0
- package/binding-source.d.ts +25 -0
- package/dist-lib/{CameraLayer-B2D29VXQ.js → CameraLayer-BUOlJcUa.js} +17 -17
- package/dist-lib/pygmalion.js +24524 -12555
- package/dist-lib/{runtime-BAy8W1mJ.js → runtime-BOfDlGPR.js} +12438 -11118
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +19 -19
- 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/duplicateMetadata.d.ts +8 -0
- package/dist-lib/types/binding/index.d.ts +7 -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 +103 -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/token-library/valueResolver.d.ts +19 -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 +47 -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/host-runtime.d.ts +73 -0
- package/inspect.d.ts +2 -0
- package/jsx-source-reconciler.d.ts +35 -0
- package/node/design-session.mjs +3 -0
- package/node/host-runtime.mjs +4 -0
- package/node/inspect-plugin.mjs +3 -1
- package/node/inspect-writeback.mjs +18 -2
- package/node/jsx-source-reconciler.mjs +155 -0
- package/node/preview-artifact-store.mjs +10 -7
- package/node/revision-catalog-resolver.mjs +159 -0
- package/node/source-archive-runtime.mjs +63 -0
- 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-module.mjs +17 -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-service-inputs.mjs +81 -0
- package/node/source-service-plugin.mjs +57 -0
- package/node/source-service.mjs +4 -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/token-source-service.mjs +151 -0
- package/node/workspace-draft-entry.mjs +52 -0
- package/node/workspace-draft-preview-plugin.mjs +70 -0
- package/node/workspace-draft-preview.mjs +232 -0
- package/node/workspace-draft-process.mjs +129 -0
- package/node/workspace-draft-runtime.mjs +67 -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/node/workspace-source-session.mjs +187 -0
- package/package.json +90 -6
- package/source-files.d.ts +35 -0
- package/source-module.d.ts +16 -0
- package/source-responsive.d.ts +10 -0
- package/source-service.d.ts +92 -0
- package/source-structure.d.ts +155 -0
- package/workspace-draft-entry.d.ts +15 -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,404 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
import { VOID_ELEMENTS as VOID, applyWrapperEdits, readWrapperLayout, wrapperClassName, wrapperClosing, wrapperContractProblem,
|
|
4
|
+
wrapperLayout, wrapperLayoutProblem, wrapperOpening, wrapperTagName } from './source-structure-wrapper-codec.mjs';
|
|
5
|
+
export { reconcileJsxStructurePlan } from './source-structure-reconcile.mjs';
|
|
6
|
+
|
|
7
|
+
const fingerprint = source => createHash('sha256').update(source, 'utf8').digest('hex');
|
|
8
|
+
const relative = value => typeof value === 'string' && value.length > 0 && !/^[a-z]:/i.test(value)
|
|
9
|
+
&& !/[\\\x00-\x1f\x7f?#]/.test(value) && !value.split('/').some(part => !part || ['.', '..', '.git'].includes(part));
|
|
10
|
+
const identity = value => typeof value === 'string' && value.trim() === value && value.length > 0 && !['__proto__', 'constructor', 'prototype'].includes(value);
|
|
11
|
+
const boundedText = value => typeof value === 'string' && !value.includes('\0') && Buffer.byteLength(value, 'utf8') <= 32 * 1024 * 1024
|
|
12
|
+
&& Buffer.from(value, 'utf8').toString('utf8') === value;
|
|
13
|
+
const container = node => ts.isJsxElement(node) || ts.isJsxFragment(node);
|
|
14
|
+
const selectable = node => container(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxExpression(node) || ts.isJsxText(node);
|
|
15
|
+
// A same-line whitespace child is rendered text; blank multiline indentation is not.
|
|
16
|
+
const blankText = value => value.length === 0 || /^[ \t\r\n]*$/.test(value) && /[\r\n]/.test(value);
|
|
17
|
+
function fail(code, message, location = {}) { throw Object.assign(new Error(message), { diagnostic: { code, message, ...location } }); }
|
|
18
|
+
function parse(file, source) {
|
|
19
|
+
if (!relative(file) || typeof source !== 'string' || !/\.(?:[cm]?[jt]s|[jt]sx)$/i.test(file)) fail('structure-file-invalid', 'A JSX source requires a normalized JavaScript or TypeScript path.', { file });
|
|
20
|
+
const kind = /\.tsx$/i.test(file) ? ts.ScriptKind.TSX : /\.(?:[cm]?ts)$/i.test(file) ? ts.ScriptKind.TS : ts.ScriptKind.JSX;
|
|
21
|
+
const ast = /** @type {import('typescript').SourceFile & { parseDiagnostics: readonly import('typescript').Diagnostic[] }} */ (ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind));
|
|
22
|
+
if (ast.parseDiagnostics.length) fail('structure-syntax-invalid', 'Source must parse before and after structural changes.', { file });
|
|
23
|
+
return ast;
|
|
24
|
+
}
|
|
25
|
+
function bounds(node, ast) { return { start: ts.isJsxText(node) ? node.pos : node.getStart(ast), end: node.end }; }
|
|
26
|
+
function tag(node, ast) {
|
|
27
|
+
return ts.isJsxElement(node) ? node.openingElement.tagName.getText(ast) : ts.isJsxSelfClosingElement(node) ? node.tagName.getText(ast) : undefined;
|
|
28
|
+
}
|
|
29
|
+
function island(node) {
|
|
30
|
+
while (container(node.parent) && node.parent.children.includes(node)) node = node.parent;
|
|
31
|
+
return node;
|
|
32
|
+
}
|
|
33
|
+
/** Host wrapper evidence for identity and reimport: the contract plus the complete stylesheet bytes. */
|
|
34
|
+
function wrapperEvidence(wrapper, file) {
|
|
35
|
+
if (wrapper === undefined) return undefined;
|
|
36
|
+
if (!wrapper || typeof wrapper !== 'object' || Array.isArray(wrapper) || Object.keys(wrapper).some(key => !['contract', 'source'].includes(key)) || !boundedText(wrapper.source)) {
|
|
37
|
+
fail('structure-wrapper-invalid', 'Wrapper evidence requires the host contract and the complete stylesheet source.', { file });
|
|
38
|
+
}
|
|
39
|
+
const problem = wrapperContractProblem(wrapper.contract);
|
|
40
|
+
if (problem) fail('structure-wrapper-invalid', problem, { file });
|
|
41
|
+
return wrapper;
|
|
42
|
+
}
|
|
43
|
+
/** A generated wrapper is exactly the canonical element and its managed stylesheet block; anything else is a plain element. */
|
|
44
|
+
function generatedWrapper(text, nodeId, contract, stylesheet) {
|
|
45
|
+
const className = wrapperClassName(nodeId);
|
|
46
|
+
if (!className || !text.startsWith(wrapperOpening(contract, className)) || !text.endsWith(wrapperClosing(contract))) return null;
|
|
47
|
+
let layout;
|
|
48
|
+
try { layout = readWrapperLayout(stylesheet, nodeId, className, contract); }
|
|
49
|
+
catch (error) { fail(error.code ?? 'structure-wrapper-stale', error.message, { file: contract.stylesheet, nodeId }); }
|
|
50
|
+
return layout ? { className, layout } : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read a host-selected JSX root without evaluating props, handlers, or expressions. */
|
|
54
|
+
export function inspectJsxStructureSource({ file, source, rootStart, opaqueNodeStarts = [] }) {
|
|
55
|
+
const ast = parse(file, source); const candidates = [];
|
|
56
|
+
const visit = node => {
|
|
57
|
+
if ((container(node) || ts.isJsxSelfClosingElement(node)) && node.getStart(ast) === rootStart) candidates.push(node);
|
|
58
|
+
ts.forEachChild(node, visit);
|
|
59
|
+
};
|
|
60
|
+
visit(ast);
|
|
61
|
+
if (candidates.length !== 1) fail('structure-root-invalid', 'The selected root must identify one complete JSX element or fragment.', { file });
|
|
62
|
+
if (!Array.isArray(opaqueNodeStarts) || opaqueNodeStarts.some(start => !Number.isSafeInteger(start) || start < 0)
|
|
63
|
+
|| new Set(opaqueNodeStarts).size !== opaqueNodeStarts.length) fail('structure-opaque-invalid', 'Opaque roots require unique source offsets.', { file });
|
|
64
|
+
const opaque = new Set(opaqueNodeStarts); const seen = new Set(); const anchors = []; const nodes = [];
|
|
65
|
+
const project = (node, parentId, runtimePath) => {
|
|
66
|
+
const { start, end } = bounds(node, ast); const nodeId = `${file}:${start}`;
|
|
67
|
+
const kind = ts.isJsxFragment(node) ? 'fragment' : ts.isJsxExpression(node) ? 'expression' : ts.isJsxText(node) ? 'text' : 'element';
|
|
68
|
+
const projected = { nodeId, parentId, children: [], kind, runtimePath, start, end };
|
|
69
|
+
const tagName = tag(node, ast); if (tagName) projected.tagName = tagName;
|
|
70
|
+
const attributes = ts.isJsxElement(node) ? node.openingElement.attributes : ts.isJsxSelfClosingElement(node) ? node.attributes : undefined;
|
|
71
|
+
const attribute = attributes?.properties.find(item => ts.isJsxAttribute(item) && item.name.getText(ast) === 'className');
|
|
72
|
+
const literal = attribute && ts.isJsxAttribute(attribute) ? attribute.initializer : undefined;
|
|
73
|
+
if (literal && ts.isStringLiteral(literal)) projected.className = literal.text;
|
|
74
|
+
else if (literal && ts.isJsxExpression(literal) && literal.expression && ts.isStringLiteral(literal.expression)) projected.className = literal.expression.text;
|
|
75
|
+
anchors.push({ nodeId, file, start, end, expected: source.slice(start, end) }); nodes.push(projected);
|
|
76
|
+
if (opaque.has(start)) { seen.add(start); return nodeId; }
|
|
77
|
+
if (container(node)) {
|
|
78
|
+
let index = 0;
|
|
79
|
+
for (const child of node.children) {
|
|
80
|
+
if (ts.isJsxText(child) && blankText(source.slice(child.pos, child.end))) continue;
|
|
81
|
+
if (ts.isJsxExpression(child) && !child.expression) continue;
|
|
82
|
+
projected.children.push(project(child, nodeId, [...runtimePath, index++]));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return nodeId;
|
|
86
|
+
};
|
|
87
|
+
project(candidates[0], null, []);
|
|
88
|
+
if (seen.size !== opaque.size) fail('structure-opaque-invalid', 'Every opaque offset must identify a visited source node.', { file });
|
|
89
|
+
return { anchors, nodes };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Capture stable IDs for exact emitted source; persist with that source in the same reviewed plan. */
|
|
93
|
+
export function createJsxStructureIdentity({ file, source, rootId, anchors, opaqueNodeIds = [], wrapper }) {
|
|
94
|
+
if (typeof source !== 'string' || !Array.isArray(anchors) || !Array.isArray(opaqueNodeIds)
|
|
95
|
+
|| anchors.some(anchor => anchor?.file !== file || source.slice(anchor.start, anchor.end) !== anchor.expected)) {
|
|
96
|
+
fail('structure-identity-invalid', 'Identity evidence must describe the complete emitted source ranges.', { file });
|
|
97
|
+
}
|
|
98
|
+
const result = { version: 1, file, sourceSha256: fingerprint(source), rootId,
|
|
99
|
+
opaqueNodeIds: [...opaqueNodeIds], anchors: anchors.map(({ nodeId, start, end }) => ({ nodeId, start, end })) };
|
|
100
|
+
// Generated wrappers are recorded from the emitted element and its managed block, then proved again as a reimport.
|
|
101
|
+
const { wrappers } = restore({ file, source, evidence: result, wrapper, detect: true });
|
|
102
|
+
if (wrappers.length) result.wrappers = wrappers;
|
|
103
|
+
restore({ file, source, evidence: result, wrapper, detect: false });
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Rebuild hierarchy from the AST, retaining only IDs proved against the exact source bytes. */
|
|
108
|
+
export function reimportJsxStructureSource({ file, source, identity, wrapper }) {
|
|
109
|
+
const { anchors, nodes } = restore({ file, source, evidence: identity, wrapper, detect: false });
|
|
110
|
+
return { anchors, nodes };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function restore({ file, source, evidence, wrapper: wrapperInput, detect }) {
|
|
114
|
+
const wrapper = wrapperEvidence(wrapperInput, file);
|
|
115
|
+
if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence) || evidence.version !== 1
|
|
116
|
+
|| Object.keys(evidence).some(key => !['version', 'file', 'sourceSha256', 'rootId', 'opaqueNodeIds', 'anchors', 'wrappers'].includes(key))
|
|
117
|
+
|| !relative(file) || evidence.file !== file || !boundedText(source)
|
|
118
|
+
|| !/^[a-f0-9]{64}$/.test(evidence.sourceSha256 ?? '') || !identity(evidence.rootId)
|
|
119
|
+
|| !Array.isArray(evidence.anchors) || !evidence.anchors.length || evidence.anchors.length > 5000
|
|
120
|
+
|| !Array.isArray(evidence.opaqueNodeIds) || evidence.opaqueNodeIds.length > evidence.anchors.length) {
|
|
121
|
+
fail('structure-identity-invalid', 'Source identity requires a bounded, exact file, root and complete anchor table.', { file });
|
|
122
|
+
}
|
|
123
|
+
if (fingerprint(source) !== evidence.sourceSha256) fail('structure-identity-stale', 'The source bytes differ from their persisted identity. Reconcile source before importing.', { file });
|
|
124
|
+
const byId = new Map(); const byRange = new Map();
|
|
125
|
+
for (const anchor of evidence.anchors) {
|
|
126
|
+
if (!anchor || typeof anchor !== 'object' || Array.isArray(anchor)
|
|
127
|
+
|| Object.keys(anchor).some(key => !['nodeId', 'start', 'end'].includes(key))
|
|
128
|
+
|| !identity(anchor.nodeId) || byId.has(anchor.nodeId) || !Number.isSafeInteger(anchor.start) || !Number.isSafeInteger(anchor.end)
|
|
129
|
+
|| anchor.start < 0 || anchor.end <= anchor.start || anchor.end > source.length || byRange.has(`${anchor.start}:${anchor.end}`)) {
|
|
130
|
+
fail('structure-identity-invalid', 'Every persisted identity must name a unique, complete source range.', { file });
|
|
131
|
+
}
|
|
132
|
+
byId.set(anchor.nodeId, anchor); byRange.set(`${anchor.start}:${anchor.end}`, anchor.nodeId);
|
|
133
|
+
}
|
|
134
|
+
const recorded = evidence.wrappers === undefined ? [] : evidence.wrappers;
|
|
135
|
+
if (!Array.isArray(recorded) || recorded.length > byId.size || new Set(recorded.map(entry => entry?.nodeId)).size !== recorded.length
|
|
136
|
+
|| recorded.some(entry => !entry || typeof entry !== 'object' || Array.isArray(entry) || Object.keys(entry).some(key => !['nodeId', 'className'].includes(key))
|
|
137
|
+
|| !identity(entry.nodeId) || !byId.has(entry.nodeId) || entry.className !== wrapperClassName(entry.nodeId))) {
|
|
138
|
+
fail('structure-identity-invalid', 'Recorded wrapper groups must name persisted nodes with their derived class names.', { file });
|
|
139
|
+
}
|
|
140
|
+
if (recorded.length && !wrapper) fail('structure-identity-invalid', 'Recorded wrapper groups require the host wrapper contract and stylesheet.', { file });
|
|
141
|
+
const root = byId.get(evidence.rootId);
|
|
142
|
+
if (!root || new Set(evidence.opaqueNodeIds).size !== evidence.opaqueNodeIds.length
|
|
143
|
+
|| evidence.opaqueNodeIds.some(id => !byId.has(id))) fail('structure-identity-invalid', 'Root and opaque boundaries must name distinct persisted source nodes.', { file });
|
|
144
|
+
const projected = inspectJsxStructureSource({ file, source, rootStart: root.start,
|
|
145
|
+
opaqueNodeStarts: evidence.opaqueNodeIds.map(id => byId.get(id).start) });
|
|
146
|
+
const ids = new Map(projected.nodes.map(node => [node.nodeId, byRange.get(`${node.start}:${node.end}`)]));
|
|
147
|
+
if (ids.size !== byId.size || [...ids.values()].some(id => id === undefined) || ids.get(projected.nodes[0].nodeId) !== evidence.rootId) {
|
|
148
|
+
fail('structure-identity-mismatch', 'The persisted identity must cover exactly the complete projected AST tree.', { file });
|
|
149
|
+
}
|
|
150
|
+
const nodes = projected.nodes.map(node => ({ ...node, nodeId: ids.get(node.nodeId),
|
|
151
|
+
parentId: node.parentId === null ? null : ids.get(node.parentId), children: node.children.map(id => ids.get(id)) }));
|
|
152
|
+
const detected = new Map();
|
|
153
|
+
if (wrapper) {
|
|
154
|
+
for (const node of nodes) {
|
|
155
|
+
if (node.kind !== 'element' || node.tagName !== wrapperTagName(wrapper.contract)) continue;
|
|
156
|
+
const box = generatedWrapper(source.slice(node.start, node.end), node.nodeId, wrapper.contract, wrapper.source);
|
|
157
|
+
if (box) detected.set(node.nodeId, box);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!detect && (detected.size !== recorded.length || recorded.some(entry => detected.get(entry.nodeId)?.className !== entry.className))) {
|
|
161
|
+
fail('structure-identity-mismatch', 'Recorded wrapper groups must match the generated elements and their stylesheet blocks exactly.', { file });
|
|
162
|
+
}
|
|
163
|
+
for (const node of nodes) {
|
|
164
|
+
const box = detected.get(node.nodeId);
|
|
165
|
+
if (box) { node.layout = box.layout; node.wrapper = { className: box.className }; }
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
anchors: projected.anchors.map(anchor => ({ ...anchor, nodeId: ids.get(anchor.nodeId) })),
|
|
169
|
+
nodes,
|
|
170
|
+
wrappers: [...detected].map(([nodeId, box]) => ({ nodeId, className: box.className })),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Compile source-owned hierarchy operations to an immutable-source file plan; never write files. */
|
|
175
|
+
export function compileJsxStructurePlan(input) {
|
|
176
|
+
try { return compile(input); }
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error?.diagnostic) return { status: 'blocked', diagnostics: [error.diagnostic] };
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function compile(input) {
|
|
184
|
+
const { sourceRevision, files, anchors, operations, allowedFiles, excludedSourcePrefixes = [], wrapper } = input ?? {};
|
|
185
|
+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(sourceRevision ?? '') || !Array.isArray(files) || !files.length || files.length > 1000
|
|
186
|
+
|| !Array.isArray(anchors) || !anchors.length || anchors.length > 5000 || !Array.isArray(operations) || operations.length > 100
|
|
187
|
+
|| !Array.isArray(allowedFiles) || !Array.isArray(excludedSourcePrefixes)) fail('structure-input-invalid', 'Structural compilation requires an exact revision, bounded source evidence, and explicit file policy.');
|
|
188
|
+
if (wrapper !== undefined) { const problem = wrapperContractProblem(wrapper); if (problem) fail('structure-wrapper-invalid', problem); }
|
|
189
|
+
const allowed = new Set(allowedFiles); const excluded = excludedSourcePrefixes.map(value => typeof value === 'string' ? value.replace(/\/$/, '') : value);
|
|
190
|
+
if ([...allowed, ...excluded].some(value => !relative(value))) fail('structure-policy-invalid', 'Source policies require normalized relative paths.');
|
|
191
|
+
const sources = new Map(); let bytes = 0;
|
|
192
|
+
for (const entry of files) {
|
|
193
|
+
if (!entry || !relative(entry.file) || sources.has(entry.file) || typeof entry.source !== 'string' || !/^[a-f0-9]{64}$/.test(entry.sha256 ?? '')) fail('structure-file-invalid', 'Source files require unique paths and complete hashes.');
|
|
194
|
+
const encoded = Buffer.from(entry.source, 'utf8'); bytes += encoded.length;
|
|
195
|
+
if (encoded.toString('utf8') !== entry.source || bytes > 32 * 1024 * 1024 || entry.source.includes('\0')) fail('structure-file-invalid', 'Source files require bounded UTF-8 content.', { file: entry.file });
|
|
196
|
+
if (fingerprint(entry.source) !== entry.sha256) fail('structure-source-stale', 'The complete source file no longer matches its hash.', { file: entry.file });
|
|
197
|
+
sources.set(entry.file, { ...entry, ast: null, matches: null, roots: [] });
|
|
198
|
+
}
|
|
199
|
+
const models = new Map(); const ranges = new Map(); const byNode = new Map();
|
|
200
|
+
for (const anchor of anchors) {
|
|
201
|
+
const source = sources.get(anchor?.file);
|
|
202
|
+
if (!anchor || !identity(anchor.nodeId) || models.has(anchor.nodeId) || !source || !Number.isSafeInteger(anchor.start) || !Number.isSafeInteger(anchor.end)
|
|
203
|
+
|| anchor.start < 0 || anchor.end <= anchor.start || anchor.end > source.source.length || typeof anchor.expected !== 'string') fail('structure-anchor-invalid', 'Source anchors require unique identities and complete JSX ranges.');
|
|
204
|
+
if (source.source.slice(anchor.start, anchor.end) !== anchor.expected) fail('structure-source-stale', 'The source anchor no longer matches its exact snippet.', { file: anchor.file, nodeId: anchor.nodeId });
|
|
205
|
+
if (!source.ast) {
|
|
206
|
+
source.ast = parse(anchor.file, source.source); source.matches = new Map();
|
|
207
|
+
const visit = node => { if (selectable(node)) { const range = bounds(node, source.ast); source.matches.set(`${range.start}:${range.end}`, node); } ts.forEachChild(node, visit); };
|
|
208
|
+
visit(source.ast);
|
|
209
|
+
}
|
|
210
|
+
const key = `${anchor.file}:${anchor.start}:${anchor.end}`; const node = source.matches.get(`${anchor.start}:${anchor.end}`);
|
|
211
|
+
if (!node || ranges.has(key)) fail('structure-anchor-invalid', 'Each anchor must identify a distinct complete JSX node.', { file: anchor.file, nodeId: anchor.nodeId });
|
|
212
|
+
ranges.set(key, anchor.nodeId); byNode.set(node, anchor.nodeId);
|
|
213
|
+
models.set(anchor.nodeId, { ...anchor, node, source, island: island(node), parentId: null, nested: [], children: null, tail: '', active: true });
|
|
214
|
+
}
|
|
215
|
+
for (const source of sources.values()) {
|
|
216
|
+
const ordered = [...models.values()].filter(model => model.source === source).sort((left, right) => left.start - right.start || right.end - left.end);
|
|
217
|
+
const stack = [];
|
|
218
|
+
for (const model of ordered) {
|
|
219
|
+
while (stack.length && stack.at(-1).end <= model.start) stack.pop();
|
|
220
|
+
if (stack.length && model.end > stack.at(-1).end) fail('structure-overlap', 'Source anchors must have disjoint or fully nested ranges.', { file: model.file });
|
|
221
|
+
if (stack.length) stack.at(-1).nested.push(model.nodeId); else source.roots.push(model.nodeId);
|
|
222
|
+
stack.push(model);
|
|
223
|
+
if (container(model.node.parent) && model.node.parent.children.includes(model.node)) model.parentId = byNode.get(model.node.parent) ?? null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const changedParents = new Set();
|
|
227
|
+
const get = nodeId => { const model = models.get(nodeId); if (!model?.active) fail('structure-node-missing', 'The operation targets an absent source-owned node.', { nodeId }); return model; };
|
|
228
|
+
const writableFile = (file, nodeId) => { if (!allowed.has(file) || excluded.some(prefix => file === prefix || file.startsWith(`${prefix}/`))) fail('structure-file-excluded', 'The host has not allowed structural edits in this source file.', { file, nodeId }); };
|
|
229
|
+
const writable = model => writableFile(model.file, model.nodeId);
|
|
230
|
+
const stylesheet = wrapper ? sources.get(wrapper.stylesheet) : undefined;
|
|
231
|
+
// Null removes a managed block; a layout writes it in place or appends it. Applied once after JSX rendering.
|
|
232
|
+
const wrapperEdits = new Map(); const removedOriginals = new Set();
|
|
233
|
+
const requireStylesheet = nodeId => {
|
|
234
|
+
if (!stylesheet) fail('structure-wrapper-stylesheet-missing', 'Wrapper groups require the declared stylesheet source in the same plan.', { file: wrapper.stylesheet, nodeId });
|
|
235
|
+
writableFile(wrapper.stylesheet, nodeId);
|
|
236
|
+
};
|
|
237
|
+
const managedWrapper = model => {
|
|
238
|
+
if (!wrapper || !model.node || !ts.isJsxElement(model.node) || tag(model.node, model.source.ast) !== wrapperTagName(wrapper)) return null;
|
|
239
|
+
const className = wrapperClassName(model.nodeId);
|
|
240
|
+
if (!className || !model.expected.startsWith(wrapperOpening(wrapper, className)) || !model.expected.endsWith(wrapperClosing(wrapper))) return null;
|
|
241
|
+
requireStylesheet(model.nodeId);
|
|
242
|
+
return generatedWrapper(model.expected, model.nodeId, wrapper, stylesheet.source);
|
|
243
|
+
};
|
|
244
|
+
const children = model => {
|
|
245
|
+
writable(model);
|
|
246
|
+
if (model.children) return model.children;
|
|
247
|
+
const node = model.node; const name = tag(node, model.source.ast);
|
|
248
|
+
if (!container(node) && !ts.isJsxSelfClosingElement(node) || name && (!/^[a-z][a-z0-9]*$/.test(name) || VOID.has(name))) fail('structure-receiver-unsupported', 'Only intrinsic non-void JSX elements and fragments can receive structural changes.', { nodeId: model.nodeId });
|
|
249
|
+
const attributes = ts.isJsxElement(node) ? node.openingElement.attributes : ts.isJsxSelfClosingElement(node) ? node.attributes : null;
|
|
250
|
+
if (attributes?.properties.some(attribute => ts.isJsxSpreadAttribute(attribute) || ['children', 'dangerouslySetInnerHTML'].includes(attribute.name.getText(model.source.ast)))) fail('structure-receiver-unsupported', 'A source receiver cannot have spread, children, or inner-HTML attributes.', { nodeId: model.nodeId });
|
|
251
|
+
model.children = [];
|
|
252
|
+
if (ts.isJsxSelfClosingElement(node)) return model.children;
|
|
253
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node.openingFragment;
|
|
254
|
+
const closing = ts.isJsxElement(node) ? node.closingElement : node.closingFragment;
|
|
255
|
+
let cursor = opening.end;
|
|
256
|
+
for (const child of node.children) {
|
|
257
|
+
if (ts.isJsxText(child) && blankText(model.source.source.slice(child.pos, child.end))) continue;
|
|
258
|
+
if (ts.isJsxText(child) || ts.isJsxExpression(child) && !child.expression) fail('structure-trivia-ambiguous', 'Rendered JSX text and standalone comments cannot be reassigned by a hierarchy edit.', { nodeId: model.nodeId });
|
|
259
|
+
const id = byNode.get(child);
|
|
260
|
+
if (!id) fail('structure-child-unowned', 'Every direct JSX child of an edited parent requires an exact source anchor.', { nodeId: model.nodeId });
|
|
261
|
+
model.children.push({ id, leading: model.source.source.slice(cursor, child.getStart(model.source.ast)) }); cursor = child.end;
|
|
262
|
+
}
|
|
263
|
+
model.tail = model.source.source.slice(cursor, closing.getStart(model.source.ast));
|
|
264
|
+
return model.children;
|
|
265
|
+
};
|
|
266
|
+
const parentOf = model => { if (!model.parentId) fail('structure-root-unsupported', 'A source root cannot be removed or moved without its owned JSX parent.', { nodeId: model.nodeId }); return get(model.parentId); };
|
|
267
|
+
const position = (list, id) => { const index = list.findIndex(item => item.id === id); if (index < 0) fail('structure-parent-mismatch', 'The source parent does not contain the selected node.', { nodeId: id }); return index; };
|
|
268
|
+
for (const operation of operations) {
|
|
269
|
+
if (!operation || !['reorder', 'reparent', 'group', 'ungroup'].includes(operation.kind)) fail('structure-operation-invalid', 'The requested structural operation is unsupported.');
|
|
270
|
+
const keys = { reorder: ['kind', 'parentId', 'children'], reparent: ['kind', 'nodeId', 'parentId', 'index'], group: ['kind', 'nodeIds', 'groupId', 'layout'], ungroup: ['kind', 'nodeId'] }[operation.kind];
|
|
271
|
+
if (Object.keys(operation).some(key => !keys.includes(key))) fail('structure-operation-invalid', 'A structural operation contains an unsupported field.');
|
|
272
|
+
if (operation.kind === 'reorder') {
|
|
273
|
+
const parent = get(operation.parentId); const list = children(parent);
|
|
274
|
+
if (!Array.isArray(operation.children) || operation.children.length !== list.length || new Set(operation.children).size !== list.length || operation.children.some(id => !list.some(item => item.id === id))) fail('structure-children-mismatch', 'A reorder must name every current direct child exactly once.', { nodeId: parent.nodeId });
|
|
275
|
+
parent.children = operation.children.map(id => list.find(item => item.id === id)); changedParents.add(parent.nodeId);
|
|
276
|
+
} else if (operation.kind === 'reparent') {
|
|
277
|
+
const node = get(operation.nodeId); const from = parentOf(node); const parent = get(operation.parentId);
|
|
278
|
+
if (node.file !== parent.file || node.island !== parent.island) fail('structure-scope-crossing', 'Nodes cannot cross source files, expression, function, or conditional render boundaries.', { nodeId: node.nodeId });
|
|
279
|
+
for (let cursor = parent; cursor; cursor = cursor.parentId ? get(cursor.parentId) : null) if (cursor === node) fail('structure-cycle', 'A node cannot become its own descendant.', { nodeId: node.nodeId });
|
|
280
|
+
const sourceChildren = children(from); const targetChildren = children(parent); const oldIndex = position(sourceChildren, node.nodeId);
|
|
281
|
+
const maximum = targetChildren.length - (from === parent ? 1 : 0);
|
|
282
|
+
if (!Number.isSafeInteger(operation.index) || operation.index < 0 || operation.index > maximum) fail('structure-index-invalid', 'The destination index must address the final direct-child list.', { nodeId: parent.nodeId });
|
|
283
|
+
const [entry] = sourceChildren.splice(oldIndex, 1); targetChildren.splice(operation.index, 0, entry); node.parentId = parent.nodeId;
|
|
284
|
+
changedParents.add(from.nodeId); changedParents.add(parent.nodeId);
|
|
285
|
+
} else if (operation.kind === 'group') {
|
|
286
|
+
const previousGroup = models.get(operation.groupId);
|
|
287
|
+
if (!Array.isArray(operation.nodeIds) || !operation.nodeIds.length || new Set(operation.nodeIds).size !== operation.nodeIds.length || !identity(operation.groupId) || previousGroup?.active) fail('structure-group-invalid', 'A fragment group requires distinct children and an available stable identity.');
|
|
288
|
+
let box = null;
|
|
289
|
+
if (operation.layout !== undefined) {
|
|
290
|
+
if (!wrapper) fail('structure-wrapper-undeclared', 'A group with a layout requires a host wrapper declaration.', { nodeId: operation.groupId });
|
|
291
|
+
const problem = wrapperLayoutProblem(operation.layout, wrapper);
|
|
292
|
+
if (problem) fail('structure-layout-invalid', problem, { nodeId: operation.groupId });
|
|
293
|
+
const className = wrapperClassName(operation.groupId);
|
|
294
|
+
if (!className) fail('structure-group-invalid', 'A wrapper group identity must derive a CSS module class name.', { nodeId: operation.groupId });
|
|
295
|
+
requireStylesheet(operation.groupId);
|
|
296
|
+
box = { className, layout: wrapperLayout(operation.layout) };
|
|
297
|
+
}
|
|
298
|
+
const selected = operation.nodeIds.map(get); const parent = parentOf(selected[0]); const list = children(parent);
|
|
299
|
+
if (previousGroup && (previousGroup.file !== parent.file || previousGroup.island !== parent.island
|
|
300
|
+
|| previousGroup.node && !ts.isJsxFragment(previousGroup.node) && !previousGroup.removedWrapper)) fail('structure-group-invalid', 'A removed fragment identity can only be reused within its original source island.');
|
|
301
|
+
const first = position(list, selected[0].nodeId);
|
|
302
|
+
if (selected.some((node, index) => node.parentId !== parent.nodeId || list[first + index]?.id !== node.nodeId)) fail('structure-group-invalid', 'A fragment group requires one contiguous, ordered sibling range.');
|
|
303
|
+
const entries = list.splice(first, selected.length); const leading = entries[0].leading; entries[0] = { ...entries[0], leading: '' };
|
|
304
|
+
const group = { nodeId: operation.groupId, file: parent.file, source: parent.source, node: null, island: parent.island, parentId: parent.nodeId, children: entries, nested: [], tail: '', active: true, wrapper: box };
|
|
305
|
+
models.set(group.nodeId, group); selected.forEach(node => { node.parentId = group.nodeId; });
|
|
306
|
+
if (box) wrapperEdits.set(group.nodeId, box.layout);
|
|
307
|
+
list.splice(first, 0, { id: group.nodeId, leading }); changedParents.add(parent.nodeId);
|
|
308
|
+
} else {
|
|
309
|
+
const group = get(operation.nodeId); const parent = parentOf(group);
|
|
310
|
+
let removed = null;
|
|
311
|
+
if (group.node && !ts.isJsxFragment(group.node)) {
|
|
312
|
+
removed = managedWrapper(group);
|
|
313
|
+
if (!removed) fail('structure-ungroup-unsupported', 'Only anonymous JSX fragments and generated wrapper elements can be removed without discarding element behavior.', { nodeId: group.nodeId });
|
|
314
|
+
}
|
|
315
|
+
const list = children(parent); const members = children(group); const index = position(list, group.nodeId); const entry = list[index];
|
|
316
|
+
const promoted = members.map(member => ({ ...member }));
|
|
317
|
+
if (promoted.length) promoted[0].leading = entry.leading + promoted[0].leading;
|
|
318
|
+
const leftover = (promoted.length ? '' : entry.leading) + group.tail;
|
|
319
|
+
list.splice(index, 1, ...promoted);
|
|
320
|
+
if (list[index + promoted.length]) list[index + promoted.length].leading = leftover + list[index + promoted.length].leading;
|
|
321
|
+
else parent.tail = leftover + parent.tail;
|
|
322
|
+
promoted.forEach(member => { get(member.id).parentId = parent.nodeId; }); group.active = false; changedParents.add(parent.nodeId);
|
|
323
|
+
if (removed) { group.removedWrapper = removed; removedOriginals.add(group.nodeId); wrapperEdits.set(group.nodeId, null); }
|
|
324
|
+
// A wrapper created earlier in this batch leaves no block; an original block already removed stays removed.
|
|
325
|
+
else if (group.wrapper) { if (removedOriginals.has(group.nodeId)) wrapperEdits.set(group.nodeId, null); else wrapperEdits.delete(group.nodeId); }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const modified = [...changedParents].map(id => models.get(id)).filter(model => model.active && model.node);
|
|
329
|
+
for (const left of modified) for (const right of modified) {
|
|
330
|
+
if (left !== right && left.file === right.file && left.start < right.end && right.start < left.end && left.island !== right.island) fail('structure-overlap', 'One batch cannot edit both an opaque expression and a render tree inside it.', { file: left.file });
|
|
331
|
+
}
|
|
332
|
+
const part = text => ({ text, positions: new Map() });
|
|
333
|
+
const join = parts => {
|
|
334
|
+
let text = ''; const positions = new Map();
|
|
335
|
+
for (const item of parts) {
|
|
336
|
+
for (const [id, range] of item.positions) {
|
|
337
|
+
if (positions.has(id)) fail('structure-anchor-duplicate', 'A source-owned node would be emitted more than once.', { nodeId: id });
|
|
338
|
+
positions.set(id, { start: text.length + range.start, end: text.length + range.end });
|
|
339
|
+
}
|
|
340
|
+
text += item.text;
|
|
341
|
+
}
|
|
342
|
+
return { text, positions };
|
|
343
|
+
};
|
|
344
|
+
const rendering = new Set();
|
|
345
|
+
const region = (source, start, end, nested) => {
|
|
346
|
+
let cursor = start; const parts = [];
|
|
347
|
+
for (const id of nested) {
|
|
348
|
+
const child = models.get(id);
|
|
349
|
+
// Recreated groups are emitted by their current parent, never by an old source range.
|
|
350
|
+
if (!child.node || child.start < start || child.end > end) continue;
|
|
351
|
+
parts.push(part(source.source.slice(cursor, child.start)), render(child)); cursor = child.end;
|
|
352
|
+
}
|
|
353
|
+
parts.push(part(source.source.slice(cursor, end))); return join(parts);
|
|
354
|
+
};
|
|
355
|
+
const render = model => {
|
|
356
|
+
if (!model.active) fail('structure-anchor-loss', 'An inactive source anchor remained in the emitted hierarchy.', { nodeId: model.nodeId });
|
|
357
|
+
if (rendering.has(model.nodeId)) fail('structure-cycle', 'A structural edit created a source cycle.', { nodeId: model.nodeId });
|
|
358
|
+
rendering.add(model.nodeId);
|
|
359
|
+
let result;
|
|
360
|
+
if (!model.children) result = region(model.source, model.start, model.end, model.nested);
|
|
361
|
+
else {
|
|
362
|
+
let opening; let closing;
|
|
363
|
+
if (!model.node) {
|
|
364
|
+
opening = part(model.wrapper ? wrapperOpening(wrapper, model.wrapper.className) : '<>');
|
|
365
|
+
closing = part(model.wrapper ? wrapperClosing(wrapper) : '</>');
|
|
366
|
+
} else if (ts.isJsxSelfClosingElement(model.node)) {
|
|
367
|
+
opening = part(model.expected.replace(/\/>$/, '>')); closing = part(`</${tag(model.node, model.source.ast)}>`);
|
|
368
|
+
if (!model.children.length) { opening = part(model.expected); closing = part(''); }
|
|
369
|
+
} else {
|
|
370
|
+
const open = ts.isJsxElement(model.node) ? model.node.openingElement : model.node.openingFragment;
|
|
371
|
+
const close = ts.isJsxElement(model.node) ? model.node.closingElement : model.node.closingFragment;
|
|
372
|
+
opening = region(model.source, model.start, open.end, model.nested); closing = part(model.source.source.slice(close.getStart(model.source.ast), model.end));
|
|
373
|
+
}
|
|
374
|
+
result = join([opening, ...model.children.flatMap(child => [part(child.leading), render(get(child.id))]), part(model.tail), closing]);
|
|
375
|
+
}
|
|
376
|
+
rendering.delete(model.nodeId); result.positions.set(model.nodeId, { start: 0, end: result.text.length }); return result;
|
|
377
|
+
};
|
|
378
|
+
const updatedAnchors = []; const changes = [];
|
|
379
|
+
for (const source of sources.values()) {
|
|
380
|
+
const result = region(source, 0, source.source.length, source.roots);
|
|
381
|
+
if (result.text !== source.source) {
|
|
382
|
+
parse(source.file, result.text);
|
|
383
|
+
changes.push({ file: source.file, beforeSha256: source.sha256, content: result.text });
|
|
384
|
+
}
|
|
385
|
+
for (const [nodeId, range] of result.positions) updatedAnchors.push({ nodeId, file: source.file, ...range, expected: result.text.slice(range.start, range.end) });
|
|
386
|
+
}
|
|
387
|
+
if (wrapperEdits.size) {
|
|
388
|
+
let content;
|
|
389
|
+
try { content = applyWrapperEdits(stylesheet.source, wrapperEdits, wrapper); }
|
|
390
|
+
catch (error) { fail(error.code ?? 'structure-wrapper-stale', error.message, { file: wrapper.stylesheet }); }
|
|
391
|
+
for (const [nodeId, layout] of wrapperEdits) {
|
|
392
|
+
if (layout === null) continue;
|
|
393
|
+
let restored;
|
|
394
|
+
try { restored = readWrapperLayout(content, nodeId, wrapperClassName(nodeId), wrapper); }
|
|
395
|
+
catch (error) { fail(error.code ?? 'structure-wrapper-stale', error.message, { file: wrapper.stylesheet, nodeId }); }
|
|
396
|
+
if (!restored || JSON.stringify(restored) !== JSON.stringify(layout)) fail('structure-wrapper-stale', 'The generated wrapper block does not read back to its layout.', { file: wrapper.stylesheet, nodeId });
|
|
397
|
+
}
|
|
398
|
+
if (content !== stylesheet.source) changes.push({ file: wrapper.stylesheet, beforeSha256: stylesheet.sha256, content });
|
|
399
|
+
}
|
|
400
|
+
if (updatedAnchors.length !== [...models.values()].filter(model => model.active).length) fail('structure-anchor-loss', 'The emitted source does not contain every retained source anchor exactly once.');
|
|
401
|
+
updatedAnchors.sort((left, right) => left.file.localeCompare(right.file) || left.start - right.start || right.end - left.end);
|
|
402
|
+
if (!changes.length) return { status: 'unchanged', anchors: updatedAnchors };
|
|
403
|
+
return { status: 'ready', payload: { version: 1, sourceRevision, proofs: files.map(({ file, sha256 }) => ({ file, sha256 })), changes }, anchors: updatedAnchors };
|
|
404
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import ts from 'typescript';
|
|
3
|
+
|
|
4
|
+
const names = /--[A-Za-z0-9_\-\u0080-\uFFFF]+/g;
|
|
5
|
+
const unwrap = node => node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) && node.type.getText() === 'const') ? unwrap(node.expression) : node;
|
|
6
|
+
const walk = (node, visit) => { visit(node); ts.forEachChild(node, child => walk(child, visit)); };
|
|
7
|
+
|
|
8
|
+
/** Conservative source coverage: any diagnostic returns the original source and no edits. */
|
|
9
|
+
export function rewriteTokenSourceReferences({ file, source, renames }) {
|
|
10
|
+
const diagnostics = []; const references = []; const proposed = new Map(); const handled = new Set();
|
|
11
|
+
const changes = new Map();
|
|
12
|
+
const report = (code, message, start, name) => diagnostics.push({ code, message, file, ...(start === undefined ? {} : { start }), ...(name ? { name } : {}) });
|
|
13
|
+
if (typeof source !== 'string' || typeof file !== 'string' || !Array.isArray(renames)) throw new TypeError('Source reference scanning requires a file, source, and rename list.');
|
|
14
|
+
for (const { from, to } of renames) {
|
|
15
|
+
if (!/^--[A-Za-z0-9_-]+$/.test(from) || to !== null && !/^--[A-Za-z0-9_-]+$/.test(to) || changes.has(from)) report('invalid-rename', 'Rename entries require unique, exact supported custom-property names.');
|
|
16
|
+
else changes.set(from, to);
|
|
17
|
+
}
|
|
18
|
+
const destinations = [...changes.values()].filter(value => value !== null);
|
|
19
|
+
if (new Set(destinations).size !== destinations.length) report('rename-collision', 'Two custom properties cannot be renamed to the same name.');
|
|
20
|
+
const edit = (start, end, after) => {
|
|
21
|
+
const before = source.slice(start, end); const prior = proposed.get(start);
|
|
22
|
+
if (prior && (prior.end !== end || prior.after !== after)) report('overlapping-edit', 'Shared source requires incompatible replacements.', start);
|
|
23
|
+
else if (before !== after) proposed.set(start, { start, end, before, after });
|
|
24
|
+
};
|
|
25
|
+
const reference = (name, start, end, kind = 'static') => {
|
|
26
|
+
references.push({ name, start, end, kind }); handled.add(start);
|
|
27
|
+
if (!changes.has(name)) return;
|
|
28
|
+
const to = changes.get(name);
|
|
29
|
+
if (to === null) report('token-in-use', 'A removed custom property still has a source reference.', start, name);
|
|
30
|
+
else if (kind === 'static') edit(start, end, to);
|
|
31
|
+
};
|
|
32
|
+
const overlaps = (prefix, suffix = '') => [...changes.keys()].some(name => name.startsWith(prefix) && name.endsWith(suffix));
|
|
33
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, ts.LanguageVariant.JSX, source);
|
|
34
|
+
const comments = [];
|
|
35
|
+
while (scanner.scan() !== ts.SyntaxKind.EndOfFileToken) {
|
|
36
|
+
if ([ts.SyntaxKind.SingleLineCommentTrivia, ts.SyntaxKind.MultiLineCommentTrivia].includes(scanner.getToken())) comments.push([scanner.getTokenPos(), scanner.getTextPos()]);
|
|
37
|
+
}
|
|
38
|
+
const inComment = offset => comments.some(([start, end]) => offset >= start && offset < end);
|
|
39
|
+
const extension = path.extname(file).toLowerCase();
|
|
40
|
+
if (['.css', '.scss'].includes(extension)) {
|
|
41
|
+
const quoted = [...source.matchAll(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g)].map(match => [match.index, match.index + match[0].length]);
|
|
42
|
+
for (const match of source.matchAll(/var\(([^)]*)\)/g)) {
|
|
43
|
+
if (!inComment(match.index) && /\$|#\{|\\/.test(match[1]) && overlaps(match[1].match(/^\s*(--[\w-]*)/)?.[1] ?? '')) report('source-coverage', 'Dynamic or escaped stylesheet names require a host-resolved source contract.', match.index);
|
|
44
|
+
}
|
|
45
|
+
for (const match of source.matchAll(/(--[\w-]*)(?:#\{[^}]*\}|\$[\w-]+)/g)) {
|
|
46
|
+
if (!inComment(match.index) && overlaps(match[1])) report('source-coverage', 'Interpolated stylesheet names require a host-resolved source contract.', match.index);
|
|
47
|
+
}
|
|
48
|
+
for (const match of source.matchAll(names)) {
|
|
49
|
+
const start = match.index; const end = start + match[0].length;
|
|
50
|
+
if (inComment(start)) continue;
|
|
51
|
+
const literal = quoted.some(([left, right]) => start >= left && end <= right);
|
|
52
|
+
const before = source.slice(0, start); const after = source.slice(end);
|
|
53
|
+
if (!literal && (/var\(\s*$/.test(before) || /^\s*:/.test(after) || /@property\s+$/.test(before))) reference(match[0], start, end);
|
|
54
|
+
}
|
|
55
|
+
} else if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cjs', '.cts'].includes(extension)) {
|
|
56
|
+
const parsed = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ['.tsx', '.jsx'].includes(extension) ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
57
|
+
if (/** @type {ts.SourceFile & { parseDiagnostics: unknown[] }} */ (parsed).parseDiagnostics.length) report('source-coverage', 'The source cannot be parsed completely.');
|
|
58
|
+
const contexts = new Map();
|
|
59
|
+
walk(parsed, node => {
|
|
60
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
61
|
+
const raw = node.getText(parsed); const prefix = raw.match(/--[\w-]*/)?.[0] ?? '';
|
|
62
|
+
if ((raw.includes('var(') || prefix) && overlaps(prefix)) report('source-coverage', 'Concatenated custom-property names require a host-resolved source contract.', node.getStart(parsed));
|
|
63
|
+
}
|
|
64
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
65
|
+
const start = node.getStart(parsed) + 1; const raw = source.slice(start, node.end - 1);
|
|
66
|
+
const parent = node.parent;
|
|
67
|
+
const propertyName = ts.isPropertyAssignment(parent) && parent.name === node;
|
|
68
|
+
const apiName = ts.isCallExpression(parent) && parent.arguments[0] === node && ts.isPropertyAccessExpression(parent.expression)
|
|
69
|
+
&& ['setProperty', 'getPropertyValue', 'removeProperty'].includes(parent.expression.name.text);
|
|
70
|
+
for (const match of node.text.matchAll(names)) {
|
|
71
|
+
const position = start + match.index;
|
|
72
|
+
if (raw !== node.text) { if (changes.has(match[0])) report('source-coverage', 'Escaped string references require an explicit source contract.', start, match[0]); continue; }
|
|
73
|
+
if (/var\(\s*$/.test(raw.slice(0, match.index)) || match[0] === raw && (propertyName || apiName)) reference(match[0], position, position + match[0].length);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!ts.isTemplateExpression(node)) return;
|
|
77
|
+
const prefixMatch = node.head.text.match(/(?:var\(\s*)?(--[\w-]*)$/);
|
|
78
|
+
if (!prefixMatch && !node.head.text.includes('var(')) return;
|
|
79
|
+
const prefix = prefixMatch?.[1] ?? '';
|
|
80
|
+
if (!overlaps(prefix)) return;
|
|
81
|
+
const span = node.templateSpans[0];
|
|
82
|
+
const suffix = node.templateSpans.length === 1 && span.literal.text.match(/^([\w-]*)\s*\)$/);
|
|
83
|
+
let callback = node.parent;
|
|
84
|
+
while (callback && !ts.isFunctionLike(callback)) callback = callback.parent;
|
|
85
|
+
let argument = callback;
|
|
86
|
+
while (argument?.parent && ts.isParenthesizedExpression(argument.parent)) argument = argument.parent;
|
|
87
|
+
const call = argument?.parent;
|
|
88
|
+
const binding = callback && ts.isArrowFunction(callback) && callback.parameters[0]?.name;
|
|
89
|
+
const first = binding && ts.isArrayBindingPattern(binding) && binding.elements[0];
|
|
90
|
+
const variable = first && ts.isBindingElement(first) && !first.initializer && !first.dotDotDotToken && ts.isIdentifier(first.name) ? first.name : null;
|
|
91
|
+
const receiver = call && ts.isCallExpression(call) && unwrap(call.arguments[0]) === callback && ts.isPropertyAccessExpression(call.expression)
|
|
92
|
+
&& call.expression.name.text === 'map' ? unwrap(call.expression.expression) : null;
|
|
93
|
+
const rows = receiver && ts.isArrayLiteralExpression(receiver) ? receiver.elements.map(unwrap) : [];
|
|
94
|
+
if (ts.isTaggedTemplateExpression(node.parent) || !/^var\(\s*--[\w-]*$/.test(node.head.text) || !suffix || !variable || !ts.isIdentifier(span.expression) || span.expression.text !== variable.text
|
|
95
|
+
|| !rows.length || rows.some(row => !ts.isArrayLiteralExpression(row) || !row.elements.length || !ts.isStringLiteral(row.elements[0]))) {
|
|
96
|
+
report('source-coverage', 'Dynamic custom properties require a direct literal tuple map and one bound interpolation.', node.getStart(parsed)); return;
|
|
97
|
+
}
|
|
98
|
+
const context = contexts.get(callback) ?? { callback, variable, rows, uses: [] };
|
|
99
|
+
context.uses.push({ prefix, suffix: suffix[1], node, expression: span.expression }); contexts.set(callback, context);
|
|
100
|
+
});
|
|
101
|
+
for (const { callback, variable, rows, uses } of contexts.values()) {
|
|
102
|
+
const replacements = [];
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
const item = row.elements[0]; const start = item.getStart(parsed) + 1; const values = new Set();
|
|
105
|
+
for (const use of uses) {
|
|
106
|
+
const name = use.prefix + item.text + use.suffix; reference(name, start, item.end - 1, 'finite');
|
|
107
|
+
const to = changes.has(name) ? changes.get(name) : name;
|
|
108
|
+
if (to === null) continue;
|
|
109
|
+
if (!to.startsWith(use.prefix) || !to.endsWith(use.suffix)) { report('source-coverage', 'The renamed property cannot use the existing template prefix and suffix.', start, name); continue; }
|
|
110
|
+
values.add(to.slice(use.prefix.length, use.suffix ? -use.suffix.length : undefined));
|
|
111
|
+
}
|
|
112
|
+
if (values.size > 1) report('source-coverage', 'A shared tuple key would change another custom-property reference.', start);
|
|
113
|
+
const value = [...values][0] ?? item.text; replacements.push(value);
|
|
114
|
+
if (value !== item.text) {
|
|
115
|
+
if (!/^[\w-]+$/.test(value) || source.slice(start, item.end - 1) !== item.text) report('source-coverage', 'Tuple edits require plain literal keys.', start);
|
|
116
|
+
else edit(start, item.end - 1, value);
|
|
117
|
+
walk(callback.body, node => {
|
|
118
|
+
if (ts.isIdentifier(node) && node.text === variable.text && !uses.some(use => use.expression === node)) report('source-coverage', 'The tuple key has another use that this rename cannot prove.', node.getStart(parsed));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (replacements.some((value, index) => replacements.indexOf(value) !== index && rows[replacements.indexOf(value)].elements[0].text !== rows[index].elements[0].text)) report('rename-collision', 'Renaming would merge distinct tuple keys.');
|
|
123
|
+
}
|
|
124
|
+
} else report('source-coverage', 'This source language has no custom-property reference contract.');
|
|
125
|
+
for (const match of source.matchAll(names)) {
|
|
126
|
+
if (changes.has(match[0]) && !handled.has(match.index) && !inComment(match.index)) report('source-coverage', 'A custom-property occurrence is outside a proven CSS reference.', match.index, match[0]);
|
|
127
|
+
}
|
|
128
|
+
for (const { name, start } of references) {
|
|
129
|
+
if (destinations.includes(name) && !changes.has(name)) report('rename-collision', 'The rename target already has another source reference.', start, name);
|
|
130
|
+
}
|
|
131
|
+
const edits = [...proposed.values()].sort((left, right) => left.start - right.start);
|
|
132
|
+
if (edits.some((entry, index) => index && entry.start < edits[index - 1].end)) report('overlapping-edit', 'Source replacements overlap.');
|
|
133
|
+
if (diagnostics.length) return { source, edits: [], diagnostics, references };
|
|
134
|
+
for (const entry of edits.toReversed()) source = source.slice(0, entry.start) + entry.after + source.slice(entry.end);
|
|
135
|
+
return { source, edits, diagnostics, references };
|
|
136
|
+
}
|