@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.
Files changed (150) hide show
  1. package/README.md +34 -0
  2. package/binding-source.d.ts +25 -0
  3. package/dist-lib/{CameraLayer-B2D29VXQ.js → CameraLayer-BUOlJcUa.js} +17 -17
  4. package/dist-lib/pygmalion.js +24524 -12555
  5. package/dist-lib/{runtime-BAy8W1mJ.js → runtime-BOfDlGPR.js} +12438 -11118
  6. package/dist-lib/style.css +1 -1
  7. package/dist-lib/testing.js +19 -19
  8. package/dist-lib/types/binding/catalog.d.ts +10 -0
  9. package/dist-lib/types/binding/contracts.d.ts +202 -0
  10. package/dist-lib/types/binding/documentCommands.d.ts +38 -0
  11. package/dist-lib/types/binding/duplicateMetadata.d.ts +8 -0
  12. package/dist-lib/types/binding/index.d.ts +7 -0
  13. package/dist-lib/types/binding/preview.d.ts +37 -0
  14. package/dist-lib/types/binding/source.d.ts +109 -0
  15. package/dist-lib/types/binding/useDataProjection.d.ts +9 -0
  16. package/dist-lib/types/binding/validate.d.ts +8 -0
  17. package/dist-lib/types/core/runtime.d.ts +6 -0
  18. package/dist-lib/types/document/appearance.d.ts +112 -0
  19. package/dist-lib/types/document/assets.d.ts +204 -0
  20. package/dist-lib/types/document/components.d.ts +169 -0
  21. package/dist-lib/types/document/contracts.d.ts +171 -0
  22. package/dist-lib/types/document/engine.d.ts +20 -0
  23. package/dist-lib/types/document/exportSettings.d.ts +45 -0
  24. package/dist-lib/types/document/geometry.d.ts +11 -0
  25. package/dist-lib/types/document/layout.d.ts +53 -0
  26. package/dist-lib/types/document/prototype.d.ts +424 -0
  27. package/dist-lib/types/document/prototypeReferences.d.ts +3 -0
  28. package/dist-lib/types/document/richText.d.ts +185 -0
  29. package/dist-lib/types/document/styles.d.ts +216 -0
  30. package/dist-lib/types/document/svgImport.d.ts +17 -0
  31. package/dist-lib/types/document/vector.d.ts +222 -0
  32. package/dist-lib/types/editor/designCompiler.d.ts +7 -0
  33. package/dist-lib/types/editor/designImport.d.ts +7 -0
  34. package/dist-lib/types/editor/inspect.d.ts +8 -0
  35. package/dist-lib/types/editor/revisionCatalogInstall.d.ts +1 -1
  36. package/dist-lib/types/editor/screenInteractions.d.ts +3 -3
  37. package/dist-lib/types/editor/store.d.ts +1 -1
  38. package/dist-lib/types/host/project.d.ts +44 -0
  39. package/dist-lib/types/host/registryConnections.d.ts +11 -0
  40. package/dist-lib/types/host/sourceFilePlan.d.ts +15 -0
  41. package/dist-lib/types/host/sourceWriteback.d.ts +50 -0
  42. package/dist-lib/types/lib.d.ts +103 -3
  43. package/dist-lib/types/token-library/bindings.d.ts +62 -0
  44. package/dist-lib/types/token-library/contracts.d.ts +117 -0
  45. package/dist-lib/types/token-library/engine.d.ts +13 -0
  46. package/dist-lib/types/token-library/valueResolver.d.ts +19 -0
  47. package/dist-lib/types/workspace/WorkspaceShell.d.ts +9 -0
  48. package/dist-lib/types/workspace/application/ApplicationWorkspace.d.ts +3 -0
  49. package/dist-lib/types/workspace/configuration.d.ts +47 -0
  50. package/dist-lib/types/workspace/contracts.d.ts +64 -0
  51. package/dist-lib/types/workspace/controller.d.ts +35 -0
  52. package/dist-lib/types/workspace/documentSessions.d.ts +42 -0
  53. package/dist-lib/types/workspace/edit/AppearancePanel.d.ts +23 -0
  54. package/dist-lib/types/workspace/edit/ArrangeLayers.d.ts +10 -0
  55. package/dist-lib/types/workspace/edit/AssetsPanel.d.ts +16 -0
  56. package/dist-lib/types/workspace/edit/CanvasGuides.d.ts +19 -0
  57. package/dist-lib/types/workspace/edit/ComponentPanel.d.ts +11 -0
  58. package/dist-lib/types/workspace/edit/DataPanel.d.ts +16 -0
  59. package/dist-lib/types/workspace/edit/DesignWorkspace.d.ts +10 -0
  60. package/dist-lib/types/workspace/edit/LayerTree.d.ts +14 -0
  61. package/dist-lib/types/workspace/edit/LayoutPanel.d.ts +13 -0
  62. package/dist-lib/types/workspace/edit/PrototypePanel.d.ts +12 -0
  63. package/dist-lib/types/workspace/edit/ScopeTransferDialog.d.ts +40 -0
  64. package/dist-lib/types/workspace/edit/SelectionHandles.d.ts +52 -0
  65. package/dist-lib/types/workspace/edit/StylesPanel.d.ts +8 -0
  66. package/dist-lib/types/workspace/edit/TextEditor.d.ts +50 -0
  67. package/dist-lib/types/workspace/edit/VectorEditor.d.ts +89 -0
  68. package/dist-lib/types/workspace/edit/VectorToolbar.d.ts +5 -0
  69. package/dist-lib/types/workspace/edit/canvasGuideModel.d.ts +102 -0
  70. package/dist-lib/types/workspace/edit/clipboard.d.ts +35 -0
  71. package/dist-lib/types/workspace/edit/duplicateMetadata.d.ts +2 -0
  72. package/dist-lib/types/workspace/edit/geometryHandles.d.ts +224 -0
  73. package/dist-lib/types/workspace/edit/layerArrangement.d.ts +39 -0
  74. package/dist-lib/types/workspace/edit/layerMoves.d.ts +44 -0
  75. package/dist-lib/types/workspace/edit/projection.d.ts +12 -0
  76. package/dist-lib/types/workspace/edit/scopeTransfer.d.ts +96 -0
  77. package/dist-lib/types/workspace/edit/textStyles.d.ts +75 -0
  78. package/dist-lib/types/workspace/edit/vectorTools.d.ts +136 -0
  79. package/dist-lib/types/workspace/legacyModeAdapter.d.ts +13 -0
  80. package/dist-lib/types/workspace/source/ResponsiveLayoutPanel.d.ts +20 -0
  81. package/dist-lib/types/workspace/source/SourceReview.d.ts +11 -0
  82. package/dist-lib/types/workspace/source/appearanceSource.d.ts +19 -0
  83. package/dist-lib/types/workspace/source/compiler.d.ts +5 -0
  84. package/dist-lib/types/workspace/source/contracts.d.ts +156 -0
  85. package/dist-lib/types/workspace/source/controller.d.ts +50 -0
  86. package/dist-lib/types/workspace/source/documentRevision.d.ts +2 -0
  87. package/dist-lib/types/workspace/source/draftPreviewClient.d.ts +13 -0
  88. package/dist-lib/types/workspace/source/draftRecipe.d.ts +30 -0
  89. package/dist-lib/types/workspace/source/flowContracts.d.ts +72 -0
  90. package/dist-lib/types/workspace/source/prototypeSource.d.ts +4 -0
  91. package/dist-lib/types/workspace/source/responsive.d.ts +13 -0
  92. package/dist-lib/types/workspace/source/responsiveContracts.d.ts +78 -0
  93. package/dist-lib/types/workspace/source/structure.d.ts +21 -0
  94. package/dist-lib/types/workspace/source/textSource.d.ts +28 -0
  95. package/dist-lib/types/workspace/tokens/TokenLibraryContext.d.ts +11 -0
  96. package/dist-lib/types/workspace/tokens/TokenLibraryWorkspace.d.ts +14 -0
  97. package/dist-lib/types/workspace/tokens/contracts.d.ts +74 -0
  98. package/dist-lib/types/workspace/tokens/controller.d.ts +44 -0
  99. package/dist-lib/types/workspace/view/PrototypePlayer.d.ts +17 -0
  100. package/dist-lib/types/workspace/view/StoryboardWorkspace.d.ts +3 -0
  101. package/dist-lib/types/workspace/view/prototypePlayback.d.ts +46 -0
  102. package/draft-preview.d.ts +54 -0
  103. package/host-runtime.d.ts +73 -0
  104. package/inspect.d.ts +2 -0
  105. package/jsx-source-reconciler.d.ts +35 -0
  106. package/node/design-session.mjs +3 -0
  107. package/node/host-runtime.mjs +4 -0
  108. package/node/inspect-plugin.mjs +3 -1
  109. package/node/inspect-writeback.mjs +18 -2
  110. package/node/jsx-source-reconciler.mjs +155 -0
  111. package/node/preview-artifact-store.mjs +10 -7
  112. package/node/revision-catalog-resolver.mjs +159 -0
  113. package/node/source-archive-runtime.mjs +63 -0
  114. package/node/source-archive.mjs +88 -0
  115. package/node/source-bindings.mjs +65 -0
  116. package/node/source-file-lock.mjs +15 -0
  117. package/node/source-file-plan.mjs +162 -0
  118. package/node/source-file-proofs.mjs +44 -0
  119. package/node/source-module.mjs +17 -0
  120. package/node/source-responsive-codec.d.mts +17 -0
  121. package/node/source-responsive-codec.mjs +133 -0
  122. package/node/source-responsive.mjs +189 -0
  123. package/node/source-service-inputs.mjs +81 -0
  124. package/node/source-service-plugin.mjs +57 -0
  125. package/node/source-service.mjs +4 -0
  126. package/node/source-structure-reconcile.mjs +186 -0
  127. package/node/source-structure-wrapper-codec.d.mts +20 -0
  128. package/node/source-structure-wrapper-codec.mjs +205 -0
  129. package/node/source-structure.mjs +404 -0
  130. package/node/token-source-references.mjs +136 -0
  131. package/node/token-source-service.mjs +151 -0
  132. package/node/workspace-draft-entry.mjs +52 -0
  133. package/node/workspace-draft-preview-plugin.mjs +70 -0
  134. package/node/workspace-draft-preview.mjs +232 -0
  135. package/node/workspace-draft-process.mjs +129 -0
  136. package/node/workspace-draft-runtime.mjs +67 -0
  137. package/node/workspace-source-identity.d.mts +1 -0
  138. package/node/workspace-source-identity.mjs +21 -0
  139. package/node/workspace-source-plan.mjs +195 -0
  140. package/node/workspace-source-session.mjs +187 -0
  141. package/package.json +90 -6
  142. package/source-files.d.ts +35 -0
  143. package/source-module.d.ts +16 -0
  144. package/source-responsive.d.ts +10 -0
  145. package/source-service.d.ts +92 -0
  146. package/source-structure.d.ts +155 -0
  147. package/workspace-draft-entry.d.ts +15 -0
  148. package/workspace-source-plan.d.ts +44 -0
  149. package/docs/coverage-contracts.md +0 -444
  150. package/docs/screen-state-contract.md +0 -252
