@pygmalionjs/pygmalion 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/binding-source.d.ts +25 -0
  2. package/dist-lib/{CameraLayer-B2D29VXQ.js → CameraLayer-BdOko8XE.js} +17 -17
  3. package/dist-lib/pygmalion.js +24599 -12746
  4. package/dist-lib/{runtime-BAy8W1mJ.js → runtime-Dxb-IaLN.js} +12414 -11114
  5. package/dist-lib/style.css +1 -1
  6. package/dist-lib/testing.js +10 -10
  7. package/dist-lib/types/binding/catalog.d.ts +10 -0
  8. package/dist-lib/types/binding/contracts.d.ts +202 -0
  9. package/dist-lib/types/binding/documentCommands.d.ts +38 -0
  10. package/dist-lib/types/binding/index.d.ts +6 -0
  11. package/dist-lib/types/binding/preview.d.ts +37 -0
  12. package/dist-lib/types/binding/source.d.ts +109 -0
  13. package/dist-lib/types/binding/useDataProjection.d.ts +9 -0
  14. package/dist-lib/types/binding/validate.d.ts +8 -0
  15. package/dist-lib/types/core/runtime.d.ts +6 -0
  16. package/dist-lib/types/document/appearance.d.ts +112 -0
  17. package/dist-lib/types/document/assets.d.ts +204 -0
  18. package/dist-lib/types/document/components.d.ts +169 -0
  19. package/dist-lib/types/document/contracts.d.ts +171 -0
  20. package/dist-lib/types/document/engine.d.ts +20 -0
  21. package/dist-lib/types/document/exportSettings.d.ts +45 -0
  22. package/dist-lib/types/document/geometry.d.ts +11 -0
  23. package/dist-lib/types/document/layout.d.ts +53 -0
  24. package/dist-lib/types/document/prototype.d.ts +424 -0
  25. package/dist-lib/types/document/prototypeReferences.d.ts +3 -0
  26. package/dist-lib/types/document/richText.d.ts +185 -0
  27. package/dist-lib/types/document/styles.d.ts +216 -0
  28. package/dist-lib/types/document/svgImport.d.ts +17 -0
  29. package/dist-lib/types/document/vector.d.ts +222 -0
  30. package/dist-lib/types/editor/designCompiler.d.ts +7 -0
  31. package/dist-lib/types/editor/designImport.d.ts +7 -0
  32. package/dist-lib/types/editor/inspect.d.ts +8 -0
  33. package/dist-lib/types/editor/revisionCatalogInstall.d.ts +1 -1
  34. package/dist-lib/types/editor/screenInteractions.d.ts +3 -3
  35. package/dist-lib/types/editor/store.d.ts +1 -1
  36. package/dist-lib/types/host/project.d.ts +44 -0
  37. package/dist-lib/types/host/registryConnections.d.ts +11 -0
  38. package/dist-lib/types/host/sourceFilePlan.d.ts +15 -0
  39. package/dist-lib/types/host/sourceWriteback.d.ts +50 -0
  40. package/dist-lib/types/lib.d.ts +101 -3
  41. package/dist-lib/types/token-library/bindings.d.ts +62 -0
  42. package/dist-lib/types/token-library/contracts.d.ts +117 -0
  43. package/dist-lib/types/token-library/engine.d.ts +13 -0
  44. package/dist-lib/types/workspace/WorkspaceShell.d.ts +9 -0
  45. package/dist-lib/types/workspace/application/ApplicationWorkspace.d.ts +3 -0
  46. package/dist-lib/types/workspace/configuration.d.ts +39 -0
  47. package/dist-lib/types/workspace/contracts.d.ts +64 -0
  48. package/dist-lib/types/workspace/controller.d.ts +35 -0
  49. package/dist-lib/types/workspace/documentSessions.d.ts +42 -0
  50. package/dist-lib/types/workspace/edit/AppearancePanel.d.ts +23 -0
  51. package/dist-lib/types/workspace/edit/ArrangeLayers.d.ts +10 -0
  52. package/dist-lib/types/workspace/edit/AssetsPanel.d.ts +16 -0
  53. package/dist-lib/types/workspace/edit/CanvasGuides.d.ts +19 -0
  54. package/dist-lib/types/workspace/edit/ComponentPanel.d.ts +11 -0
  55. package/dist-lib/types/workspace/edit/DataPanel.d.ts +16 -0
  56. package/dist-lib/types/workspace/edit/DesignWorkspace.d.ts +10 -0
  57. package/dist-lib/types/workspace/edit/LayerTree.d.ts +14 -0
  58. package/dist-lib/types/workspace/edit/LayoutPanel.d.ts +13 -0
  59. package/dist-lib/types/workspace/edit/PrototypePanel.d.ts +12 -0
  60. package/dist-lib/types/workspace/edit/ScopeTransferDialog.d.ts +40 -0
  61. package/dist-lib/types/workspace/edit/SelectionHandles.d.ts +52 -0
  62. package/dist-lib/types/workspace/edit/StylesPanel.d.ts +8 -0
  63. package/dist-lib/types/workspace/edit/TextEditor.d.ts +50 -0
  64. package/dist-lib/types/workspace/edit/VectorEditor.d.ts +89 -0
  65. package/dist-lib/types/workspace/edit/VectorToolbar.d.ts +5 -0
  66. package/dist-lib/types/workspace/edit/canvasGuideModel.d.ts +102 -0
  67. package/dist-lib/types/workspace/edit/clipboard.d.ts +35 -0
  68. package/dist-lib/types/workspace/edit/duplicateMetadata.d.ts +2 -0
  69. package/dist-lib/types/workspace/edit/geometryHandles.d.ts +224 -0
  70. package/dist-lib/types/workspace/edit/layerArrangement.d.ts +39 -0
  71. package/dist-lib/types/workspace/edit/layerMoves.d.ts +44 -0
  72. package/dist-lib/types/workspace/edit/projection.d.ts +12 -0
  73. package/dist-lib/types/workspace/edit/scopeTransfer.d.ts +96 -0
  74. package/dist-lib/types/workspace/edit/textStyles.d.ts +75 -0
  75. package/dist-lib/types/workspace/edit/vectorTools.d.ts +136 -0
  76. package/dist-lib/types/workspace/legacyModeAdapter.d.ts +13 -0
  77. package/dist-lib/types/workspace/source/ResponsiveLayoutPanel.d.ts +20 -0
  78. package/dist-lib/types/workspace/source/SourceReview.d.ts +11 -0
  79. package/dist-lib/types/workspace/source/appearanceSource.d.ts +19 -0
  80. package/dist-lib/types/workspace/source/compiler.d.ts +5 -0
  81. package/dist-lib/types/workspace/source/contracts.d.ts +156 -0
  82. package/dist-lib/types/workspace/source/controller.d.ts +50 -0
  83. package/dist-lib/types/workspace/source/documentRevision.d.ts +2 -0
  84. package/dist-lib/types/workspace/source/draftPreviewClient.d.ts +13 -0
  85. package/dist-lib/types/workspace/source/draftRecipe.d.ts +30 -0
  86. package/dist-lib/types/workspace/source/flowContracts.d.ts +72 -0
  87. package/dist-lib/types/workspace/source/prototypeSource.d.ts +4 -0
  88. package/dist-lib/types/workspace/source/responsive.d.ts +13 -0
  89. package/dist-lib/types/workspace/source/responsiveContracts.d.ts +78 -0
  90. package/dist-lib/types/workspace/source/structure.d.ts +21 -0
  91. package/dist-lib/types/workspace/source/textSource.d.ts +28 -0
  92. package/dist-lib/types/workspace/tokens/TokenLibraryContext.d.ts +11 -0
  93. package/dist-lib/types/workspace/tokens/TokenLibraryWorkspace.d.ts +14 -0
  94. package/dist-lib/types/workspace/tokens/contracts.d.ts +74 -0
  95. package/dist-lib/types/workspace/tokens/controller.d.ts +44 -0
  96. package/dist-lib/types/workspace/view/PrototypePlayer.d.ts +17 -0
  97. package/dist-lib/types/workspace/view/StoryboardWorkspace.d.ts +3 -0
  98. package/dist-lib/types/workspace/view/prototypePlayback.d.ts +46 -0
  99. package/draft-preview.d.ts +54 -0
  100. package/inspect.d.ts +2 -0
  101. package/node/design-session.mjs +3 -0
  102. package/node/inspect-plugin.mjs +3 -1
  103. package/node/inspect-writeback.mjs +18 -2
  104. package/node/preview-artifact-store.mjs +10 -7
  105. package/node/source-archive.mjs +88 -0
  106. package/node/source-bindings.mjs +65 -0
  107. package/node/source-file-lock.mjs +15 -0
  108. package/node/source-file-plan.mjs +162 -0
  109. package/node/source-file-proofs.mjs +44 -0
  110. package/node/source-responsive-codec.d.mts +17 -0
  111. package/node/source-responsive-codec.mjs +133 -0
  112. package/node/source-responsive.mjs +189 -0
  113. package/node/source-structure-reconcile.mjs +186 -0
  114. package/node/source-structure-wrapper-codec.d.mts +20 -0
  115. package/node/source-structure-wrapper-codec.mjs +205 -0
  116. package/node/source-structure.mjs +404 -0
  117. package/node/token-source-references.mjs +136 -0
  118. package/node/workspace-draft-preview-plugin.mjs +70 -0
  119. package/node/workspace-draft-preview.mjs +231 -0
  120. package/node/workspace-source-identity.d.mts +1 -0
  121. package/node/workspace-source-identity.mjs +21 -0
  122. package/node/workspace-source-plan.mjs +195 -0
  123. package/package.json +52 -6
  124. package/source-files.d.ts +35 -0
  125. package/source-responsive.d.ts +10 -0
  126. package/source-structure.d.ts +155 -0
  127. package/workspace-source-plan.d.ts +44 -0
  128. package/docs/coverage-contracts.md +0 -444
  129. package/docs/screen-state-contract.md +0 -252
