nativemason 0.0.0-stage → 0.3.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 (44) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +4 -2
  3. package/bin/nativemason.js +13 -0
  4. package/dist/application/index.d.ts +34 -0
  5. package/dist/application/index.js +593 -0
  6. package/dist/browser/accessibility.d.ts +4 -0
  7. package/dist/browser/accessibility.js +93 -0
  8. package/dist/browser/index.d.ts +16 -0
  9. package/dist/browser/index.js +495 -0
  10. package/dist/cli.d.ts +2 -0
  11. package/dist/cli.js +23 -0
  12. package/dist/core/accessibility.d.ts +5 -0
  13. package/dist/core/accessibility.js +55 -0
  14. package/dist/core/design-intent.d.ts +3 -0
  15. package/dist/core/design-intent.js +108 -0
  16. package/dist/core/index.d.ts +15 -0
  17. package/dist/core/index.js +324 -0
  18. package/dist/editor/index.d.ts +4 -0
  19. package/dist/editor/index.js +4 -0
  20. package/dist/editor/planner.d.ts +4 -0
  21. package/dist/editor/planner.js +374 -0
  22. package/dist/editor/source-format.d.ts +6 -0
  23. package/dist/editor/source-format.js +32 -0
  24. package/dist/editor/source-port.d.ts +3 -0
  25. package/dist/editor/source-port.js +79 -0
  26. package/dist/editor/transaction-planner.d.ts +3 -0
  27. package/dist/editor/transaction-planner.js +231 -0
  28. package/dist/editor/transaction-port.d.ts +3 -0
  29. package/dist/editor/transaction-port.js +257 -0
  30. package/dist/evidence/report.d.ts +4 -0
  31. package/dist/evidence/report.js +180 -0
  32. package/dist/init/index.d.ts +26 -0
  33. package/dist/init/index.js +81 -0
  34. package/dist/instrumentation/index.d.ts +25 -0
  35. package/dist/instrumentation/index.js +235 -0
  36. package/dist/mcp/index.d.ts +3 -0
  37. package/dist/mcp/index.js +42 -0
  38. package/dist/native/index.d.ts +4 -0
  39. package/dist/native/index.js +221 -0
  40. package/dist/overlay/index.d.ts +3 -0
  41. package/dist/overlay/index.js +35 -0
  42. package/dist/scenario/index.d.ts +5 -0
  43. package/dist/scenario/index.js +318 -0
  44. package/package.json +46 -3