@@ -0,0 +1,159 @@
1
+ import { execFile } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { promisify } from 'node:util';
4
+ import { withSourceArchive } from './source-archive.mjs';
5
+
6
+ const execute = promisify(execFile);
7
+ const COMMIT_SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
8
+
9
+ function revision(value) {
10
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
11
+ if (!COMMIT_SHA.test(normalized)) throw new Error('A revision catalog requires the exact complete source commit.');
12
+ return normalized;
13
+ }
14
+
15
+ function freeze(value) {
16
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
17
+ Object.values(value).forEach(freeze);
18
+ Object.freeze(value);
19
+ }
20
+ return value;
21
+ }
22
+
23
+ function capture(catalog, sourceRevision) {
24
+ const portable = JSON.parse(JSON.stringify(catalog));
25
+ if (portable?.version !== 1 || portable.sourceRevision !== sourceRevision || !Array.isArray(portable.screens?.pages)) {
26
+ throw new Error(`The catalog does not match its requested source revision: ${sourceRevision.slice(0, 8)}`);
27
+ }
28
+ return freeze(portable);
29
+ }
30
+
31
+ /** A commit must be attached to this checkout or reachable from one of its refs. */
32
+ async function reachableFromRefs(projectRoot, sourceRevision, signal) {
33
+ const git = args => execute('git', ['-C', projectRoot, ...args], { encoding: 'utf8', signal });
34
+ const head = (await git(['rev-parse', '--verify', '--quiet', 'HEAD'])).stdout.trim();
35
+ if (head === sourceRevision) return true;
36
+ try {
37
+ await git(['cat-file', '-e', `${sourceRevision}^{commit}`]);
38
+ } catch (error) {
39
+ if (error?.code === 1 || error?.code === 128) return false;
40
+ throw error;
41
+ }
42
+ const { stdout } = await git([
43
+ 'for-each-ref', '--count=1', '--format=%(refname)', `--contains=${sourceRevision}`, 'refs/heads', 'refs/remotes', 'refs/tags',
44
+ ]);
45
+ return stdout.trim().length > 0;
46
+ }
47
+
48
+ /** Hosts provide catalog declarations; the SDK owns archive lifetime and bounded shared work. */
49
+ export function createRevisionCatalogResolver({
50
+ projectRoot = process.cwd(),
51
+ appDirectory = '.',
52
+ currentCatalog,
53
+ generate,
54
+ reachable = (sourceRevision, signal) => reachableFromRefs(path.resolve(projectRoot), sourceRevision, signal),
55
+ archive = (sourceRevision, signal, operation) => withSourceArchive({ projectRoot, appDirectory, sourceRevision, signal }, operation),
56
+ maxEntries = 4,
57
+ maxAgeMs = 30 * 60_000,
58
+ } = {}) {
59
+ if (typeof currentCatalog !== 'function' || typeof generate !== 'function'
60
+ || typeof reachable !== 'function' || typeof archive !== 'function') {
61
+ throw new TypeError('A catalog resolver requires currentCatalog and generate callbacks.');
62
+ }
63
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 0 || !Number.isFinite(maxAgeMs) || maxAgeMs < 0) {
64
+ throw new TypeError('Catalog cache limits must be finite non-negative values.');
65
+ }
66
+ const entries = new Map();
67
+ const pending = new Map();
68
+ const controllers = new Set();
69
+ let queue = Promise.resolve();
70
+ let disposed = false;
71
+ const check = signal => {
72
+ signal?.throwIfAborted();
73
+ if (disposed) throw new Error('The revision catalog resolver has been disposed.');
74
+ };
75
+ const prune = () => {
76
+ const now = Date.now();
77
+ for (const [key, entry] of entries) {
78
+ if (now - entry.createdAt >= maxAgeMs) entries.delete(key);
79
+ }
80
+ };
81
+ const remember = (sourceRevision, catalog) => {
82
+ prune();
83
+ if (maxEntries === 0 || maxAgeMs === 0) return;
84
+ entries.delete(sourceRevision);
85
+ entries.set(sourceRevision, { catalog, createdAt: Date.now() });
86
+ while (entries.size > maxEntries) entries.delete(entries.keys().next().value);
87
+ };
88
+ const cached = sourceRevision => {
89
+ prune();
90
+ const entry = entries.get(sourceRevision);
91
+ if (!entry) return undefined;
92
+ entries.delete(sourceRevision); entries.set(sourceRevision, entry);
93
+ return entry.catalog;
94
+ };
95
+ const current = async signal => {
96
+ check(signal);
97
+ const catalog = await currentCatalog(signal);
98
+ check(signal);
99
+ return capture(catalog, revision(catalog?.sourceRevision));
100
+ };
101
+ const build = sourceRevision => {
102
+ const controller = new AbortController();
103
+ controllers.add(controller);
104
+ const work = (async () => {
105
+ // Archive extraction and compilation share one bounded execution lane.
106
+ await queue;
107
+ controller.signal.throwIfAborted();
108
+ if (!(await reachable(sourceRevision, controller.signal))) {
109
+ throw new Error(`The source revision is not reachable in this repository: ${sourceRevision.slice(0, 8)}`);
110
+ }
111
+ controller.signal.throwIfAborted();
112
+ const catalog = await archive(sourceRevision, controller.signal,
113
+ ({ appRoot, signal }) => generate({ archiveRoot: appRoot, sourceRevision, signal }));
114
+ controller.signal.throwIfAborted();
115
+ const portable = capture(catalog, sourceRevision);
116
+ if (!disposed) remember(sourceRevision, portable);
117
+ return portable;
118
+ })();
119
+ queue = work.catch(() => {});
120
+ const cleanup = () => {
121
+ controllers.delete(controller);
122
+ if (pending.get(sourceRevision) === work) pending.delete(sourceRevision);
123
+ };
124
+ void work.then(cleanup, cleanup);
125
+ return work;
126
+ };
127
+ const awaitShared = (work, signal) => {
128
+ if (!signal) return work;
129
+ return new Promise((resolve, reject) => {
130
+ const abort = () => reject(signal.reason ?? new DOMException('Catalog request cancelled.', 'AbortError'));
131
+ if (signal.aborted) { abort(); return; }
132
+ signal.addEventListener('abort', abort, { once: true });
133
+ void work.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
134
+ });
135
+ };
136
+ return Object.freeze({
137
+ async resolve(sourceRevision, signal) {
138
+ check(signal);
139
+ const normalized = revision(sourceRevision);
140
+ const own = await current(signal);
141
+ if (own.sourceRevision === normalized) return own;
142
+ const hit = cached(normalized);
143
+ if (hit) return hit;
144
+ const work = pending.get(normalized) ?? build(normalized);
145
+ pending.set(normalized, work);
146
+ const result = await awaitShared(work, signal);
147
+ check(signal);
148
+ return result;
149
+ },
150
+ async currentRevision(signal) { return (await current(signal)).sourceRevision; },
151
+ retained() { prune(); return [...entries.keys()]; },
152
+ dispose() {
153
+ if (disposed) return;
154
+ disposed = true;
155
+ for (const controller of controllers) controller.abort(new Error('The revision catalog resolver has been disposed.'));
156
+ entries.clear(); pending.clear();
157
+ },
158
+ });
159
+ }
@@ -0,0 +1,63 @@
1
+ import { lstat, readFile, realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ function localPath(value) {
5
+ return typeof value === 'string' && value.length > 0 && !path.isAbsolute(value)
6
+ && !/[\\\x00-\x1f\x7f]/.test(value)
7
+ && !/^[a-z]:/i.test(value) && value.split('/').every(part => part && part !== '.' && part !== '..');
8
+ }
9
+
10
+ async function directory(root) {
11
+ return (await lstat(root).catch(error => {
12
+ if (error.code === 'ENOENT') return undefined;
13
+ throw error;
14
+ }))?.isDirectory() ?? false;
15
+ }
16
+
17
+ /** Select an existing install only when its lockfile is byte-identical to the archive. */
18
+ export async function resolveSourceArchiveDependencies({ archiveRoot, dependencyRoots, lockfile = 'package-lock.json' }) {
19
+ if (!localPath(lockfile) || !Array.isArray(dependencyRoots)) {
20
+ throw new TypeError('Archive dependencies require a relative lockfile and candidate root list.');
21
+ }
22
+ if (await directory(path.join(archiveRoot, 'node_modules'))) return [];
23
+ const lock = await readFile(path.join(archiveRoot, lockfile)).catch(error => {
24
+ if (error.code === 'ENOENT') throw new Error(`The source archive is missing ${lockfile}.`, { cause: error });
25
+ throw error;
26
+ });
27
+ const rejected = [];
28
+ for (const candidate of dependencyRoots) {
29
+ const root = path.resolve(candidate);
30
+ const modules = path.join(root, 'node_modules');
31
+ if (!(await directory(modules))) { rejected.push(`${root}: missing node_modules`); continue; }
32
+ const candidateLock = await readFile(path.join(root, lockfile)).catch(error => {
33
+ if (error.code === 'ENOENT') return undefined;
34
+ throw error;
35
+ });
36
+ if (!candidateLock?.equals(lock)) { rejected.push(`${root}: ${lockfile} differs`); continue; }
37
+ return [modules];
38
+ }
39
+ throw new Error(`No dependency install matches the source archive lockfile.${rejected.length ? ` (${rejected.join('; ')})` : ''}`);
40
+ }
41
+
42
+ /** Check the isolation boundary before a trusted generator writes into extracted source. */
43
+ export async function assertIsolatedSourceRoot({ archiveRoot, hostRoot, requiredDirectories = [] }) {
44
+ if (!Array.isArray(requiredDirectories) || !requiredDirectories.every(localPath)) {
45
+ throw new TypeError('Required source directories must be relative paths.');
46
+ }
47
+ const root = await realpath(archiveRoot);
48
+ const host = await realpath(hostRoot);
49
+ const relative = path.relative(host, root);
50
+ if (relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) {
51
+ throw new Error('Source generation requires an isolated archive outside the host working tree.');
52
+ }
53
+ const metadata = await lstat(path.join(root, '.git')).catch(error => {
54
+ if (error.code === 'ENOENT') return undefined;
55
+ throw error;
56
+ });
57
+ if (metadata) throw new Error('The isolated source archive must not contain Git working-tree metadata.');
58
+ if (!(await directory(root))) throw new Error('The source archive must be a directory.');
59
+ for (const name of requiredDirectories) {
60
+ if (!(await directory(path.join(root, name)))) throw new Error(`The source archive is missing a regular directory: ${name}`);
61
+ }
62
+ return root;
63
+ }
@@ -0,0 +1,88 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fsp from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+
7
+ const executeFile = promisify(execFile);
8
+ const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
9
+ async function execute(file, args, options) {
10
+ options.signal?.throwIfAborted();
11
+ const work = executeFile(file, args, options);
12
+ const child = work.child;
13
+ const closed = child ? new Promise(resolve => child.once('close', resolve)) : Promise.resolve();
14
+ try { return await work; }
15
+ finally {
16
+ // AbortSignal rejects execFile before the child has necessarily stopped writing.
17
+ const force = child && child.exitCode === null && child.signalCode === null
18
+ ? setTimeout(() => child.kill('SIGKILL'), 1000) : undefined;
19
+ try { await closed; } finally { clearTimeout(force); }
20
+ }
21
+ }
22
+ function relative(value) {
23
+ return typeof value === 'string' && value.length > 0 && !/[\\\x00-\x1f\x7f]/.test(value)
24
+ && !path.posix.isAbsolute(value) && !/^[a-z]:/i.test(value)
25
+ && !value.split('/').some(part => !part || part === '.' || part === '..' || part === '.git');
26
+ }
27
+
28
+ async function git(projectRoot, args, signal, encoding = 'utf8') {
29
+ const { stdout } = await execute('git', ['-C', projectRoot, ...args], { encoding, maxBuffer: MAX_ARCHIVE_BYTES, signal });
30
+ return stdout;
31
+ }
32
+
33
+ export async function materializeSourceArchive(projectRoot, appDirectory, revision, temporary, signal) {
34
+ signal.throwIfAborted();
35
+ if (appDirectory !== '.' && !relative(appDirectory)) throw new Error('The application directory must be project-relative.');
36
+ const top = await git(projectRoot, ['rev-parse', '--show-toplevel'], signal);
37
+ if (await fsp.realpath(top.toString().trim()) !== await fsp.realpath(projectRoot)) throw new Error('The preview project root must be the Git repository root.');
38
+ const commit = (await git(projectRoot, ['rev-parse', '--verify', `${revision}^{commit}`], signal)).toString().trim();
39
+ if (commit !== revision) throw new Error('The preview requires the exact complete source commit.');
40
+ const selected = appDirectory === '.' ? [] : [appDirectory];
41
+ const tree = await git(projectRoot, ['ls-tree', '-r', '-z', revision, '--', ...selected], signal);
42
+ const entries = tree.toString().split('\0').filter(Boolean);
43
+ if (!entries.length) throw new Error('The selected application is absent from the source commit.');
44
+ for (const entry of entries) {
45
+ const separator = entry.indexOf('\t');
46
+ const metadata = entry.slice(0, separator).split(' ');
47
+ const file = entry.slice(separator + 1);
48
+ if (separator < 0 || !['100644', '100755'].includes(metadata[0]) || metadata[1] !== 'blob' || !relative(file)) throw new Error('Archived source must contain regular files only; links and submodules are unsupported.');
49
+ }
50
+ const checkout = path.join(temporary, 'source');
51
+ const archive = path.join(temporary, 'source.tar');
52
+ await fsp.mkdir(checkout);
53
+ const bytes = await git(projectRoot, ['archive', '--format=tar', '--', revision, ...selected], signal, 'buffer');
54
+ signal.throwIfAborted();
55
+ await fsp.writeFile(archive, bytes, { signal });
56
+ await execute('tar', ['-xf', archive, '-C', checkout, '--no-same-owner', '--no-same-permissions'], { signal, maxBuffer: 1024 * 1024 });
57
+ signal.throwIfAborted();
58
+ const appRoot = path.join(checkout, appDirectory);
59
+ if (!(await fsp.lstat(appRoot)).isDirectory()) throw new Error('The archived application must be a directory.');
60
+ signal.throwIfAborted();
61
+ return appRoot;
62
+ }
63
+
64
+ /** The trusted callback must stop its workers and settle before cleanup can remove its archive. */
65
+ export async function withSourceArchive(options, operation) {
66
+ const { projectRoot, sourceRevision, appDirectory = '.', signal = new AbortController().signal } = options;
67
+ if (typeof projectRoot !== 'string' || !projectRoot.trim() || typeof operation !== 'function' || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(sourceRevision ?? '')) throw new Error('A source archive requires a project root, exact commit, and trusted operation.');
68
+ const root = path.resolve(projectRoot);
69
+ signal.throwIfAborted();
70
+ const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'pygmalion-source-archive-'));
71
+ let result; let failure; let failed = false;
72
+ try {
73
+ const appRoot = await materializeSourceArchive(root, appDirectory, sourceRevision, temporary, signal);
74
+ signal.throwIfAborted();
75
+ result = await operation(Object.freeze({ appRoot, sourceRevision, signal }));
76
+ signal.throwIfAborted();
77
+ } catch (error) { failure = error; failed = true; }
78
+ let cleanupFailure; let cleanupFailed = false;
79
+ try { await fsp.rm(temporary, { recursive: true, force: true }); }
80
+ catch (error) { cleanupFailure = error; cleanupFailed = true; }
81
+ if (cleanupFailed) {
82
+ if (failed) throw new AggregateError([failure, cleanupFailure], 'Source archive operation and cleanup both failed.', { cause: failure });
83
+ throw cleanupFailure;
84
+ }
85
+ if (failed) throw failure;
86
+ signal.throwIfAborted();
87
+ return result;
88
+ }
@@ -0,0 +1,65 @@
1
+ import { createHash } from 'node:crypto';
2
+ import ts from 'typescript';
3
+
4
+ function relativeFile(value) {
5
+ return typeof value === 'string' && value.length > 0 && !value.startsWith('/')
6
+ && !/^[a-z]:/i.test(value) && !/[\\\0?#]/.test(value)
7
+ && !value.split('/').some((part) => !part || part === '.' || part === '..');
8
+ }
9
+
10
+ /**
11
+ * Verify source evidence before a host reviews its structured binding plan.
12
+ * This is not an apply endpoint. It never writes files or converts expressions
13
+ * into the literal-prop inspection protocol.
14
+ */
15
+ export function validateSourceBindingProofs(plan, options) {
16
+ const diagnostics = [];
17
+ const fail = (code, message, file) => diagnostics.push({ severity: 'error', code, message, ...(file ? { file } : {}) });
18
+ if (!plan || plan.schema !== 'pygmalion.binding-source-plan' || plan.version !== 1
19
+ || !plan.catalog || plan.catalog.sourceRevision !== options.sourceRevision
20
+ || !Array.isArray(plan.bindings) || !Array.isArray(plan.repeats)) {
21
+ fail('binding-source-plan-invalid', 'A versioned plan must match the source revision being reviewed.');
22
+ return { valid: false, diagnostics };
23
+ }
24
+ const allowedFiles = new Set(options.allowedFiles ?? []);
25
+ const excluded = options.excludedSourcePrefixes ?? [];
26
+ if ([...allowedFiles, ...excluded].some((file) => !relativeFile(file))) {
27
+ fail('binding-source-policy-invalid', 'Source policies require normalized relative paths.');
28
+ return { valid: false, diagnostics };
29
+ }
30
+ if (!plan.bindings.length && !plan.repeats.length) fail('binding-source-empty', 'The plan has no source targets.');
31
+ const ranges = new Map();
32
+ for (const entry of [...plan.bindings, ...plan.repeats]) {
33
+ const proof = entry?.resolution?.proof;
34
+ if (!proof || !relativeFile(proof.file) || proof.sourceRevision !== options.sourceRevision
35
+ || typeof proof.fileHash !== 'string' || !/^[a-f0-9]{64}$/.test(proof.fileHash)
36
+ || !Number.isSafeInteger(proof.start) || !Number.isSafeInteger(proof.end)
37
+ || proof.start < 0 || proof.end <= proof.start || typeof proof.expected !== 'string'
38
+ || proof.expected.length !== proof.end - proof.start) {
39
+ fail('binding-source-proof-invalid', 'Each source target requires an exact revision, file hash, and expression range.'); continue;
40
+ }
41
+ const file = proof.file;
42
+ if (!allowedFiles.has(file) || excluded.some((prefix) => file === prefix || file.startsWith(`${prefix}/`))) {
43
+ fail('binding-source-file-excluded', 'The host has not allowed this source file for design changes.', file); continue;
44
+ }
45
+ const source = options.files instanceof Map ? options.files.get(file) : options.files?.[file];
46
+ if (typeof source !== 'string') { fail('binding-source-file-missing', 'The source file is unavailable.', file); continue; }
47
+ if (createHash('sha256').update(source, 'utf8').digest('hex') !== proof.fileHash
48
+ || source.slice(proof.start, proof.end) !== proof.expected || proof.end > source.length) {
49
+ fail('binding-source-stale', 'The source file or expression has changed since the proof was created.', file); continue;
50
+ }
51
+ const fileRanges = ranges.get(file) ?? [];
52
+ if (fileRanges.some((range) => range.start < proof.end && proof.start < range.end)) fail('binding-source-overlap', 'Source targets must have disjoint exact ranges.', file);
53
+ fileRanges.push(proof);
54
+ ranges.set(file, fileRanges);
55
+ const parsed = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
56
+ let expressionFound = false;
57
+ function visit(node) {
58
+ if (node.getStart(parsed) === proof.start && node.end === proof.end && ts.isExpression(node)) expressionFound = true;
59
+ ts.forEachChild(node, visit);
60
+ }
61
+ visit(parsed);
62
+ if (parsed.parseDiagnostics.length || !expressionFound) fail('binding-source-anchor-invalid', 'The source proof must point to a complete expression in valid source.', file);
63
+ }
64
+ return { valid: diagnostics.length === 0, diagnostics };
65
+ }
@@ -0,0 +1,15 @@
1
+ import fsp from 'node:fs/promises';
2
+
3
+ const pending = new Map();
4
+
5
+ /** Serialize source reads and writes within one process and canonical application root. */
6
+ export async function withSourceFileLock(root, operation) {
7
+ const key = await fsp.realpath(root);
8
+ const previous = pending.get(key) ?? Promise.resolve();
9
+ let release = () => {};
10
+ const next = new Promise(resolve => { release = () => resolve(undefined); });
11
+ pending.set(key, next);
12
+ await previous;
13
+ try { return await operation(key); }
14
+ finally { release(); if (pending.get(key) === next) pending.delete(key); }
15
+ }
@@ -0,0 +1,162 @@
1
+ export { withSourceArchive } from './source-archive.mjs';
2
+ export { rewriteTokenSourceReferences } from './token-source-references.mjs';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import fsp from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { createUnifiedDiff } from './source-diff.mjs';
7
+ import { withSourceFileLock } from './source-file-lock.mjs';
8
+ import { workspaceSourceFingerprint } from './workspace-source-identity.mjs';
9
+
10
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex');
11
+ const isHash = value => value === null || typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
12
+ function sourcePath(file) {
13
+ if (typeof file !== 'string' || !file || /[\\\x00-\x1f\x7f?#]/.test(file) || path.posix.isAbsolute(file)
14
+ || /^[a-z]:/i.test(file) || file.split('/').some(part => !part || ['.', '..', '.git'].includes(part))) {
15
+ throw new Error('A source file plan requires safe project-relative paths.');
16
+ }
17
+ return file;
18
+ }
19
+
20
+ export function captureSourceFilePlan(input, options) {
21
+ const plan = JSON.parse(workspaceSourceFingerprint(input));
22
+ if (plan?.version !== 1 || typeof plan.sourceRevision !== 'string' || !plan.sourceRevision.trim()
23
+ || !Array.isArray(plan.proofs) || !plan.proofs.length || plan.proofs.length > 3000
24
+ || !Array.isArray(plan.changes) || !plan.changes.length || plan.changes.length > 1000) {
25
+ throw new Error('A source file plan requires a revision, complete proofs, and bounded changes.');
26
+ }
27
+ if (options.sourceRevision !== plan.sourceRevision) throw new Error('The source file plan does not match the active source revision.');
28
+ const allowed = new Set((options.allowedFiles ?? []).map(sourcePath));
29
+ const excluded = (options.excludedSourcePrefixes ?? []).map(prefix => sourcePath(prefix.replace(/\/+$/, '')));
30
+ const assertNotExcluded = file => {
31
+ if (excluded.some(prefix => file === prefix || file.startsWith(`${prefix}/`))) throw new Error(`Excluded source files cannot enter a source file plan: ${file}`);
32
+ };
33
+ const proofs = new Map();
34
+ for (const proof of plan.proofs) {
35
+ sourcePath(proof?.file);
36
+ assertNotExcluded(proof.file);
37
+ if (!isHash(proof.sha256) || proofs.has(proof.file)) throw new Error('Source proofs must contain unique files and exact hashes.');
38
+ proofs.set(proof.file, proof.sha256);
39
+ }
40
+ let total = 0;
41
+ const changed = new Set();
42
+ for (const change of plan.changes) {
43
+ sourcePath(change?.file);
44
+ if (!allowed.has(change.file)) throw new Error(`The host has not permitted this source file: ${change.file}`);
45
+ if (changed.has(change.file) || !proofs.has(change.file) || !isHash(change.beforeSha256) || proofs.get(change.file) !== change.beforeSha256
46
+ || typeof change.content !== 'string' || change.content.includes('\0')
47
+ || Buffer.from(change.content, 'utf8').toString('utf8') !== change.content) throw new Error('Each source change must have one matching proof and UTF-8 text content.');
48
+ changed.add(change.file); total += Buffer.byteLength(change.content);
49
+ }
50
+ if (total > 32 * 1024 * 1024) throw new Error('The source file plan exceeds its text resource limit.');
51
+ return plan;
52
+ }
53
+
54
+ /** Refuse symbolic links at every component, including links that stay inside the root. */
55
+ async function inspectFile(root, file) {
56
+ const parts = file.split('/');
57
+ let current = root;
58
+ for (let index = 0; index < parts.length; index++) {
59
+ current = path.join(current, parts[index]);
60
+ let stat;
61
+ try { stat = await fsp.lstat(current); }
62
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
63
+ if (stat.isSymbolicLink()) throw new Error(`Source file plans cannot follow symbolic links: ${file}`);
64
+ if (index < parts.length - 1) {
65
+ if (!stat.isDirectory()) throw new Error(`A source parent is not a directory: ${file}`);
66
+ } else {
67
+ if (!stat.isFile()) throw new Error(`A source proof must identify a regular file: ${file}`);
68
+ const bytes = await fsp.readFile(current);
69
+ if (!Buffer.from(bytes.toString('utf8')).equals(bytes)) throw new Error(`Source file plans support UTF-8 text only: ${file}`);
70
+ return { bytes, sha256: hash(bytes), mode: stat.mode & 0o777 };
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ async function verify(root, plan) {
77
+ const files = new Map();
78
+ for (const proof of plan.proofs) {
79
+ const actual = await inspectFile(root, proof.file);
80
+ if ((actual?.sha256 ?? null) !== proof.sha256) throw new Error(`Source changed since review: ${proof.file}`);
81
+ files.set(proof.file, actual);
82
+ }
83
+ return files;
84
+ }
85
+
86
+ async function validated(root, input, options, operation) {
87
+ // Capture before waiting for another source operation; later caller mutations cannot change the plan.
88
+ const plan = captureSourceFilePlan(input, options);
89
+ const signal = options.signal;
90
+ return withSourceFileLock(root, async base => {
91
+ signal?.throwIfAborted();
92
+ const before = await verify(base, plan);
93
+ signal?.throwIfAborted();
94
+ const effective = plan.changes.filter(change => (before.get(change.file)?.bytes.toString('utf8') ?? '') !== change.content);
95
+ if (effective.length !== plan.changes.length) throw new Error('Every declared source change must produce a nonempty diff.');
96
+ return operation(base, plan, before, signal);
97
+ });
98
+ }
99
+
100
+ /** Validate the complete plan and produce diffs without writing any project file. */
101
+ export async function previewSourceFilePlan(root, input, options) {
102
+ return validated(root, input, options, async (_base, plan, before) => ({
103
+ files: plan.changes.map(change => ({ file: change.file, diff: createUnifiedDiff(before.get(change.file)?.bytes.toString('utf8') ?? '', change.content, change.file) })),
104
+ affectedFiles: plan.changes.map(change => change.file).sort(),
105
+ }));
106
+ }
107
+
108
+ /** Stage all text, recheck every proof, and restore owned writes if publication fails. */
109
+ export async function applySourceFilePlan(root, input, options) {
110
+ return validated(root, input, options, async (base, plan, before, signal) => {
111
+ const staged = new Map(); const published = []; const createdDirectories = [];
112
+ let result; let failure;
113
+ try {
114
+ for (const change of plan.changes) {
115
+ signal?.throwIfAborted();
116
+ const parent = path.dirname(path.join(base, change.file));
117
+ const missing = [];
118
+ for (let cursor = parent; cursor !== base; cursor = path.dirname(cursor)) {
119
+ try { await fsp.lstat(cursor); break; } catch (error) { if (error.code !== 'ENOENT') throw error; missing.push(cursor); }
120
+ }
121
+ for (const directory of missing.reverse()) { await fsp.mkdir(directory); createdDirectories.push(directory); }
122
+ await inspectFile(base, change.file);
123
+ const temporary = path.join(parent, `.pygmalion-source-${randomUUID()}.tmp`);
124
+ staged.set(change.file, temporary);
125
+ await fsp.writeFile(temporary, change.content, { flag: 'wx', mode: before.get(change.file)?.mode ?? 0o644 });
126
+ }
127
+ await verify(base, plan);
128
+ signal?.throwIfAborted();
129
+ for (const change of plan.changes) {
130
+ signal?.throwIfAborted();
131
+ const current = await inspectFile(base, change.file);
132
+ if ((current?.sha256 ?? null) !== change.beforeSha256) throw new Error(`Source changed during apply: ${change.file}`);
133
+ await fsp.rename(staged.get(change.file), path.join(base, change.file));
134
+ published.push(change); staged.delete(change.file);
135
+ }
136
+ signal?.throwIfAborted();
137
+ result = { applied: plan.changes.length, changed: plan.changes.length, files: plan.changes.map(change => change.file).sort() };
138
+ } catch (error) {
139
+ const failures = [];
140
+ for (const change of published.reverse()) {
141
+ try {
142
+ const current = await inspectFile(base, change.file);
143
+ if (current?.sha256 !== hash(Buffer.from(change.content))) throw new Error(`Concurrent source edit was preserved during rollback: ${change.file}`, { cause: error });
144
+ const original = before.get(change.file);
145
+ if (original) await fsp.writeFile(path.join(base, change.file), original.bytes, { mode: original.mode });
146
+ else await fsp.unlink(path.join(base, change.file));
147
+ } catch (rollback) { failures.push(rollback); }
148
+ }
149
+ failure = failures.length
150
+ ? new AggregateError([error, ...failures], 'Source publication failed and concurrent edits prevented complete rollback.', { cause: error })
151
+ : error;
152
+ }
153
+ const cleanup = await Promise.allSettled([...staged.values()].map(file => fsp.rm(file, { force: true })));
154
+ const cleanupErrors = cleanup.filter(item => item.status === 'rejected').map(item => item.reason);
155
+ for (const directory of createdDirectories.reverse()) {
156
+ try { await fsp.rmdir(directory); } catch (error) { if (!['ENOTEMPTY', 'ENOENT'].includes(error.code)) cleanupErrors.push(error); }
157
+ }
158
+ if (cleanupErrors.length) throw new AggregateError(failure ? [failure, ...cleanupErrors] : cleanupErrors, 'Source operation cleanup was incomplete.', { cause: failure ?? cleanupErrors[0] });
159
+ if (failure) throw failure;
160
+ return result;
161
+ });
162
+ }
@@ -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 { createRequire } from 'node:module';
2
+ import { dirname, isAbsolute } from 'node:path';
3
+
4
+ /** Evaluates trusted declaration code after the supplied bundler produces one in-memory CommonJS module. */
5
+ export async function evaluateBundledSourceModule({ build, buildOptions, filename, require: requireModule }) {
6
+ if (typeof build !== 'function') throw new TypeError('A source module bundler is required.');
7
+ if (typeof filename !== 'string' || !isAbsolute(filename)) throw new TypeError('An absolute source module filename is required.');
8
+ if (requireModule !== undefined && typeof requireModule !== 'function') throw new TypeError('require must be a module loader.');
9
+ const result = await build({ ...buildOptions, bundle: true, format: 'cjs', platform: 'node', write: false });
10
+ if (result.outputFiles?.length !== 1 || typeof result.outputFiles[0]?.text !== 'string') {
11
+ throw new Error('The source module bundler must produce exactly one in-memory output.');
12
+ }
13
+ const module = { exports: {} };
14
+ const evaluate = new Function('module', 'exports', 'require', '__filename', '__dirname', result.outputFiles[0].text);
15
+ evaluate(module, module.exports, requireModule ?? createRequire(filename), filename, dirname(filename));
16
+ return module.exports;
17
+ }
@@ -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'];