@@ -0,0 +1,44 @@
1
+ import type { TokenLibraryEngine } from '../../token-library/contracts.js';
2
+ import type { InspectApplyResult, InspectPreviewResult } from '../../editor/host.js';
3
+ import type { WorkspaceTarget } from '../contracts.js';
4
+ import type { WorkspaceTokenLibraryBaseline, WorkspaceTokenLibraryCapabilities, WorkspaceTokenSourceRequest } from './contracts.js';
5
+ export interface WorkspaceTokenLibraryState {
6
+ status: 'idle' | 'loading' | 'ready' | 'reviewing' | 'reviewed' | 'applying' | 'applied' | 'error';
7
+ baseline?: WorkspaceTokenLibraryBaseline;
8
+ engine?: TokenLibraryEngine;
9
+ request?: WorkspaceTokenSourceRequest;
10
+ preview?: InspectPreviewResult;
11
+ result?: InspectApplyResult;
12
+ savedFingerprint?: string;
13
+ saving?: boolean;
14
+ error?: string;
15
+ }
16
+ /** A library owns its draft and review independently from visual screen sessions. */
17
+ export declare class WorkspaceTokenLibraryController {
18
+ private state;
19
+ private listeners;
20
+ private epoch;
21
+ private pending;
22
+ private pendingSave;
23
+ private saveGeneration;
24
+ private disposed;
25
+ private invalidating;
26
+ private applying;
27
+ private unsubscribe;
28
+ private writer;
29
+ readonly capabilities: WorkspaceTokenLibraryCapabilities;
30
+ readonly sourceRevision: string;
31
+ constructor(sourceRevision: string, capabilities: WorkspaceTokenLibraryCapabilities);
32
+ getSnapshot: () => WorkspaceTokenLibraryState;
33
+ subscribe: (listener: () => void) => () => void;
34
+ private update;
35
+ private invalidate;
36
+ private current;
37
+ private subscribeEngine;
38
+ load(): Promise<boolean>;
39
+ saveDraft(): Promise<boolean>;
40
+ review(target: WorkspaceTarget): Promise<boolean>;
41
+ apply(): Promise<boolean>;
42
+ reset(): boolean;
43
+ dispose(): void;
44
+ }
@@ -0,0 +1,17 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { DesignDocument } from '../../document/contracts.js';
3
+ import { type PrototypeState } from '../../document/prototype.js';
4
+ import { type PrototypeClock } from './prototypePlayback.js';
5
+
6
+ export interface PrototypePlayerProps {
7
+ document: DesignDocument;
8
+ /** Uses the same document/component renderer as Edit, with authoring controls disabled. */
9
+ renderScene(rootId: string, state: PrototypeState): ReactNode;
10
+ sceneSize?(rootId: string, state: PrototypeState): {
11
+ width: number;
12
+ height: number;
13
+ } | undefined;
14
+ onClose(): void;
15
+ clock?: PrototypeClock;
16
+ }
17
+ export declare function PrototypePlayer(props: PrototypePlayerProps): import("react").JSX.Element;
@@ -0,0 +1,3 @@
1
+ export declare const StoryboardWorkspace: import("react").FunctionComponent<{
2
+ sourceRevision: string;
3
+ }>;
@@ -0,0 +1,46 @@
1
+ import type { DesignDocument, DocumentNode, ResolvedDocumentNode } from '../../document/contracts.js';
2
+ import { type PrototypeEffect, type PrototypeEvent, type PrototypeStartOptions, type PrototypeState } from '../../document/prototype.js';
3
+ export interface PrototypeClock {
4
+ now(): number;
5
+ schedule(callback: () => void, delayMs: number): unknown;
6
+ cancel(handle: unknown): void;
7
+ }
8
+ export interface PrototypePlayerSnapshot {
9
+ state: PrototypeState;
10
+ previous: PrototypeState | null;
11
+ effects: readonly PrototypeEffect[];
12
+ playing: boolean;
13
+ }
14
+ export interface PrototypePlayerOptions extends PrototypeStartOptions {
15
+ clock: PrototypeClock;
16
+ /** Effects describe intended links and scrolling; the adapter decides how to execute them. */
17
+ onEffect?(effect: PrototypeEffect): void;
18
+ }
19
+ export interface PrototypePlayerController {
20
+ getSnapshot(): PrototypePlayerSnapshot;
21
+ subscribe(listener: () => void): () => void;
22
+ dispatch(event: PrototypeEvent): void;
23
+ pause(): void;
24
+ resume(): void;
25
+ dispose(): void;
26
+ }
27
+ /** One document snapshot and one clock. Playback cannot mutate the document engine or its history. */
28
+ export declare function createPrototypePlayer(document: DesignDocument, options: PrototypePlayerOptions): PrototypePlayerController;
29
+ export interface PrototypePresentationScene {
30
+ id: string;
31
+ rootId: string;
32
+ root: ResolvedDocumentNode;
33
+ x: number;
34
+ y: number;
35
+ overlay: boolean;
36
+ backdrop: {
37
+ color: string;
38
+ opacity: number;
39
+ } | null;
40
+ }
41
+ export declare function prototypeScene(document: DesignDocument, state: PrototypeState): PrototypePresentationScene[];
42
+ export declare function prototypeFlowOptions(document: DesignDocument): {
43
+ id: string;
44
+ label: string;
45
+ node: DocumentNode;
46
+ }[];
@@ -0,0 +1,54 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { WorkspaceSourceRequest } from './dist-lib/types/workspace/source/flowContracts.js';
3
+ import type { WorkspaceTokenSourceRequest } from './dist-lib/types/workspace/tokens/contracts.js';
4
+ import type { WorkspaceApplicationSurface } from './dist-lib/types/workspace/contracts.js';
5
+
6
+ export type WorkspaceDraftSourceRequest = WorkspaceSourceRequest | WorkspaceTokenSourceRequest;
7
+
8
+ export interface WorkspaceDraftPreviewServiceOptions {
9
+ projectRoot: string;
10
+ appDirectory?: string;
11
+ /** Existing node_modules directory reused by the trusted isolated build. */
12
+ dependenciesRoot?: string;
13
+ /**
14
+ * Stage proven working-copy inputs in the disposable archive. Full request proofs are checked afterward.
15
+ * `appRoot` holds exactly `sourceRevision` (the request target), materialized from the repository
16
+ * history rather than the working tree, so another revision than the checkout can be built honestly.
17
+ */
18
+ prepareSource?(context: { appRoot: string; request: WorkspaceDraftSourceRequest; sourceRevision: string; signal: AbortSignal }): Promise<void>;
19
+ /** Must stop and settle when aborted; outputDirectory must contain a completed static app. */
20
+ build(context: { appRoot: string; outputDirectory: string; request: WorkspaceDraftSourceRequest; sourceRevision: string; signal: AbortSignal }): Promise<void>;
21
+ /** Return true after handling a host API request. False or void allows static routing. */
22
+ handleRequest?(request: IncomingMessage, response: ServerResponse, context: { request: WorkspaceDraftSourceRequest; sourceRevision: string }): boolean | void | Promise<boolean | void>;
23
+ /** Active builds and ready origins count toward this bound. Default: 3. */
24
+ maxRuns?: number;
25
+ maxOutputFiles?: number;
26
+ maxOutputBytes?: number;
27
+ }
28
+
29
+ export declare function createWorkspaceDraftPreviewService(options: WorkspaceDraftPreviewServiceOptions): {
30
+ /** The host must validate file-plan requests with its canonical compiler before preparing a build. */
31
+ prepare(input: { request: WorkspaceDraftSourceRequest; documentRevision: string; route: string }, options?: { signal?: AbortSignal }): Promise<WorkspaceApplicationSurface>;
32
+ /** Abort builds, close origins, and remove every temporary archive. */
33
+ dispose(): Promise<void>;
34
+ };
35
+
36
+ /** Minimal Vite-compatible boundary; headless service consumers do not require Vite types. */
37
+ export declare function workspaceDraftPreviewPlugin(options: {
38
+ service: ReturnType<typeof createWorkspaceDraftPreviewService>;
39
+ endpoint?: string;
40
+ /** The built app reports its recipe result through the workspace ready-message protocol. */
41
+ waitForReady?: boolean;
42
+ /** Validate file-plan source semantics with the host compiler, not only browser-supplied fingerprints. */
43
+ resolveRequest(input: unknown, options: { signal: AbortSignal }):
44
+ { request: WorkspaceDraftSourceRequest; documentRevision: string; route: string } |
45
+ Promise<{ request: WorkspaceDraftSourceRequest; documentRevision: string; route: string }>;
46
+ }): {
47
+ name: string;
48
+ apply: 'serve';
49
+ configureServer(server: {
50
+ httpServer?: { once(event: 'close', listener: () => void): unknown } | null;
51
+ config: { logger: { error(message: string): void } };
52
+ middlewares: { use(handler: (request: IncomingMessage, response: ServerResponse, next: () => void) => void | Promise<void>): unknown };
53
+ }): void;
54
+ };
package/inspect.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { InspectApplyPayload } from './dist-lib/types/lib';
2
2
 