@@ -0,0 +1,231 @@
1
+ import { createTwoFilesPatch } from 'diff';
2
+ import { Node, Project, SyntaxKind } from 'ts-morph';
3
+ import { SourceMutationSchema } from '@nativemason/contracts';
4
+ import { isLiteralStyleObject, valueText } from './source-format.js';
5
+ import { hashSource, planSourcePatch } from './planner.js';
6
+ /** All project inputs are supplied by the adapter. Planning never reads or executes application code. */
7
+ export function planTransaction(files, mutations) {
8
+ try {
9
+ if (!mutations.length)
10
+ throw Error('A transaction requires at least one mutation');
11
+ const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true, compilerOptions: { jsx: 4, allowJs: true, noLib: true } });
12
+ const originals = new Map(files.map(f => [f.path, f]));
13
+ for (const file of files)
14
+ project.createSourceFile('/' + file.path, file.text);
15
+ const changes = new Map();
16
+ const blastRadius = { consumers: [], unresolvedReferences: [] };
17
+ const add = (path, source, after) => {
18
+ const before = originals.get(path).text;
19
+ let start = 0;
20
+ while (start < before.length && start < after.length && before[start] === after[start])
21
+ start++;
22
+ let end = before.length, newEnd = after.length;
23
+ while (end > start && newEnd > start && before[end - 1] === after[newEnd - 1]) {
24
+ end--;
25
+ newEnd--;
26
+ }
27
+ if (start === end && start === newEnd)
28
+ return;
29
+ const entry = changes.get(path) ?? { source, edits: [] };
30
+ const edit = { start, end, text: after.slice(start, newEnd) };
31
+ if (entry.edits.some(e => e.start === start && e.end === end && e.text === edit.text))
32
+ return;
33
+ if (entry.edits.some(e => Math.max(e.start, start) < Math.min(e.end, end) || (e.start === start && e.end === end)))
34
+ throw Error('Conflicting mutations target the same source range');
35
+ entry.edits.push(edit);
36
+ changes.set(path, entry);
37
+ };
38
+ const grouped = new Map();
39
+ for (const raw of mutations) {
40
+ const mutation = SourceMutationSchema.parse(raw), key = JSON.stringify([mutation.source, mutation.scope ?? { kind: 'local' }]);
41
+ const prior = grouped.get(key);
42
+ if (prior) {
43
+ for (const [property, value] of Object.entries(mutation.patch)) {
44
+ const old = prior.patch[property];
45
+ if (old !== undefined && old !== value)
46
+ throw Error('Conflicting values target the same style property');
47
+ }
48
+ prior.patch = { ...prior.patch, ...mutation.patch };
49
+ }
50
+ else
51
+ grouped.set(key, structuredClone(mutation));
52
+ }
53
+ for (const raw of grouped.values()) {
54
+ const mutation = SourceMutationSchema.parse(raw), file = originals.get(mutation.source.path);
55
+ if (!file || file.hash !== hashSource(file.text) || file.hash !== mutation.source.hash)
56
+ return { ok: false, error: { code: 'STALE_SOURCE', message: 'Transaction source revision is unavailable or stale' } };
57
+ if (mutation.scope?.kind !== 'shared') {
58
+ const planned = planSourcePatch(file.text, mutation.source, mutation.patch, mutation.scope ?? { kind: 'local' });
59
+ if (!planned.ok)
60
+ return planned;
61
+ // Reuse the shared-edit discovery as a proof, never as authorization to
62
+ // widen a local edit. Unknown/shared consumers keep the local override.
63
+ const ast = project.getSourceFileOrThrow('/' + file.path);
64
+ const site = ast.getDescendants().find(n => (Node.isJsxElement(n) || Node.isJsxSelfClosingElement(n)) && n.getStart() === mutation.source.start && n.getEnd() === mutation.source.end);
65
+ const opening = site && (Node.isJsxElement(site) ? site.getOpeningElement() : Node.isJsxSelfClosingElement(site) ? site : undefined);
66
+ const attribute = opening?.getAttribute('style');
67
+ const initializer = attribute && Node.isJsxAttribute(attribute) ? attribute.getInitializer() : undefined;
68
+ const expression = initializer && Node.isJsxExpression(initializer) ? initializer.getExpression() : undefined;
69
+ const finalExpression = expression && Node.isArrayLiteralExpression(expression) ? expression.getElements().at(-1) : expression;
70
+ if ((!mutation.scope || mutation.scope.kind === 'local') && finalExpression && (Node.isIdentifier(finalExpression) || Node.isPropertyAccessExpression(finalExpression))) {
71
+ const folded = planTransaction(files, [{ ...mutation, scope: { ...mutation.scope, kind: 'shared' } }]);
72
+ const consumer = folded.ok && folded.value.blastRadius.consumers.length === 1 ? folded.value.blastRadius.consumers[0] : undefined;
73
+ if (folded.ok && consumer && consumer.source.path === mutation.source.path && consumer.source.start === mutation.source.start && consumer.source.end === mutation.source.end && folded.value.files.length === 1) {
74
+ const plan = folded.value.files[0], definition = project.getSourceFileOrThrow('/' + plan.source.path).getDescendants().find(n => Node.isObjectLiteralExpression(n) && n.getStart() === plan.source.start);
75
+ if (definition && Node.isObjectLiteralExpression(definition) && safeAutomaticDefinition(definition, project)) {
76
+ add(plan.source.path, plan.source, plan.updatedText);
77
+ blastRadius.consumers.push(consumer);
78
+ continue;
79
+ }
80
+ }
81
+ }
82
+ add(file.path, mutation.source, planned.value.updatedText);
83
+ blastRadius.consumers.push({ source: mutation.source, mountedIds: [], requiredScenarios: [] });
84
+ continue;
85
+ }
86
+ const ast = project.getSourceFileOrThrow('/' + file.path);
87
+ const site = ast.getDescendants().find(n => (Node.isJsxElement(n) || Node.isJsxSelfClosingElement(n)) && n.getStart() === mutation.source.start && n.getEnd() === mutation.source.end);
88
+ if (!site || (!Node.isJsxElement(site) && !Node.isJsxSelfClosingElement(site)))
89
+ return { ok: false, error: { code: 'STALE_SOURCE', message: 'Shared mutation no longer identifies its JSX source' } };
90
+ const opening = Node.isJsxElement(site) ? site.getOpeningElement() : site;
91
+ const attr = opening.getAttribute('style');
92
+ if (!attr || !Node.isJsxAttribute(attr))
93
+ throw Error('Shared editing requires a style expression');
94
+ const initializer = attr.getInitializer();
95
+ if (!initializer || !Node.isJsxExpression(initializer) || !initializer.getExpression())
96
+ throw Error('Shared editing requires a style expression');
97
+ const initialExpression = initializer.getExpression();
98
+ const finalExpression = Node.isArrayLiteralExpression(initialExpression) ? initialExpression.getElements().at(-1) : initialExpression;
99
+ if (!finalExpression || !Node.isExpression(finalExpression))
100
+ throw Error('Shared editing requires a final style expression');
101
+ const expression = finalExpression;
102
+ function resolveObject(expr) {
103
+ if (Node.isObjectLiteralExpression(expr))
104
+ return expr;
105
+ if (Node.isIdentifier(expr)) {
106
+ const symbol = expr.getSymbol()?.getAliasedSymbol() ?? expr.getSymbol();
107
+ const declaration = symbol?.getDeclarations().find(Node.isVariableDeclaration);
108
+ const value = declaration?.getInitializer();
109
+ if (value)
110
+ return resolveObject(value);
111
+ }
112
+ if (Node.isPropertyAccessExpression(expr)) {
113
+ const base = resolveObject(expr.getExpression());
114
+ const property = base.getProperty(expr.getName());
115
+ if (property && Node.isPropertyAssignment(property) && property.getInitializer())
116
+ return resolveObject(property.getInitializer());
117
+ }
118
+ if (Node.isCallExpression(expr) && Node.isPropertyAccessExpression(expr.getExpression()) && expr.getExpression().getText().endsWith('.create')) {
119
+ const owner = expr.getExpression();
120
+ const base = owner.getExpression();
121
+ if (!Node.isIdentifier(base) || !base.getSymbol()?.getDeclarations().some(d => Node.isImportSpecifier(d) && d.getImportDeclaration().getModuleSpecifierValue() === 'react-native' && d.getName() === 'StyleSheet'))
122
+ throw Error('Untrusted stylesheet factory');
123
+ const arg = expr.getArguments()[0];
124
+ if (arg && Node.isObjectLiteralExpression(arg))
125
+ return arg;
126
+ }
127
+ throw Error('Shared definition cannot be resolved statically');
128
+ }
129
+ const object = resolveObject(expression), targetFile = object.getSourceFile(), relative = targetFile.getFilePath().slice(1), original = originals.get(relative);
130
+ if (!original)
131
+ throw Error('Shared definition is outside supplied project sources');
132
+ // Resolve property-access references rather than assuming equal text means equal binding.
133
+ let references = 0;
134
+ for (const candidate of project.getSourceFiles())
135
+ for (const node of candidate.getDescendants()) {
136
+ if (!Node.isPropertyAccessExpression(node) && !Node.isIdentifier(node))
137
+ continue;
138
+ try {
139
+ if (resolveObject(node) !== object)
140
+ continue;
141
+ }
142
+ catch {
143
+ continue;
144
+ }
145
+ const parent = node.getParent();
146
+ if (parent && (Node.isPropertyAccessExpression(parent) && parent.getExpression() === node)) {
147
+ try {
148
+ resolveObject(parent);
149
+ continue;
150
+ }
151
+ catch {
152
+ blastRadius.unresolvedReferences.push(candidate.getFilePath().slice(1) + ':' + parent.getStart());
153
+ continue;
154
+ }
155
+ }
156
+ if (Node.isVariableDeclaration(parent)) {
157
+ if (parent.getInitializer() === node)
158
+ blastRadius.unresolvedReferences.push(candidate.getFilePath().slice(1) + ':' + node.getStart());
159
+ continue;
160
+ }
161
+ const jsx = node.getAncestors().find(n => Node.isJsxElement(n) || Node.isJsxSelfClosingElement(n));
162
+ if (!jsx) {
163
+ blastRadius.unresolvedReferences.push(candidate.getFilePath().slice(1) + ':' + node.getStart());
164
+ continue;
165
+ }
166
+ references++;
167
+ const location = candidate.getLineAndColumnAtPos(jsx.getStart());
168
+ blastRadius.consumers.push({ source: { path: candidate.getFilePath().slice(1), hash: hashSource(candidate.getFullText()), start: jsx.getStart(), end: jsx.getEnd(), line: location.line, column: location.column - 1 }, mountedIds: [], requiredScenarios: [] });
169
+ }
170
+ if (!references || blastRadius.unresolvedReferences.length)
171
+ return { ok: false, error: { code: 'UNSUPPORTED_EDIT', message: 'Shared definition has unresolved or escaping consumers', details: blastRadius } };
172
+ for (const prop of object.getProperties())
173
+ if (!Node.isPropertyAssignment(prop) || prop.getNameNode().getKind() === SyntaxKind.ComputedPropertyName)
174
+ throw Error('Shared definitions with spreads or computed keys are unsupported');
175
+ // Modify an isolated copy so subsequent mutations continue to use original AST offsets.
176
+ const copyProject = new Project({ useInMemoryFileSystem: true });
177
+ const copy = copyProject.createSourceFile('/copy.tsx', original.text);
178
+ const copyObject = copy.getDescendants().find(n => Node.isObjectLiteralExpression(n) && n.getStart() === object.getStart());
179
+ if (!copyObject || !Node.isObjectLiteralExpression(copyObject))
180
+ throw Error('Shared source mapping failed');
181
+ for (const [key, value] of Object.entries(mutation.patch)) {
182
+ if (value === undefined)
183
+ continue;
184
+ const prop = copyObject.getProperty(key);
185
+ if (prop && Node.isPropertyAssignment(prop))
186
+ prop.setInitializer(valueText(value, copy, prop.getInitializer()));
187
+ else
188
+ copyObject.addPropertyAssignment({ name: key, initializer: valueText(value, copy) });
189
+ }
190
+ const location = targetFile.getLineAndColumnAtPos(object.getStart());
191
+ add(relative, { path: relative, hash: original.hash, start: object.getStart(), end: object.getEnd(), line: location.line, column: location.column - 1 }, copy.getFullText());
192
+ }
193
+ const plans = [...changes].sort(([a], [b]) => a.localeCompare(b)).map(([path, { source, edits }]) => {
194
+ const original = originals.get(path);
195
+ let updatedText = original.text;
196
+ for (const edit of edits.sort((a, b) => b.start - a.start))
197
+ updatedText = updatedText.slice(0, edit.start) + edit.text + updatedText.slice(edit.end);
198
+ return { source, expectedHash: original.hash, updatedText, diff: createTwoFilesPatch(path, path, original.text, updatedText) };
199
+ });
200
+ if (!plans.length)
201
+ throw Error('Transaction would not change source');
202
+ return { ok: true, value: { files: plans, blastRadius } };
203
+ }
204
+ catch (error) {
205
+ return { ok: false, error: { code: 'UNSUPPORTED_EDIT', message: error instanceof Error ? error.message : String(error) } };
206
+ }
207
+ }
208
+ /** Exported StyleSheet containers may only be used through known property access. */
209
+ function safeAutomaticDefinition(object, project) {
210
+ if (!isLiteralStyleObject(object))
211
+ return false;
212
+ const declaration = object.getAncestors().find(Node.isVariableDeclaration);
213
+ if (!declaration || declaration.getInitializer() === object)
214
+ return true;
215
+ const symbol = declaration.getSymbol();
216
+ if (!symbol)
217
+ return false;
218
+ for (const file of project.getSourceFiles())
219
+ for (const id of file.getDescendantsOfKind(SyntaxKind.Identifier)) {
220
+ const target = id.getSymbol()?.getAliasedSymbol() ?? id.getSymbol();
221
+ if (target !== symbol || id === declaration.getNameNode())
222
+ continue;
223
+ const parent = id.getParent();
224
+ if (Node.isImportSpecifier(parent) || Node.isExportSpecifier(parent))
225
+ continue;
226
+ if (Node.isPropertyAccessExpression(parent) && parent.getExpression() === id)
227
+ continue;
228
+ return false;
229
+ }
230
+ return true;
231
+ }
@@ -0,0 +1,3 @@
1
+ import { type TransactionSourcePort } from '@nativemason/contracts';
2
+ /** Recoverable multi-file replacement. Journals persist until apply or rollback completes. */
3
+ export declare function createTransactionSourcePort(projectRoot: string): TransactionSourcePort;
@@ -0,0 +1,257 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, open, readFile, rename, unlink, readdir, realpath, lstat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { createTwoFilesPatch } from 'diff';
5
+ import { BridgeFault, TransactionPlanSchema, TransactionReceiptSchema } from '@nativemason/contracts';
6
+ import { createSourcePort } from './source-port.js';
7
+ import { hashSource } from './planner.js';
8
+ const ignore = new Set(['node_modules', '.git', '.nativemason', 'artifacts', 'dist', 'build', '.expo', 'android', 'ios']);
9
+ /** Recoverable multi-file replacement. Journals persist until apply or rollback completes. */
10
+ export function createTransactionSourcePort(projectRoot) {
11
+ const base = createSourcePort(projectRoot);
12
+ const rootPromise = realpath(projectRoot);
13
+ let queue = Promise.resolve();
14
+ async function control() { const root = await rootPromise; const dir = path.join(root, '.nativemason'); await mkdir(dir, { recursive: true, mode: 0o700 }); if ((await realpath(dir)) !== dir)
15
+ throw new BridgeFault('SOURCE_OUTSIDE_PROJECT', 'Transaction storage must not be a symlink'); return dir; }
16
+ async function source(relative) {
17
+ await base.read(relative);
18
+ const root = await rootPromise;
19
+ const file = path.resolve(root, relative);
20
+ if ((await realpath(file)) !== file)
21
+ throw new BridgeFault('UNSUPPORTED_EDIT', 'Transactions do not replace symlink sources');
22
+ return file;
23
+ }
24
+ async function persist(file, value) { const temporary = file + '.' + randomUUID() + '.tmp'; const handle = await open(temporary, 'wx', 0o600); try {
25
+ await handle.writeFile(JSON.stringify(value));
26
+ await handle.sync();
27
+ }
28
+ finally {
29
+ await handle.close();
30
+ } await rename(temporary, file); }
31
+ async function remove(file) { await unlink(file).catch(error => { if (error.code !== 'ENOENT')
32
+ throw error; }); }
33
+ async function syncDirectory(dir) { const handle = await open(dir, 'r'); try {
34
+ await handle.sync();
35
+ }
36
+ finally {
37
+ await handle.close();
38
+ } }
39
+ async function recoverUnlocked(dir) {
40
+ for (const filename of await readdir(dir)) {
41
+ if (!filename.endsWith('.journal.json'))
42
+ continue;
43
+ const journalPath = path.join(dir, filename);
44
+ const journal = JSON.parse(await readFile(journalPath, 'utf8'));
45
+ if (journal.state === 'committed') {
46
+ await persist(path.join(dir, journal.id + '.receipt.json'), { receipt: journal.receipt, files: journal.files });
47
+ await syncDirectory(dir);
48
+ await remove(journalPath);
49
+ await syncDirectory(dir);
50
+ continue;
51
+ }
52
+ for (const item of [...journal.files].reverse()) {
53
+ const file = await source(item.path), current = await readFile(file, 'utf8');
54
+ if (path.dirname(item.temporary) !== path.dirname(file) || !path.basename(item.temporary).startsWith(path.basename(file) + '.') || !item.temporary.endsWith('.stage'))
55
+ throw new BridgeFault('INVALID_INPUT', 'Invalid recovery staging path');
56
+ if (hashSource(current) === hashSource(item.before)) {
57
+ await remove(item.temporary);
58
+ continue;
59
+ }
60
+ if (hashSource(current) !== hashSource(item.after))
61
+ throw new BridgeFault('STALE_SOURCE', 'Transaction recovery conflicts with external edits', { transactionId: journal.id, path: item.path });
62
+ const temp = file + '.' + randomUUID() + '.rollback';
63
+ const rollback = await open(temp, 'wx', item.mode);
64
+ try {
65
+ await rollback.writeFile(item.before);
66
+ await rollback.chmod(item.mode);
67
+ await rollback.sync();
68
+ }
69
+ finally {
70
+ await rollback.close();
71
+ }
72
+ await rename(temp, file);
73
+ await syncDirectory(path.dirname(file));
74
+ await remove(item.temporary);
75
+ }
76
+ await remove(journalPath);
77
+ }
78
+ }
79
+ async function locked(operation) {
80
+ const work = async () => {
81
+ const dir = await control(), lock = path.join(dir, 'transaction.lock');
82
+ try {
83
+ const handle = await open(lock, 'wx', 0o600);
84
+ try {
85
+ await handle.writeFile(JSON.stringify({ pid: process.pid }));
86
+ await handle.sync();
87
+ }
88
+ finally {
89
+ await handle.close();
90
+ }
91
+ }
92
+ catch (error) {
93
+ if (error.code !== 'EEXIST')
94
+ throw error;
95
+ const recovery = path.join(dir, 'transaction.recovery.lock');
96
+ let recoveryHandle;
97
+ try {
98
+ recoveryHandle = await open(recovery, 'wx', 0o600);
99
+ await recoveryHandle.writeFile(JSON.stringify({ pid: process.pid }));
100
+ await recoveryHandle.sync();
101
+ await recoveryHandle.close();
102
+ recoveryHandle = undefined;
103
+ }
104
+ catch (cause) {
105
+ const created = recoveryHandle !== undefined;
106
+ await recoveryHandle?.close().catch(() => undefined);
107
+ if (created)
108
+ await remove(recovery).catch(() => undefined);
109
+ if (cause.code === 'EEXIST' && !created)
110
+ throw new BridgeFault('RECOVERY_CONFLICT', 'Transaction recovery is locked; inspect .nativemason/transaction.recovery.lock for its owner before retrying');
111
+ throw cause;
112
+ }
113
+ try {
114
+ let owner;
115
+ try {
116
+ const decoded = JSON.parse(await readFile(lock, 'utf8'));
117
+ if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded) || !Number.isSafeInteger(decoded.pid) || (decoded.pid <= 0))
118
+ throw new Error('Invalid lock owner');
119
+ owner = decoded;
120
+ }
121
+ catch (cause) {
122
+ if (cause.code !== 'ENOENT')
123
+ throw new BridgeFault('INTERNAL_ERROR', 'Transaction lock is unreadable; recovery requires checking the owning process');
124
+ }
125
+ if (owner) {
126
+ try {
127
+ process.kill(owner.pid, 0);
128
+ throw new BridgeFault('INVALID_INPUT', 'Another source transaction is active');
129
+ }
130
+ catch (cause) {
131
+ if (cause instanceof BridgeFault)
132
+ throw cause;
133
+ if (cause.code !== 'ESRCH')
134
+ throw cause;
135
+ }
136
+ await remove(lock);
137
+ }
138
+ }
139
+ finally {
140
+ await remove(recovery);
141
+ }
142
+ return work();
143
+ }
144
+ try {
145
+ await recoverUnlocked(dir);
146
+ return await operation(dir);
147
+ }
148
+ finally {
149
+ await remove(lock);
150
+ }
151
+ };
152
+ const result = queue.then(work, work);
153
+ queue = result.catch(() => undefined);
154
+ return result;
155
+ }
156
+ async function applyUnlocked(plan, dir, revertsCommitId) {
157
+ const parsed = TransactionPlanSchema.safeParse(plan);
158
+ if (!parsed.success)
159
+ throw new BridgeFault('INVALID_INPUT', 'Invalid transaction plan');
160
+ const paths = plan.files.map(f => f.source.path);
161
+ if (new Set(paths).size !== paths.length)
162
+ throw new BridgeFault('INVALID_INPUT', 'A transaction must coalesce each source file');
163
+ const id = hashSource(JSON.stringify([plan.intentId ?? null, plan.files.map(f => [f.source.path, f.expectedHash, hashSource(f.updatedText)])]));
164
+ const receiptPath = path.join(dir, id + '.receipt.json');
165
+ try {
166
+ const prior = JSON.parse(await readFile(receiptPath, 'utf8'));
167
+ for (const file of prior.receipt.files)
168
+ if ((await base.read(file.path)).hash !== file.newHash)
169
+ throw new BridgeFault('STALE_SOURCE', 'Previously committed transaction source changed');
170
+ return TransactionReceiptSchema.parse(prior.receipt);
171
+ }
172
+ catch (error) {
173
+ if (error.code !== 'ENOENT')
174
+ throw error;
175
+ }
176
+ const files = [];
177
+ for (const planFile of plan.files) {
178
+ if (planFile.source.hash !== planFile.expectedHash)
179
+ throw new BridgeFault('INVALID_INPUT', 'Source and expected hashes disagree');
180
+ const file = await source(planFile.source.path), before = await readFile(file, 'utf8');
181
+ if (hashSource(before) !== planFile.expectedHash)
182
+ throw new BridgeFault('STALE_SOURCE', 'Source changed before transaction', { path: planFile.source.path });
183
+ files.push({ path: planFile.source.path, before, after: planFile.updatedText, mode: (await lstat(file)).mode & 0o777, temporary: file + '.' + randomUUID() + '.stage' });
184
+ }
185
+ const receipt = { commitId: id, files: plan.files.map(f => ({ path: f.source.path, oldHash: f.expectedHash, newHash: hashSource(f.updatedText), diff: f.diff })), nativeStatus: 'pending', ...(revertsCommitId ? { revertsCommitId } : {}) };
186
+ const journal = { id, state: 'prepared', files, receipt }, journalPath = path.join(dir, id + '.journal.json');
187
+ await persist(journalPath, journal);
188
+ await syncDirectory(dir);
189
+ try {
190
+ for (const item of files) {
191
+ const handle = await open(item.temporary, 'wx', item.mode);
192
+ try {
193
+ await handle.writeFile(item.after);
194
+ await handle.chmod(item.mode);
195
+ await handle.sync();
196
+ }
197
+ finally {
198
+ await handle.close();
199
+ }
200
+ }
201
+ // Preflight the complete set again before the first replacement.
202
+ for (const item of files)
203
+ if ((await base.read(item.path)).hash !== hashSource(item.before))
204
+ throw new BridgeFault('STALE_SOURCE', 'Source changed while staging', { path: item.path });
205
+ for (const item of files) {
206
+ const file = await source(item.path);
207
+ if (hashSource(await readFile(file, 'utf8')) !== hashSource(item.before))
208
+ throw new BridgeFault('STALE_SOURCE', 'Source changed during transaction', { path: item.path });
209
+ await rename(item.temporary, file);
210
+ await syncDirectory(path.dirname(file));
211
+ }
212
+ journal.state = 'committed';
213
+ await persist(journalPath, journal);
214
+ await syncDirectory(dir);
215
+ await persist(receiptPath, { receipt, files });
216
+ await syncDirectory(dir);
217
+ await remove(journalPath);
218
+ await syncDirectory(dir);
219
+ return receipt;
220
+ }
221
+ catch (error) {
222
+ await recoverUnlocked(dir);
223
+ throw error;
224
+ }
225
+ }
226
+ return { ...base,
227
+ read: async (relative) => { await locked(async () => { }); return base.read(relative); },
228
+ apply: async (plan) => { const receipt = await locked(dir => applyUnlocked({ files: [plan], blastRadius: { consumers: [], unresolvedReferences: [] } }, dir)); const file = receipt.files[0]; return { commitId: receipt.commitId, ...file, nativeStatus: receipt.nativeStatus, files: receipt.files }; },
229
+ readProject: async () => {
230
+ await locked(async () => { });
231
+ const root = await rootPromise;
232
+ const files = [];
233
+ const visit = async (dir) => { for (const entry of await readdir(dir, { withFileTypes: true })) {
234
+ if (ignore.has(entry.name) || entry.isSymbolicLink())
235
+ continue;
236
+ const absolute = path.join(dir, entry.name);
237
+ if (entry.isDirectory())
238
+ await visit(absolute);
239
+ else if (/\.[cm]?[jt]sx?$/.test(entry.name)) {
240
+ const relative = path.relative(root, absolute).split(path.sep).join('/');
241
+ files.push(await base.read(relative));
242
+ }
243
+ } };
244
+ await visit(root);
245
+ return files;
246
+ },
247
+ applyTransaction: plan => locked(dir => applyUnlocked(plan, dir)),
248
+ recover: () => locked(async () => { }),
249
+ revert: commitId => locked(async (dir) => {
250
+ if (!/^[a-f0-9]{64}$/.test(commitId))
251
+ throw new BridgeFault('INVALID_INPUT', 'Invalid transaction commit ID');
252
+ const stored = JSON.parse(await readFile(path.join(dir, commitId + '.receipt.json'), 'utf8'));
253
+ const files = stored.files.map(item => { const current = stored.receipt.files.find(f => f.path === item.path); return { source: { path: item.path, hash: current.newHash, start: 0, end: 1, line: 1, column: 0 }, expectedHash: current.newHash, updatedText: item.before, diff: createTwoFilesPatch(item.path, item.path, item.after, item.before) }; });
254
+ return applyUnlocked({ intentId: 'revert:' + commitId, files, blastRadius: { consumers: [], unresolvedReferences: [] } }, dir, commitId);
255
+ }),
256
+ };
257
+ }
@@ -0,0 +1,4 @@
1
+ import { type EvidenceInputs, type VisualEvidenceReport } from '@nativemason/contracts';
2
+ export declare function buildEvidenceReport(rawInput: EvidenceInputs): VisualEvidenceReport;
3
+ export declare function renderEvidenceHtml(report: VisualEvidenceReport): string;
4
+ export declare function renderEvidenceSummary(report: VisualEvidenceReport): string;