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.
- package/LICENSE +202 -0
- package/README.md +4 -2
- package/bin/nativemason.js +13 -0
- package/dist/application/index.d.ts +34 -0
- package/dist/application/index.js +593 -0
- package/dist/browser/accessibility.d.ts +4 -0
- package/dist/browser/accessibility.js +93 -0
- package/dist/browser/index.d.ts +16 -0
- package/dist/browser/index.js +495 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +23 -0
- package/dist/core/accessibility.d.ts +5 -0
- package/dist/core/accessibility.js +55 -0
- package/dist/core/design-intent.d.ts +3 -0
- package/dist/core/design-intent.js +108 -0
- package/dist/core/index.d.ts +15 -0
- package/dist/core/index.js +324 -0
- package/dist/editor/index.d.ts +4 -0
- package/dist/editor/index.js +4 -0
- package/dist/editor/planner.d.ts +4 -0
- package/dist/editor/planner.js +374 -0
- package/dist/editor/source-format.d.ts +6 -0
- package/dist/editor/source-format.js +32 -0
- package/dist/editor/source-port.d.ts +3 -0
- package/dist/editor/source-port.js +79 -0
- package/dist/editor/transaction-planner.d.ts +3 -0
- package/dist/editor/transaction-planner.js +231 -0
- package/dist/editor/transaction-port.d.ts +3 -0
- package/dist/editor/transaction-port.js +257 -0
- package/dist/evidence/report.d.ts +4 -0
- package/dist/evidence/report.js +180 -0
- package/dist/init/index.d.ts +26 -0
- package/dist/init/index.js +81 -0
- package/dist/instrumentation/index.d.ts +25 -0
- package/dist/instrumentation/index.js +235 -0
- package/dist/mcp/index.d.ts +3 -0
- package/dist/mcp/index.js +42 -0
- package/dist/native/index.d.ts +4 -0
- package/dist/native/index.js +221 -0
- package/dist/overlay/index.d.ts +3 -0
- package/dist/overlay/index.js +35 -0
- package/dist/scenario/index.d.ts +5 -0
- package/dist/scenario/index.js +318 -0
- package/package.json +46 -3
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { EvidenceInputsSchema, NativeTargetSchema, NativeVerificationSchema, SpatialSnapshotSchema, VisualEvidenceReportSchema, VisualEvidenceRunSchema, } from '@nativemason/contracts';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { buildOverlayScene } from '../core/index.js';
|
|
5
|
+
const MAX_INPUT_BYTES = 16 * 1024 * 1024;
|
|
6
|
+
const MAX_SNAPSHOT_NODES = 5_000;
|
|
7
|
+
const MAX_RENDERED_NODES = 250;
|
|
8
|
+
const MAX_LAYOUT_UNITS = 100_000;
|
|
9
|
+
const palette = ['#2563eb', '#dc2626', '#059669', '#7c3aed', '#d97706', '#0891b2'];
|
|
10
|
+
function readJson(path) {
|
|
11
|
+
try {
|
|
12
|
+
if (statSync(path).size > MAX_INPUT_BYTES)
|
|
13
|
+
return undefined;
|
|
14
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function isRelativeEvidencePath(path) {
|
|
21
|
+
const hasControl = [...path].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127);
|
|
22
|
+
return path.length > 0 && !path.startsWith('/') && !path.includes('\\') && !path.includes(':') && !hasControl && path.split('/').every(part => part !== '' && part !== '.' && part !== '..');
|
|
23
|
+
}
|
|
24
|
+
function hasSafePaths(run) {
|
|
25
|
+
const paths = [
|
|
26
|
+
...Object.keys(run.before.revisions),
|
|
27
|
+
...Object.keys(run.after.revisions),
|
|
28
|
+
...run.before.nodes.flatMap(node => node.source ? [node.source.path, ...(node.source.dependencies ?? [])] : []),
|
|
29
|
+
...run.after.nodes.flatMap(node => node.source ? [node.source.path, ...(node.source.dependencies ?? [])] : []),
|
|
30
|
+
...(run.receipt ? [run.receipt.path, ...(run.receipt.files?.map(file => file.path) ?? [])] : []),
|
|
31
|
+
];
|
|
32
|
+
return paths.every(isRelativeEvidencePath);
|
|
33
|
+
}
|
|
34
|
+
function boundedSnapshot(snapshot) {
|
|
35
|
+
const { width, height } = snapshot.profile.window;
|
|
36
|
+
if (width > MAX_LAYOUT_UNITS || height > MAX_LAYOUT_UNITS || snapshot.nodes.length > MAX_SNAPSHOT_NODES)
|
|
37
|
+
return false;
|
|
38
|
+
return snapshot.nodes.every(({ rect }) => [rect.x, rect.y, rect.width, rect.height].every(value => Math.abs(value) <= MAX_LAYOUT_UNITS));
|
|
39
|
+
}
|
|
40
|
+
function unavailable(message) { return `${message} No measurements were added.`; }
|
|
41
|
+
function addRun(value, label, runs, notes) {
|
|
42
|
+
const parsed = VisualEvidenceRunSchema.safeParse(value);
|
|
43
|
+
if (!parsed.success || !hasSafePaths(parsed.data) || !boundedSnapshot(parsed.data.before) || !boundedSnapshot(parsed.data.after)) {
|
|
44
|
+
notes.push(unavailable(`${label} evidence is missing, invalid, unsafe, or exceeds report bounds.`));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
runs.push(parsed.data);
|
|
48
|
+
}
|
|
49
|
+
function addNativeRun(directory, platform, runs, notes) {
|
|
50
|
+
const label = platform === 'android' ? 'Android emulator' : 'iOS simulator';
|
|
51
|
+
if (!directory) {
|
|
52
|
+
notes.push(unavailable(`${label} evidence is unavailable.`));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const calibrationValue = readJson(join(directory, 'calibration.json'));
|
|
56
|
+
const acceptanceValue = readJson(join(directory, 'acceptance.json'));
|
|
57
|
+
const verified = acceptanceValue?.verified;
|
|
58
|
+
const target = NativeTargetSchema.safeParse(acceptanceValue?.target);
|
|
59
|
+
const before = SpatialSnapshotSchema.safeParse(calibrationValue);
|
|
60
|
+
const verification = NativeVerificationSchema.safeParse(verified);
|
|
61
|
+
const rawTarget = acceptanceValue?.target;
|
|
62
|
+
const rawBefore = calibrationValue;
|
|
63
|
+
const rawAfter = verified?.snapshot;
|
|
64
|
+
const isPhysical = rawTarget?.kind === 'physical' || rawBefore?.nativeTarget?.kind === 'physical' || rawAfter?.nativeTarget?.kind === 'physical'
|
|
65
|
+
|| target.success && target.data.kind === 'physical'
|
|
66
|
+
|| before.success && before.data.nativeTarget?.kind === 'physical'
|
|
67
|
+
|| verification.success && verification.data.snapshot.nativeTarget?.kind === 'physical';
|
|
68
|
+
if (isPhysical) {
|
|
69
|
+
notes.push(unavailable(`${label} physical-target evidence is excluded from public reports.`));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!target.success || !before.success || !verification.success || verification.data.results.length === 0) {
|
|
73
|
+
notes.push(unavailable(`${label} acceptance or calibration evidence is missing or inconsistent.`));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const after = verification.data.snapshot;
|
|
77
|
+
const receipt = verification.data.receipt;
|
|
78
|
+
const expectedKind = platform === 'android' ? 'emulator' : 'simulator';
|
|
79
|
+
const valid = target.data.platform === platform && target.data.kind === expectedKind
|
|
80
|
+
&& before.data.platform === platform && before.data.nativeTarget?.kind === expectedKind
|
|
81
|
+
&& after.platform === platform && after.nativeTarget?.kind === expectedKind
|
|
82
|
+
&& (verification.data.passed || receipt.nativeStatus !== 'passed')
|
|
83
|
+
&& before.data.nativeTarget.targetId === target.data.targetId
|
|
84
|
+
&& after.nativeTarget.targetId === target.data.targetId;
|
|
85
|
+
if (!valid) {
|
|
86
|
+
notes.push(unavailable(`${label} acceptance or calibration evidence is missing or inconsistent.`));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const passed = verification.data.passed;
|
|
90
|
+
addRun({
|
|
91
|
+
id: platform === 'android' ? 'android-emulator' : 'ios-simulator',
|
|
92
|
+
title: label,
|
|
93
|
+
target: platform === 'android' ? 'android-emulator' : 'ios-simulator',
|
|
94
|
+
before: before.data,
|
|
95
|
+
after,
|
|
96
|
+
results: verification.data.results,
|
|
97
|
+
receipt,
|
|
98
|
+
nativeStatus: passed ? 'passed' : 'failed',
|
|
99
|
+
notes: passed ? [] : ['Native acceptance did not pass.'],
|
|
100
|
+
}, label, runs, notes);
|
|
101
|
+
}
|
|
102
|
+
export function buildEvidenceReport(rawInput) {
|
|
103
|
+
const input = EvidenceInputsSchema.parse(rawInput);
|
|
104
|
+
const runs = [];
|
|
105
|
+
const notes = [];
|
|
106
|
+
if (input.webFile) {
|
|
107
|
+
const web = readJson(input.webFile);
|
|
108
|
+
if (web === undefined)
|
|
109
|
+
notes.push(unavailable('Web evidence is unavailable.'));
|
|
110
|
+
else
|
|
111
|
+
addRun(web, 'Web', runs, notes);
|
|
112
|
+
}
|
|
113
|
+
else
|
|
114
|
+
notes.push(unavailable('Web evidence is unavailable.'));
|
|
115
|
+
addNativeRun(input.androidDirectory, 'android', runs, notes);
|
|
116
|
+
addNativeRun(input.iosDirectory, 'ios', runs, notes);
|
|
117
|
+
return VisualEvidenceReportSchema.parse({
|
|
118
|
+
schemaVersion: 1,
|
|
119
|
+
repository: input.repository,
|
|
120
|
+
revision: input.revision,
|
|
121
|
+
...(input.pullRequest ? { pullRequest: input.pullRequest } : {}),
|
|
122
|
+
runs,
|
|
123
|
+
notes,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function escapeHtml(value) {
|
|
127
|
+
const text = String(value);
|
|
128
|
+
const bounded = text.length > 512 ? text.slice(0, 509) + '…' : text;
|
|
129
|
+
return bounded.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
130
|
+
}
|
|
131
|
+
function number(value) { return Number(value.toFixed(3)).toString(); }
|
|
132
|
+
function rectText(node) {
|
|
133
|
+
if (!node)
|
|
134
|
+
return '—';
|
|
135
|
+
const { x, y, width, height } = node.rect;
|
|
136
|
+
return `${number(x)}, ${number(y)} · ${number(width)} × ${number(height)}`;
|
|
137
|
+
}
|
|
138
|
+
function renderSvg(snapshot, title, colors) {
|
|
139
|
+
const scene = buildOverlayScene(snapshot, {
|
|
140
|
+
schemaVersion: 1, scenarioId: snapshot.scenarioId, profile: snapshot.profile, requiredElementIds: [], constraints: [],
|
|
141
|
+
});
|
|
142
|
+
const { width, height } = scene;
|
|
143
|
+
const nodes = scene.marks.slice(0, MAX_RENDERED_NODES);
|
|
144
|
+
const shapes = nodes.map((node, index) => {
|
|
145
|
+
const color = colors.get(node.id) ?? palette[index % palette.length];
|
|
146
|
+
const rect = node.rect;
|
|
147
|
+
return `<rect x="${number(rect.x)}" y="${number(rect.y)}" width="${number(rect.width)}" height="${number(rect.height)}" fill="${color}" fill-opacity=".13" stroke="${color}" stroke-width="1"><title>${escapeHtml(node.id)} · registered layout box</title></rect>`;
|
|
148
|
+
}).join('');
|
|
149
|
+
return `<svg class="layout" viewBox="0 0 ${number(width)} ${number(height)}" role="img" aria-label="${escapeHtml(title)} registered measured layout boxes"><title>${escapeHtml(title)} · registered measured layout boxes</title><rect width="100%" height="100%" fill="#f8fafc"/>${shapes}</svg>`;
|
|
150
|
+
}
|
|
151
|
+
function renderRun(run) {
|
|
152
|
+
const colors = new Map([...new Set([...run.before.nodes, ...run.after.nodes].map(node => node.id))].sort().map((id, index) => [id, palette[index % palette.length]]));
|
|
153
|
+
const beforeById = new Map(run.before.nodes.map(node => [node.id, node]));
|
|
154
|
+
const afterById = new Map(run.after.nodes.map(node => [node.id, node]));
|
|
155
|
+
const ids = [...new Set([...beforeById.keys(), ...afterById.keys()])].sort();
|
|
156
|
+
const visibleIds = ids.slice(0, MAX_RENDERED_NODES);
|
|
157
|
+
const geometryRows = visibleIds.map(id => `<tr><th>${escapeHtml(id)}</th><td>${rectText(beforeById.get(id))}</td><td>${rectText(afterById.get(id))}</td></tr>`).join('');
|
|
158
|
+
const constraints = run.results.length ? run.results.slice(0, MAX_RENDERED_NODES).map(result => `<tr><td>${escapeHtml(result.constraintId)}</td><td>${escapeHtml(result.status)}</td><td>${escapeHtml(result.elementIds.join(', '))}</td><td>${escapeHtml(result.tolerance)}</td><td>${escapeHtml(result.deviation ?? '—')}</td><td>${escapeHtml(result.message)}</td></tr>`).join('') : '<tr><td colspan="6">No constraint results were supplied.</td></tr>';
|
|
159
|
+
const revisions = [...new Set([...Object.keys(run.before.revisions), ...Object.keys(run.after.revisions)])].sort();
|
|
160
|
+
const revisionRows = revisions.slice(0, MAX_RENDERED_NODES).map(path => `<tr><th>${escapeHtml(path)}</th><td><code>${escapeHtml(run.before.revisions[path] ?? '—')}</code></td><td><code>${escapeHtml(run.after.revisions[path] ?? '—')}</code></td></tr>`).join('');
|
|
161
|
+
const receiptFiles = run.receipt?.files?.length ? run.receipt.files : run.receipt ? [run.receipt] : [];
|
|
162
|
+
const receipt = run.receipt ? `<table><caption>Commit receipt</caption><thead><tr><th>Commit</th><th>Status</th><th>File</th><th>Old revision</th><th>New revision</th></tr></thead><tbody>${receiptFiles.slice(0, MAX_RENDERED_NODES).map(file => `<tr><td>${escapeHtml(run.receipt.commitId)}</td><td>${escapeHtml(run.receipt.nativeStatus)}</td><td>${escapeHtml(file.path)}</td><td><code>${escapeHtml(file.oldHash)}</code></td><td><code>${escapeHtml(file.newHash)}</code></td></tr>`).join('')}</tbody></table>` : '<p>No commit receipt was supplied.</p>';
|
|
163
|
+
const notes = run.notes.length ? `<ul>${run.notes.slice(0, MAX_RENDERED_NODES).map(note => `<li>${escapeHtml(note)}</li>`).join('')}</ul>` : '';
|
|
164
|
+
const truncated = ids.length > MAX_RENDERED_NODES || revisions.length > MAX_RENDERED_NODES || run.results.length > MAX_RENDERED_NODES || receiptFiles.length > MAX_RENDERED_NODES || run.notes.length > MAX_RENDERED_NODES
|
|
165
|
+
? `<p>SVGs show at most ${MAX_RENDERED_NODES} boxes; tables and notes show at most ${MAX_RENDERED_NODES} rows. The JSON manifest retains full evidence.</p>` : '';
|
|
166
|
+
return `<section id="${escapeHtml(run.id)}"><h2>${escapeHtml(run.title)}</h2><p>Target: ${escapeHtml(run.target)} · status: <strong>${escapeHtml(run.nativeStatus)}</strong> · scenario: ${escapeHtml(run.after.scenarioId)}</p>${notes}<h3>Registered measured layout boxes</h3><div class="panels"><figure>${renderSvg(run.before, 'Before', colors)}<figcaption>Before · ${escapeHtml(run.before.profile.window.width)} × ${escapeHtml(run.before.profile.window.height)} window units</figcaption></figure><figure>${renderSvg(run.after, 'After', colors)}<figcaption>After · ${escapeHtml(run.after.profile.window.width)} × ${escapeHtml(run.after.profile.window.height)} window units</figcaption></figure></div><table><caption>Layout box measurements · x, y · width × height in app-root-relative logical units</caption><thead><tr><th>Element</th><th>Before</th><th>After</th></tr></thead><tbody>${geometryRows || '<tr><td colspan="3">No measured layout boxes.</td></tr>'}</tbody></table>${truncated}<table><caption>Constraint results</caption><thead><tr><th>Constraint</th><th>Status</th><th>Elements</th><th>Tolerance</th><th>Deviation</th><th>Message</th></tr></thead><tbody>${constraints}</tbody></table><table><caption>Source revisions</caption><thead><tr><th>Relative source path</th><th>Before</th><th>After</th></tr></thead><tbody>${revisionRows || '<tr><td colspan="3">No source revisions.</td></tr>'}</tbody></table>${receipt}</section>`;
|
|
167
|
+
}
|
|
168
|
+
export function renderEvidenceHtml(report) {
|
|
169
|
+
const links = report.runs.map(run => `<li><a href="#${escapeHtml(run.id)}">${escapeHtml(run.title)} (${escapeHtml(run.nativeStatus)})</a></li>`).join('');
|
|
170
|
+
const notes = report.notes.length ? `<section><h2>Unavailable evidence</h2><ul>${report.notes.slice(0, MAX_RENDERED_NODES).map(note => `<li>${escapeHtml(note)}</li>`).join('')}</ul></section>` : '';
|
|
171
|
+
const runs = report.runs.map(renderRun).join('');
|
|
172
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Visual evidence · ${escapeHtml(report.repository)}</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:1100px;margin:2rem auto;padding:0 1rem;color:#172033}h1,h2{line-height:1.2}section{border-top:1px solid #cbd5e1;margin-top:2rem;padding-top:1rem}.panels{display:flex;flex-wrap:wrap;gap:1rem}.panels figure{flex:1 1 280px;margin:0}.layout{display:block;width:min(100%,640px);height:auto;border:1px solid #cbd5e1}table{border-collapse:collapse;width:100%;margin:1rem 0;font-size:.9rem}caption{text-align:left;font-weight:700;padding:.5rem 0}th,td{border:1px solid #cbd5e1;padding:.4rem;text-align:left;vertical-align:top}code{overflow-wrap:anywhere}.status{color:#475569}</style></head><body><h1>Visual evidence report</h1><p>${escapeHtml(report.repository)} · revision <code>${escapeHtml(report.revision)}</code>${report.pullRequest ? ` · pull request #${escapeHtml(report.pullRequest)}` : ''}</p><p class="status">SVGs place registered measured layout boxes within the window frame using the profile root origin; measurement tables show app-root-relative logical units. These are not native screenshots. <a href="manifest.json">Download the validated JSON manifest</a>.</p><nav aria-label="Evidence runs"><ul>${links || '<li>No measured runs are available.</li>'}</ul></nav>${notes}${runs}</body></html>`;
|
|
173
|
+
}
|
|
174
|
+
export function renderEvidenceSummary(report) {
|
|
175
|
+
const rows = ['| Target | Status |', '| --- | --- |', ...report.runs.map(run => `| ${run.target} | ${run.nativeStatus} |`)];
|
|
176
|
+
if (!report.runs.length)
|
|
177
|
+
rows.push('| unavailable | no measured run |');
|
|
178
|
+
rows.push('', `See the visual evidence report for ${report.repository} at revision ${report.revision}.`);
|
|
179
|
+
return rows.join('\n') + '\n';
|
|
180
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare function initialize(projectRoot: string, dryRun?: boolean, settings?: {
|
|
2
|
+
textProbes?: boolean;
|
|
3
|
+
}): Promise<{
|
|
4
|
+
projectRoot: string;
|
|
5
|
+
rootComponent: string;
|
|
6
|
+
automaticRoot: boolean;
|
|
7
|
+
dryRun: boolean;
|
|
8
|
+
changed: boolean;
|
|
9
|
+
file: string;
|
|
10
|
+
before: string;
|
|
11
|
+
after: string;
|
|
12
|
+
ignoreEdit: {
|
|
13
|
+
before: string;
|
|
14
|
+
after: string;
|
|
15
|
+
};
|
|
16
|
+
install: string;
|
|
17
|
+
mcp: {
|
|
18
|
+
mcpServers: {
|
|
19
|
+
nativemason: {
|
|
20
|
+
command: string;
|
|
21
|
+
args: string[];
|
|
22
|
+
cwd: string;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
}>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFile, writeFile, access } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath, URL } from 'node:url';
|
|
4
|
+
import { Project, Node, SyntaxKind } from 'ts-morph';
|
|
5
|
+
import { BridgeFault } from '@nativemason/contracts';
|
|
6
|
+
const exists = async (p) => access(p).then(() => true, () => false);
|
|
7
|
+
export async function initialize(projectRoot, dryRun = false, settings = {}) {
|
|
8
|
+
const root = path.resolve(projectRoot);
|
|
9
|
+
const app = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
|
10
|
+
if (!app.dependencies?.expo)
|
|
11
|
+
throw new BridgeFault('INVALID_INPUT', 'Initialization requires an Expo project');
|
|
12
|
+
const roots = ['app/_layout', 'src/app/_layout', 'App', 'src/App'].flatMap(base => ['tsx', 'jsx', 'js', 'ts'].map(extension => base + '.' + extension));
|
|
13
|
+
const discovered = [];
|
|
14
|
+
for (const file of roots)
|
|
15
|
+
if (await exists(path.join(root, file)))
|
|
16
|
+
discovered.push(file);
|
|
17
|
+
// Follow the declared Router entry and Expo's documented src/app precedence.
|
|
18
|
+
const routerEntry = typeof app.main === 'string' && /(?:^|\/)expo-router\/entry(?:\.js)?$/.test(app.main);
|
|
19
|
+
const routerDirectory = ['src/app/', 'app/'].find(directory => discovered.some(file => file.startsWith(directory)));
|
|
20
|
+
const candidates = routerEntry ? discovered.filter(file => routerDirectory && file.startsWith(routerDirectory)) : discovered;
|
|
21
|
+
if (candidates.length !== 1)
|
|
22
|
+
throw new BridgeFault('INVALID_INPUT', 'Expected one Expo app root', { candidates });
|
|
23
|
+
const configs = ['babel.config.js', 'babel.config.cjs'];
|
|
24
|
+
const present = [];
|
|
25
|
+
for (const file of configs)
|
|
26
|
+
if (await exists(path.join(root, file)))
|
|
27
|
+
present.push(file);
|
|
28
|
+
if (present.length > 1)
|
|
29
|
+
throw new BridgeFault('UNSUPPORTED_EDIT', 'Multiple Babel configurations require manual selection');
|
|
30
|
+
const file = present[0] ?? 'babel.config.cjs';
|
|
31
|
+
const old = present.length ? await readFile(path.join(root, file), 'utf8') : "module.exports = function(api) { api.cache(true); return { presets: [require.resolve('babel-preset-expo', { paths: [require.resolve('expo/package.json')] })], plugins: [] }; };\n";
|
|
32
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true });
|
|
33
|
+
const source = project.createSourceFile('/babel.js', old);
|
|
34
|
+
const config = source.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression).filter(o => o.getProperty('presets') || o.getProperty('plugins'));
|
|
35
|
+
const rootSource = project.createSourceFile('/root.tsx', await readFile(path.join(root, candidates[0]), 'utf8'));
|
|
36
|
+
const providerTags = new Set();
|
|
37
|
+
let wrappedRoot = false;
|
|
38
|
+
for (const declaration of rootSource.getImportDeclarations())
|
|
39
|
+
if (declaration.getModuleSpecifierValue() === '@nativemason/runtime') {
|
|
40
|
+
for (const imported of declaration.getNamedImports()) {
|
|
41
|
+
if (imported.getName() === 'LayoutProvider')
|
|
42
|
+
providerTags.add(imported.getAliasNode()?.getText() ?? imported.getName());
|
|
43
|
+
if (imported.getName() === 'withLayoutRoot')
|
|
44
|
+
wrappedRoot = true;
|
|
45
|
+
}
|
|
46
|
+
const namespace = declaration.getNamespaceImport();
|
|
47
|
+
if (namespace)
|
|
48
|
+
providerTags.add(namespace.getText() + '.LayoutProvider');
|
|
49
|
+
}
|
|
50
|
+
const manualProvider = wrappedRoot || rootSource.getDescendants().some(node => (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) && providerTags.has(node.getTagNameNode().getText()));
|
|
51
|
+
const options = { autoInstrument: true, textProbes: settings.textProbes ?? false, nativewind: Boolean(app.dependencies?.nativewind), ...(manualProvider ? {} : { rootComponent: candidates[0] }) };
|
|
52
|
+
let updated = old;
|
|
53
|
+
if (!old.includes('nativemason/babel')) {
|
|
54
|
+
if (config.length !== 1)
|
|
55
|
+
throw new BridgeFault('UNSUPPORTED_EDIT', 'Babel configuration cannot be safely edited', { suggestedPlugin: ["nativemason/babel", options] });
|
|
56
|
+
const plugins = config[0].getProperty('plugins');
|
|
57
|
+
const text = `[require.resolve('nativemason/babel'), { projectRoot: __dirname, ...${JSON.stringify(options)} }]`;
|
|
58
|
+
if (!plugins)
|
|
59
|
+
config[0].addPropertyAssignment({ name: 'plugins', initializer: `[${text}]` });
|
|
60
|
+
else if (Node.isPropertyAssignment(plugins) && Node.isArrayLiteralExpression(plugins.getInitializer()))
|
|
61
|
+
plugins.getInitializerIfKindOrThrow(SyntaxKind.ArrayLiteralExpression).insertElement(0, text);
|
|
62
|
+
else
|
|
63
|
+
throw new BridgeFault('UNSUPPORTED_EDIT', 'Computed Babel plugins cannot be safely edited', { suggestedPlugin: ["nativemason/babel", options] });
|
|
64
|
+
updated = source.getFullText();
|
|
65
|
+
}
|
|
66
|
+
const ignorePath = path.join(root, '.gitignore');
|
|
67
|
+
const ignoreBefore = await readFile(ignorePath, 'utf8').catch(error => { if (error.code === 'ENOENT')
|
|
68
|
+
return ''; throw error; });
|
|
69
|
+
let ignoreAfter = ignoreBefore;
|
|
70
|
+
for (const entry of ['/.nativemason/', '/artifacts/nativemason/'])
|
|
71
|
+
if (!ignoreBefore.split(/\r?\n/).includes(entry))
|
|
72
|
+
ignoreAfter += (ignoreAfter && !ignoreAfter.endsWith('\n') ? '\n' : '') + entry + '\n';
|
|
73
|
+
if (!dryRun) {
|
|
74
|
+
if (updated !== old)
|
|
75
|
+
await writeFile(path.join(root, file), updated, 'utf8');
|
|
76
|
+
if (ignoreAfter !== ignoreBefore)
|
|
77
|
+
await writeFile(ignorePath, ignoreAfter, 'utf8');
|
|
78
|
+
}
|
|
79
|
+
const mcp = { mcpServers: { nativemason: { command: 'node', args: [fileURLToPath(new URL('../cli.js', import.meta.url))], cwd: root } } };
|
|
80
|
+
return { projectRoot: root, rootComponent: candidates[0], automaticRoot: !manualProvider, dryRun, changed: updated !== old || ignoreAfter !== ignoreBefore, file, before: old, after: updated, ignoreEdit: { before: ignoreBefore, after: ignoreAfter }, install: 'Install matching nativemason, @nativemason/runtime and @nativemason/contracts packages before starting Expo.', mcp };
|
|
81
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type PluginObj, type PluginPass } from '@babel/core';
|
|
2
|
+
import * as t from '@babel/types';
|
|
3
|
+
interface Options {
|
|
4
|
+
projectRoot?: string;
|
|
5
|
+
autoInstrument?: boolean;
|
|
6
|
+
rootComponent?: string;
|
|
7
|
+
textProbes?: boolean;
|
|
8
|
+
nativewind?: boolean;
|
|
9
|
+
}
|
|
10
|
+
type State = PluginPass & {
|
|
11
|
+
opts: Options;
|
|
12
|
+
bridgeHosts?: Map<string, t.Identifier>;
|
|
13
|
+
bridgeKinds?: Map<string, string>;
|
|
14
|
+
bridgeRoot?: t.Identifier;
|
|
15
|
+
bridgeRevision?: t.Identifier;
|
|
16
|
+
bridgeBindings?: Map<string, unknown>;
|
|
17
|
+
bridgeStyleImports?: Map<string, {
|
|
18
|
+
file: string;
|
|
19
|
+
binding: unknown;
|
|
20
|
+
}>;
|
|
21
|
+
bridgeDependencyCache?: Map<string, string[]>;
|
|
22
|
+
bridgeOrdinal?: number;
|
|
23
|
+
};
|
|
24
|
+
export default function bridgeSourcePlugin(): PluginObj<State>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { parseSync } from '@babel/core';
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import * as t from '@babel/types';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { dirname, relative, resolve, sep } from 'node:path';
|
|
6
|
+
const hosts = new Set(['View', 'Text', 'Pressable', 'TextInput', 'ScrollView', 'Image']);
|
|
7
|
+
function staticCallback(expression) {
|
|
8
|
+
if (!expression || (!t.isArrowFunctionExpression(expression) && !t.isFunctionExpression(expression)) || expression.async || expression.generator || expression.params.length !== 1)
|
|
9
|
+
return false;
|
|
10
|
+
const parameter = expression.params[0];
|
|
11
|
+
if (!t.isObjectPattern(parameter) || parameter.properties.length !== 1)
|
|
12
|
+
return false;
|
|
13
|
+
const pressed = parameter.properties[0];
|
|
14
|
+
if (!t.isObjectProperty(pressed) || !t.isIdentifier(pressed.key, { name: 'pressed' }) || !t.isIdentifier(pressed.value))
|
|
15
|
+
return false;
|
|
16
|
+
const body = t.isBlockStatement(expression.body) ? expression.body.body.length === 1 && t.isReturnStatement(expression.body.body[0]) ? expression.body.body[0].argument : null : expression.body;
|
|
17
|
+
if (!t.isConditionalExpression(body))
|
|
18
|
+
return false;
|
|
19
|
+
const test = t.isUnaryExpression(body.test, { operator: '!' }) ? body.test.argument : body.test;
|
|
20
|
+
if (!t.isIdentifier(test, { name: pressed.value.name }))
|
|
21
|
+
return false;
|
|
22
|
+
return [body.consequent, body.alternate].every(arm => t.isObjectExpression(arm) && arm.properties.every(property => t.isObjectProperty(property) && !property.computed && !property.shorthand && (t.isIdentifier(property.key) || t.isStringLiteral(property.key)) && (t.isStringLiteral(property.value) || t.isNumericLiteral(property.value) || t.isBooleanLiteral(property.value) || t.isNullLiteral(property.value) || (t.isUnaryExpression(property.value) && ['+', '-'].includes(property.value.operator) && t.isNumericLiteral(property.value.argument)))));
|
|
23
|
+
}
|
|
24
|
+
function development(state) { const caller = state.file.opts.caller; if (caller && 'dev' in caller && caller.dev === false)
|
|
25
|
+
return false; const filename = state.filename ?? state.file.opts.filename ?? ''; if (filename.includes('\0') || !/\.[cm]?[jt]sx?$/.test(filename))
|
|
26
|
+
return false; return process.env.NODE_ENV !== 'production' && state.file.opts.envName !== 'production' && !state.filename?.split(/[\\/]/).includes('node_modules'); }
|
|
27
|
+
function physicalPath(path) { try {
|
|
28
|
+
return realpathSync(path);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return resolve(path);
|
|
32
|
+
} }
|
|
33
|
+
function filePath(state) {
|
|
34
|
+
const filename = state.filename ?? state.file.opts.filename;
|
|
35
|
+
if (!filename)
|
|
36
|
+
return null;
|
|
37
|
+
return relative(physicalPath(state.opts.projectRoot ?? state.file.opts.root ?? process.cwd()), physicalPath(filename)).split(sep).join('/');
|
|
38
|
+
}
|
|
39
|
+
function localModule(from, specifier) {
|
|
40
|
+
if (!specifier.startsWith('.'))
|
|
41
|
+
return;
|
|
42
|
+
const base = resolve(dirname(from), specifier);
|
|
43
|
+
return [base, ...['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs'].map(ext => base + ext), ...['index.ts', 'index.tsx', 'index.js', 'index.jsx'].map(file => resolve(base, file))].find(file => /\.[cm]?[jt]sx?$/.test(file) && existsSync(file));
|
|
44
|
+
}
|
|
45
|
+
function styleModules(file, root, seen = new Set()) {
|
|
46
|
+
if (seen.has(file))
|
|
47
|
+
return [];
|
|
48
|
+
seen.add(file);
|
|
49
|
+
file = physicalPath(file);
|
|
50
|
+
root = physicalPath(root);
|
|
51
|
+
const relativePath = relative(root, file).split(sep).join('/');
|
|
52
|
+
if (relativePath.startsWith('../'))
|
|
53
|
+
return [];
|
|
54
|
+
const result = [relativePath];
|
|
55
|
+
const ast = parseSync(readFileSync(file, 'utf8'), { filename: file, configFile: false, babelrc: false, parserOpts: { plugins: ['typescript', 'jsx'], sourceType: 'unambiguous' } });
|
|
56
|
+
if (ast)
|
|
57
|
+
t.traverseFast(ast, node => { if ((t.isImportDeclaration(node) || t.isExportNamedDeclaration(node) || t.isExportAllDeclaration(node)) && node.source) {
|
|
58
|
+
const dependency = localModule(file, node.source.value);
|
|
59
|
+
if (dependency)
|
|
60
|
+
result.push(...styleModules(dependency, root, seen));
|
|
61
|
+
} });
|
|
62
|
+
return [...new Set(result)];
|
|
63
|
+
}
|
|
64
|
+
export default function bridgeSourcePlugin() {
|
|
65
|
+
return {
|
|
66
|
+
name: 'nativemason-source',
|
|
67
|
+
visitor: {
|
|
68
|
+
Program: {
|
|
69
|
+
enter(path, state) {
|
|
70
|
+
if (!development(state))
|
|
71
|
+
return;
|
|
72
|
+
const projectPath = filePath(state);
|
|
73
|
+
if (!projectPath || projectPath.startsWith('../'))
|
|
74
|
+
return;
|
|
75
|
+
state.bridgeRevision = path.scope.generateUidIdentifier("registerSourceRevision");
|
|
76
|
+
state.bridgeOrdinal = 0;
|
|
77
|
+
state.bridgeHosts = new Map();
|
|
78
|
+
state.bridgeKinds = new Map();
|
|
79
|
+
state.bridgeBindings = new Map();
|
|
80
|
+
state.bridgeStyleImports = new Map();
|
|
81
|
+
state.bridgeDependencyCache = new Map();
|
|
82
|
+
for (const statement of path.node.body) {
|
|
83
|
+
if (!t.isImportDeclaration(statement) || statement.importKind === 'type')
|
|
84
|
+
continue;
|
|
85
|
+
const file = localModule(state.filename ?? state.file.opts.filename, statement.source.value);
|
|
86
|
+
if (file)
|
|
87
|
+
for (const specifier of statement.specifiers)
|
|
88
|
+
state.bridgeStyleImports.set(specifier.local.name, { file, binding: path.scope.getBinding(specifier.local.name) });
|
|
89
|
+
}
|
|
90
|
+
const imports = [t.importSpecifier(state.bridgeRevision, t.identifier("registerSourceRevision"))];
|
|
91
|
+
if (state.opts.nativewind)
|
|
92
|
+
path.unshiftContainer("body", t.importDeclaration([], t.stringLiteral("@nativemason/runtime/nativewind")));
|
|
93
|
+
if (state.opts.autoInstrument)
|
|
94
|
+
for (const statement of path.node.body) {
|
|
95
|
+
if (!t.isImportDeclaration(statement) || statement.source.value !== 'react-native')
|
|
96
|
+
continue;
|
|
97
|
+
for (const specifier of statement.specifiers) {
|
|
98
|
+
if (t.isImportNamespaceSpecifier(specifier)) {
|
|
99
|
+
for (const host of hosts) {
|
|
100
|
+
const alias = path.scope.generateUidIdentifier('NativeMason' + host);
|
|
101
|
+
state.bridgeHosts.set(specifier.local.name + '.' + host, alias);
|
|
102
|
+
state.bridgeKinds.set(specifier.local.name + '.' + host, host);
|
|
103
|
+
state.bridgeBindings.set(specifier.local.name + '.' + host, path.scope.getBinding(specifier.local.name));
|
|
104
|
+
imports.push(t.importSpecifier(alias, t.identifier('Layout' + host)));
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!t.isImportSpecifier(specifier) || specifier.importKind === 'type' || statement.importKind === 'type')
|
|
109
|
+
continue;
|
|
110
|
+
const name = t.isIdentifier(specifier.imported) ? specifier.imported.name : specifier.imported.value;
|
|
111
|
+
if (!hosts.has(name))
|
|
112
|
+
continue;
|
|
113
|
+
const alias = path.scope.generateUidIdentifier(`NativeMason${name}`);
|
|
114
|
+
state.bridgeHosts.set(specifier.local.name, alias);
|
|
115
|
+
state.bridgeKinds.set(specifier.local.name, name);
|
|
116
|
+
state.bridgeBindings.set(specifier.local.name, path.scope.getBinding(specifier.local.name));
|
|
117
|
+
imports.push(t.importSpecifier(alias, t.identifier(`Layout${name}`)));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (state.opts.rootComponent?.replaceAll('\\', '/') === projectPath) {
|
|
121
|
+
state.bridgeRoot = path.scope.generateUidIdentifier('withLayoutRoot');
|
|
122
|
+
imports.push(t.importSpecifier(state.bridgeRoot, t.identifier('withLayoutRoot')));
|
|
123
|
+
}
|
|
124
|
+
if (imports.length)
|
|
125
|
+
path.unshiftContainer('body', t.importDeclaration(imports, t.stringLiteral('@nativemason/runtime')));
|
|
126
|
+
if (!state.bridgeRoot)
|
|
127
|
+
return;
|
|
128
|
+
for (const child of path.get('body')) {
|
|
129
|
+
if (!child.isExportDefaultDeclaration())
|
|
130
|
+
continue;
|
|
131
|
+
const declaration = child.node.declaration;
|
|
132
|
+
if (t.isTSDeclareFunction(declaration))
|
|
133
|
+
throw child.buildCodeFrameError('NativeMason root must have a runtime default export');
|
|
134
|
+
const options = t.objectExpression([t.objectProperty(t.identifier('textProbes'), t.booleanLiteral(state.opts.textProbes ?? false))]);
|
|
135
|
+
if ((t.isFunctionDeclaration(declaration) || t.isClassDeclaration(declaration)) && declaration.id) {
|
|
136
|
+
child.replaceWithMultiple([declaration, t.exportDefaultDeclaration(t.callExpression(state.bridgeRoot, [t.identifier(declaration.id.name), options]))]);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const expression = t.isDeclaration(declaration) ? t.toExpression(declaration) : declaration;
|
|
140
|
+
child.node.declaration = t.callExpression(state.bridgeRoot, [expression, options]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
exit(path, state) {
|
|
145
|
+
if (!development(state))
|
|
146
|
+
return;
|
|
147
|
+
const projectPath = filePath(state);
|
|
148
|
+
if (state.bridgeRevision && projectPath && !projectPath.startsWith('../'))
|
|
149
|
+
path.pushContainer('body', t.expressionStatement(t.callExpression(state.bridgeRevision, [t.stringLiteral(projectPath), t.stringLiteral(createHash('sha256').update(state.file.code).digest('hex'))])));
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
JSXOpeningElement(path, state) {
|
|
153
|
+
if (!development(state))
|
|
154
|
+
return;
|
|
155
|
+
const name = path.node.name;
|
|
156
|
+
const member = t.isJSXMemberExpression(name) && t.isJSXIdentifier(name.object) && t.isJSXIdentifier(name.property) ? name : null;
|
|
157
|
+
const localName = t.isJSXIdentifier(name) ? name.name : member && t.isJSXIdentifier(member.object) ? member.object.name : null;
|
|
158
|
+
if (!localName)
|
|
159
|
+
return;
|
|
160
|
+
const binding = path.scope.getBinding(localName);
|
|
161
|
+
const lookupName = member ? localName + '.' + member.property.name : localName;
|
|
162
|
+
const automatic = Boolean(binding && state.opts.autoInstrument && state.bridgeHosts?.has(lookupName) && state.bridgeBindings?.get(lookupName) === binding);
|
|
163
|
+
let explicit = false;
|
|
164
|
+
let explicitKind;
|
|
165
|
+
if (!automatic) {
|
|
166
|
+
if (!binding?.path.isImportSpecifier())
|
|
167
|
+
return;
|
|
168
|
+
const imported = binding.path.node.imported;
|
|
169
|
+
const originalName = t.isIdentifier(imported) ? imported.name : imported.value;
|
|
170
|
+
const declaration = binding.path.parentPath;
|
|
171
|
+
explicitKind = originalName.slice(6);
|
|
172
|
+
explicit = Boolean(declaration?.isImportDeclaration() && declaration.node.source.value === '@nativemason/runtime' && originalName.startsWith('Layout') && hosts.has(originalName.slice(6)));
|
|
173
|
+
}
|
|
174
|
+
if (!automatic && !explicit)
|
|
175
|
+
return;
|
|
176
|
+
if (path.node.attributes.some(attribute => t.isJSXAttribute(attribute) && ['__bridgeSource', '__bridgeId'].includes(String(attribute.name.name))))
|
|
177
|
+
throw path.buildCodeFrameError('__bridgeSource is reserved for source instrumentation');
|
|
178
|
+
const element = path.parentPath.node;
|
|
179
|
+
if (!t.isJSXElement(element) || element.start == null || element.end == null || !element.loc)
|
|
180
|
+
return;
|
|
181
|
+
const projectPath = filePath(state);
|
|
182
|
+
if (!projectPath || projectPath.startsWith('../'))
|
|
183
|
+
throw path.buildCodeFrameError('Instrumented file must be inside projectRoot');
|
|
184
|
+
const dependencies = new Set(), visited = new Set();
|
|
185
|
+
const inspectExpression = (expression) => {
|
|
186
|
+
if (!expression.node || visited.has(expression.node))
|
|
187
|
+
return;
|
|
188
|
+
visited.add(expression.node);
|
|
189
|
+
const inspectIdentifier = (identifier) => {
|
|
190
|
+
const binding = identifier.scope.getBinding(identifier.node.name), imported = state.bridgeStyleImports?.get(identifier.node.name);
|
|
191
|
+
if (imported && imported.binding === binding) {
|
|
192
|
+
let paths = state.bridgeDependencyCache?.get(imported.file);
|
|
193
|
+
if (!paths) {
|
|
194
|
+
paths = styleModules(imported.file, resolve(state.opts.projectRoot ?? state.file.opts.root ?? process.cwd()));
|
|
195
|
+
state.bridgeDependencyCache?.set(imported.file, paths);
|
|
196
|
+
}
|
|
197
|
+
for (const file of paths)
|
|
198
|
+
dependencies.add(file);
|
|
199
|
+
}
|
|
200
|
+
else if (binding?.path.isVariableDeclarator()) {
|
|
201
|
+
const initializer = binding.path.get('init');
|
|
202
|
+
if (initializer.node)
|
|
203
|
+
inspectExpression(initializer);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
if (expression.isReferencedIdentifier())
|
|
207
|
+
inspectIdentifier(expression);
|
|
208
|
+
expression.traverse({ ReferencedIdentifier: inspectIdentifier });
|
|
209
|
+
};
|
|
210
|
+
for (const attribute of path.get('attributes'))
|
|
211
|
+
if (attribute.isJSXAttribute() && t.isJSXIdentifier(attribute.node.name) && ['style', 'className'].includes(attribute.node.name.name)) {
|
|
212
|
+
const value = attribute.get('value');
|
|
213
|
+
if (value.isJSXExpressionContainer()) {
|
|
214
|
+
const expression = value.get('expression');
|
|
215
|
+
if (expression.node)
|
|
216
|
+
inspectExpression(expression);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const styleAttribute = path.node.attributes.find(attribute => t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name, { name: 'style' }));
|
|
220
|
+
const callback = (automatic ? state.bridgeKinds?.get(lookupName) : explicitKind) === 'Pressable' && t.isJSXAttribute(styleAttribute) && t.isJSXExpressionContainer(styleAttribute.value) && staticCallback(styleAttribute.value.expression) && !path.node.attributes.some(attribute => t.isJSXSpreadAttribute(attribute));
|
|
221
|
+
const source = { path: projectPath, ...(callback ? { styleForm: 'pressable-state' } : {}), ...(dependencies.size ? { dependencies: [...dependencies].sort() } : {}), hash: createHash('sha256').update(state.file.code).digest('hex'), start: element.start, end: element.end, line: element.loc.start.line, column: element.loc.start.column };
|
|
222
|
+
if (automatic) {
|
|
223
|
+
const alias = state.bridgeHosts?.get(lookupName);
|
|
224
|
+
if (!alias)
|
|
225
|
+
return;
|
|
226
|
+
path.node.name = t.jsxIdentifier(alias.name);
|
|
227
|
+
if (element.closingElement)
|
|
228
|
+
element.closingElement.name = t.jsxIdentifier(alias.name);
|
|
229
|
+
}
|
|
230
|
+
path.pushContainer('attributes', t.jsxAttribute(t.jsxIdentifier('__bridgeId'), t.stringLiteral(`${projectPath}#${state.bridgeOrdinal = (state.bridgeOrdinal ?? 0) + 1}`)));
|
|
231
|
+
path.pushContainer('attributes', t.jsxAttribute(t.jsxIdentifier('__bridgeSource'), t.jsxExpressionContainer(t.valueToNode(source))));
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { BridgeFault, ToolSchemas, ToolOutputSchemas, ToolMcpOutputSchemas } from '@nativemason/contracts';
|
|
3
|
+
import { BridgeApplication } from '../application/index.js';
|
|
4
|
+
const descriptions = {
|
|
5
|
+
import_design_intent: 'Convert Figma auto-layout JSON or agent-provided screenshot annotations into a reviewable spec. Does not load or accept design intent.',
|
|
6
|
+
inspect_accessibility: 'Report authored native accessibility metadata, browser accessibility evidence, and bounded RTL checks with unavailable evidence explicit.',
|
|
7
|
+
open_session: 'Connect a managed Chromium mirror and selected Android/iOS measurement runtime. Returns native connection port and token.',
|
|
8
|
+
load_wireframe_spec: 'Load explicit JSON constraints matching the session scenario/profile.',
|
|
9
|
+
inspect_ui_spatial_graph: 'Capture stable instrumented layout and evaluate constraints. Replaces the trial baseline.',
|
|
10
|
+
capture_annotated_overlay: 'Capture a matching live Chromium screenshot with numbered element marks and rectangle targets; native snapshots are unsupported.',
|
|
11
|
+
simulate_layout_mutation: 'Replace the current trial using React Native style props, without source writes. Null patch resets trials.',
|
|
12
|
+
commit_rn_ast_patch: 'Preview or apply a scoped source transaction. Explicit allowNativePending may defer unavailable native-only text checks; verification remains required.',
|
|
13
|
+
verify_native_layout: 'Measure committed Android/iOS layout for matching source revisions, scenario, profile, and required interaction state; return evidence and pass/fail.',
|
|
14
|
+
revert_commit: 'Revert a transaction with source hash checks; native verification remains pending.',
|
|
15
|
+
find_elements: 'Search measured elements by ID, role, kind, or source path.',
|
|
16
|
+
infer_constraints: 'Derive a reviewable specification and default findings from a known-good snapshot.',
|
|
17
|
+
suggest_fix: 'Rank bounded geometry, text-fit, and wrapping candidates using React browser trials across explicit profiles. Optional native baseline seeds proposals, never native trial claims.',
|
|
18
|
+
run_scenario: 'Reset and execute a typed scenario script.',
|
|
19
|
+
run_profile_matrix: 'Run scenario profiles and report supported, failed, or unavailable cells.',
|
|
20
|
+
close_session: 'Close Chromium and native transport and discard session state.',
|
|
21
|
+
};
|
|
22
|
+
export function createMcpServer(application = new BridgeApplication()) {
|
|
23
|
+
const server = new McpServer({ name: 'nativemason', version: '0.3.0' });
|
|
24
|
+
server.registerPrompt('layout-workflow', { description: 'Measured layout correction workflow' }, async () => ({ messages: [{ role: 'user', content: { type: 'text', text: 'Open a session for the same named scenario on web and native. Inspect measurements and diagnostics; load or infer and review constraints. Use suggest_fix or batch trials, preview the transaction and blast radius, then apply only an accepted candidate. Verify matching committed revisions on the selected native target. Report unavailable evidence; use revert_commit when requested and verify the revert. Close the session.' } }] }));
|
|
25
|
+
for (const name of Object.keys(ToolSchemas)) {
|
|
26
|
+
server.registerTool(name, { description: descriptions[name], inputSchema: ToolSchemas[name].shape, outputSchema: ToolMcpOutputSchemas[name] }, async (input) => {
|
|
27
|
+
try {
|
|
28
|
+
const result = await application.execute(name, input);
|
|
29
|
+
if (name === 'capture_annotated_overlay') {
|
|
30
|
+
const { image, ...metadata } = ToolOutputSchemas.capture_annotated_overlay.parse(result);
|
|
31
|
+
return { structuredContent: result, content: [{ type: 'image', data: image, mimeType: 'image/png' }, { type: 'text', text: JSON.stringify(metadata) }] };
|
|
32
|
+
}
|
|
33
|
+
return { structuredContent: result, content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
const fault = error instanceof BridgeFault ? { code: error.code, message: error.message, details: error.details } : { code: 'INTERNAL_ERROR', message: error instanceof Error ? error.message : String(error) };
|
|
37
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: fault }) }] };
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return server;
|
|
42
|
+
}
|