3
3
  export interface PygmalionInspectWritebackOptions {
4
+ /** If supplied, source proofs must use this exact source revision. */
5
+ sourceRevision?: string;
4
6
  sourceDirectory?: string;
5
7
  normalizeValue?: (property: string, value: string) => string;
6
8
  }
@@ -12,6 +12,7 @@ import { normalizeViteMode } from './dev-mirror.mjs';
12
12
  import { writeStyleEdit, writeTextEdit } from './inspect-plugin.mjs';
13
13
  import { createUnifiedDiff } from './source-diff.mjs';
14
14
  import { writeSourceOperations } from './source-operations.mjs';
15
+ import { validateInspectSourceProofs } from './source-file-proofs.mjs';
15
16
  import { resolvePygmalionPreviewViteCacheDir } from './preview-vite-cache.mjs';
16
17
  import { sendJson as sendJsonEnvelope } from './envelope.mjs';
17
18
 
@@ -696,6 +697,7 @@ export function pygmalionDesignSessionPlugin(options) {
696
697
  if (dirty) throw new Error(`There are unconfirmed changes in the edit session task folder:${dirty.split('\n')[0]}`);
697
698
 
698
699
  const sessionAppRoot = appRootForWorktree(session.worktreeRoot);
700
+ await validateInspectSourceProofs(sessionAppRoot, input.changes, { sourceRevision: session.baseDevSha });
699
701
  const files = collectVisualFiles(sessionAppRoot, changes);
700
702
  const before = new Map();
701
703
  for (const [relativeFile, absoluteFile] of files) {
@@ -748,6 +750,7 @@ export function pygmalionDesignSessionPlugin(options) {
748
750
  if (dirty) throw new Error(`There are unconfirmed changes in the edit session task folder:${dirty.split('\n')[0]}`);
749
751
 
750
752
  const sessionAppRoot = appRootForWorktree(session.worktreeRoot);
753
+ await validateInspectSourceProofs(sessionAppRoot, input.changes, { sourceRevision: session.baseDevSha });
751
754
  const files = collectVisualFiles(sessionAppRoot, { styles, texts, operations });
752
755
  const backups = new Map();
753
756
  for (const [relativeFile, absoluteFile] of files) {
@@ -24,8 +24,10 @@ export { writeSourceOperations } from './source-operations.mjs';
24
24
  // POST /__pygmalion-inspect/impact — Check dedependency closure of changed files.
25
25
  // The editor doesn't know the endpoint URL — the onInspectApply callback in design-editor.tsx POSTs here.
26
26
 
27
+ // Only a real import statement at the start of a line registers; the same text inside a
28
+ // string literal or comment must not create a registration for an undefined identifier.
27
29
  const CSS_MODULE_IMPORT_RE =
28
- /import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+\.module\.scss)['"]\s*;?/g;
30
+ /^[ \t]*import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+\.module\.scss)['"]\s*;?/gm;
29
31
 
30
32
  export const PYGMALION_INSPECT_CONTROL = PYGMALION_ENDPOINTS.inspectControl;
31
33
 
@@ -4,6 +4,8 @@ import path from 'node:path';
4
4
  import { writeStyleEdit, writeTextEdit } from './inspect-plugin.mjs';
5
5
  import { createUnifiedDiff } from './source-diff.mjs';
6
6
  import { writeSourceOperations } from './source-operations.mjs';
7
+ import { validateInspectSourceProofs } from './source-file-proofs.mjs';
8
+ import { withSourceFileLock } from './source-file-lock.mjs';
7
9
 
8
10
  const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.mts', '.cjs', '.cts']);
9
11
 
@@ -100,8 +102,9 @@ async function applyInspectChanges(root, changes, options) {
100
102
  };
101
103
  }
102
104
 
103
- export async function previewInspectChanges(root, input, options = {}) {
105
+ async function previewInspectChangesUnlocked(root, input, options = {}) {
104
106
  const appRoot = path.resolve(root);
107
+ await validateInspectSourceProofs(appRoot, input, options);
105
108
  const sourceDirectory = normalizeSourceDirectory(options.sourceDirectory);
106
109
  const changes = normalizeInspectChanges(input);
107
110
  const files = collectInspectFiles(appRoot, changes, sourceDirectory);
@@ -136,8 +139,9 @@ export async function previewInspectChanges(root, input, options = {}) {
136
139
  }
137
140
  }
138
141
 
139
- export async function writeInspectChanges(root, input, options = {}) {
142
+ async function writeInspectChangesUnlocked(root, input, options = {}) {
140
143
  const appRoot = path.resolve(root);
144
+ await validateInspectSourceProofs(appRoot, input, options);
141
145
  const sourceDirectory = normalizeSourceDirectory(options.sourceDirectory);
142
146
  const changes = normalizeInspectChanges(input);
143
147
  const files = collectInspectFiles(appRoot, changes, sourceDirectory);
@@ -161,3 +165,15 @@ export async function writeInspectChanges(root, input, options = {}) {
161
165
  throw error;
162
166
  }
163
167
  }
168
+
169
+ export async function previewInspectChanges(root, input, options = {}) {
170
+ const snapshot = structuredClone(input);
171
+ const configuration = { ...options };
172
+ return withSourceFileLock(root, base => previewInspectChangesUnlocked(base, snapshot, configuration));
173
+ }
174
+
175
+ export async function writeInspectChanges(root, input, options = {}) {
176
+ const snapshot = structuredClone(input);
177
+ const configuration = { ...options };
178
+ return withSourceFileLock(root, base => writeInspectChangesUnlocked(base, snapshot, configuration));
179
+ }
@@ -1230,7 +1230,6 @@ export function createPreviewArtifactStore({
1230
1230
  ) => {
1231
1231
  const manifest = await readManifest(namespace);
1232
1232
  let aggregate = null;
1233
- const contentFreshIds = new Set();
1234
1233
  const sourcePendingIds = new Set();
1235
1234
  if (manifest) {
1236
1235
  // Index once per read. The earlier path filtered the complete manifest for
@@ -1272,12 +1271,21 @@ export function createPreviewArtifactStore({
1272
1271
  artifact.frames[request.id]
1273
1272
  ) {
1274
1273
  const frame = artifact.frames[request.id];
1274
+ const acceptedAsFresh = exactSet.has(entry);
1275
1275
  const withProvenance = {
1276
1276
  ...artifact,
1277
1277
  frames: {
1278
1278
  ...artifact.frames,
1279
1279
  [request.id]: {
1280
1280
  ...frame,
1281
+ // A matching recipe plus unchanged observed source files is
1282
+ // a content proof for the requested fingerprint. Serve that
1283
+ // proof under the requested identity so browser bootstrap
1284
+ // reaches the same exact verdict as the manifest resolver.
1285
+ // The original capture revision remains truthful provenance.
1286
+ ...(acceptedAsFresh && request.fingerprint != null
1287
+ ? { fingerprint: request.fingerprint }
1288
+ : {}),
1281
1289
  ...(entry.sourceRevision == null
1282
1290
  ? {}
1283
1291
  : { sourceRevision: entry.sourceRevision }),
@@ -1288,8 +1296,7 @@ export function createPreviewArtifactStore({
1288
1296
  aggregate,
1289
1297
  withProvenance,
1290
1298
  );
1291
- if (exactSet.has(entry)) {
1292
- contentFreshIds.add(request.id);
1299
+ if (acceptedAsFresh) {
1293
1300
  sourcePendingIds.delete(request.id);
1294
1301
  }
1295
1302
  break;
@@ -1304,10 +1311,6 @@ export function createPreviewArtifactStore({
1304
1311
  );
1305
1312
  return {
1306
1313
  ...selection,
1307
- // The wire-level selector only knows fingerprints. A frame whose recipe
1308
- // and observed files were verified against the requested source context is
1309
- // also current, even when its coarse source fingerprint changed.
1310
- stale: selection.stale.filter((id) => !contentFreshIds.has(id)),
1311
1314
  ...(sourcePendingIds.size
1312
1315
  ? { sourcePending: [...sourcePendingIds] }
1313
1316
  : {}),
@@ -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
+ }