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,374 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createTwoFilesPatch } from 'diff';
|
|
3
|
+
import { Node, Project, SyntaxKind, ts } from 'ts-morph';
|
|
4
|
+
import { isLiteralStyleObject, valueText } from './source-format.js';
|
|
5
|
+
import { RnStylePatchSchema, SourceRefSchema } from '@nativemason/contracts';
|
|
6
|
+
export const hashSource = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
|
|
7
|
+
class Unsupported extends Error {
|
|
8
|
+
}
|
|
9
|
+
function reject(message) { throw new Unsupported(message); }
|
|
10
|
+
function hasTrailingComma(sourceText) {
|
|
11
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.Standard, sourceText);
|
|
12
|
+
return scanner.scan() === ts.SyntaxKind.CommaToken && scanner.scan() === ts.SyntaxKind.EndOfFileToken;
|
|
13
|
+
}
|
|
14
|
+
function staticLiteral(expression) {
|
|
15
|
+
if (Node.isStringLiteral(expression) || Node.isNumericLiteral(expression) || Node.isNoSubstitutionTemplateLiteral(expression))
|
|
16
|
+
return true;
|
|
17
|
+
if ([SyntaxKind.TrueKeyword, SyntaxKind.FalseKeyword, SyntaxKind.NullKeyword].includes(expression.getKind()))
|
|
18
|
+
return true;
|
|
19
|
+
return Node.isPrefixUnaryExpression(expression) && ['+', '-'].includes(expression.getText().slice(0, 1)) && Node.isNumericLiteral(expression.getOperand());
|
|
20
|
+
}
|
|
21
|
+
function validateObject(object) {
|
|
22
|
+
const names = new Set();
|
|
23
|
+
for (const property of object.getProperties()) {
|
|
24
|
+
if (!Node.isPropertyAssignment(property))
|
|
25
|
+
reject('Styles must contain only explicit literal property assignments');
|
|
26
|
+
const nameNode = property.getNameNode();
|
|
27
|
+
if (!(Node.isIdentifier(nameNode) || Node.isStringLiteral(nameNode)))
|
|
28
|
+
reject('Computed style property names are unsupported');
|
|
29
|
+
const name = Node.isStringLiteral(nameNode) ? nameNode.getLiteralValue() : nameNode.getText();
|
|
30
|
+
if (names.has(name))
|
|
31
|
+
reject('Duplicate style properties are unsupported');
|
|
32
|
+
names.add(name);
|
|
33
|
+
const value = property.getInitializer();
|
|
34
|
+
if (!value || !staticLiteral(value))
|
|
35
|
+
reject('Style values must be static primitive literals');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function localStyleObject(expression, source) {
|
|
39
|
+
if (Node.isObjectLiteralExpression(expression))
|
|
40
|
+
return expression;
|
|
41
|
+
if (!Node.isPropertyAccessExpression(expression) || !Node.isIdentifier(expression.getExpression()))
|
|
42
|
+
reject('Only inline objects or local StyleSheet.create entries are supported');
|
|
43
|
+
const variableId = expression.getExpression();
|
|
44
|
+
const variableSymbol = variableId.getSymbol();
|
|
45
|
+
const declaration = variableSymbol?.getDeclarations().find(Node.isVariableDeclaration);
|
|
46
|
+
if (!declaration || declaration.getSourceFile() !== source)
|
|
47
|
+
reject('Imported and unresolved styles are unsupported');
|
|
48
|
+
const initializer = declaration.getInitializer();
|
|
49
|
+
if (!initializer || !Node.isCallExpression(initializer))
|
|
50
|
+
reject('Styles must be declared with StyleSheet.create');
|
|
51
|
+
const call = initializer.getExpression();
|
|
52
|
+
if (!Node.isPropertyAccessExpression(call) || call.getName() !== 'create' || !Node.isIdentifier(call.getExpression()))
|
|
53
|
+
reject('Styles must be declared with an imported StyleSheet.create');
|
|
54
|
+
const importId = call.getExpression();
|
|
55
|
+
const importSpecifier = importId.getSymbol()?.getDeclarations().find(Node.isImportSpecifier);
|
|
56
|
+
if (!importSpecifier || importSpecifier.getName() !== 'StyleSheet' || importSpecifier.getImportDeclaration().getModuleSpecifierValue() !== 'react-native')
|
|
57
|
+
reject('StyleSheet must be a named react-native import');
|
|
58
|
+
const args = initializer.getArguments();
|
|
59
|
+
if (args.length !== 1 || !Node.isObjectLiteralExpression(args[0]))
|
|
60
|
+
reject('StyleSheet.create must receive one literal object');
|
|
61
|
+
const entryName = expression.getName();
|
|
62
|
+
const entryNames = new Set();
|
|
63
|
+
for (const property of args[0].getProperties()) {
|
|
64
|
+
if (!Node.isPropertyAssignment(property))
|
|
65
|
+
reject('StyleSheet.create must not contain spreads or shorthand entries');
|
|
66
|
+
const nameNode = property.getNameNode();
|
|
67
|
+
if (!(Node.isIdentifier(nameNode) || Node.isStringLiteral(nameNode)))
|
|
68
|
+
reject('Computed StyleSheet entry names are unsupported');
|
|
69
|
+
const name = Node.isStringLiteral(nameNode) ? nameNode.getLiteralValue() : nameNode.getText();
|
|
70
|
+
if (entryNames.has(name))
|
|
71
|
+
reject('Duplicate StyleSheet entries are unsupported');
|
|
72
|
+
entryNames.add(name);
|
|
73
|
+
}
|
|
74
|
+
const entry = args[0].getProperty(entryName);
|
|
75
|
+
if (!entry || !Node.isPropertyAssignment(entry))
|
|
76
|
+
reject('StyleSheet entry must be a literal property');
|
|
77
|
+
const object = entry.getInitializer();
|
|
78
|
+
if (!object || !Node.isObjectLiteralExpression(object))
|
|
79
|
+
reject('StyleSheet entry must be a literal object');
|
|
80
|
+
let usages = 0;
|
|
81
|
+
for (const id of source.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
82
|
+
if (id.getSymbol() !== variableSymbol || id === declaration.getNameNode())
|
|
83
|
+
continue;
|
|
84
|
+
const parent = id.getParent();
|
|
85
|
+
if (!parent || !Node.isPropertyAccessExpression(parent) || parent.getExpression() !== id)
|
|
86
|
+
reject('StyleSheet object escapes its declaration');
|
|
87
|
+
if (parent.getName() === entryName)
|
|
88
|
+
usages++;
|
|
89
|
+
}
|
|
90
|
+
if (usages !== 1)
|
|
91
|
+
reject('Shared StyleSheet entries cannot be edited automatically');
|
|
92
|
+
return object;
|
|
93
|
+
}
|
|
94
|
+
function rejectRepeatedSite(target) {
|
|
95
|
+
for (const ancestor of target.getAncestors()) {
|
|
96
|
+
if (Node.isForStatement(ancestor) || Node.isForOfStatement(ancestor) || Node.isForInStatement(ancestor) || Node.isWhileStatement(ancestor) || Node.isDoStatement(ancestor))
|
|
97
|
+
reject('Repeated JSX source sites cannot be edited automatically');
|
|
98
|
+
// A JSX node inside a callback may render any number of instances. Named component
|
|
99
|
+
// functions are allowed; callbacks supplied to another function are deliberately not.
|
|
100
|
+
if (Node.isArrowFunction(ancestor) || Node.isFunctionExpression(ancestor)) {
|
|
101
|
+
const parent = ancestor.getParent();
|
|
102
|
+
if (parent && (Node.isCallExpression(parent) || Node.isArrayLiteralExpression(parent)))
|
|
103
|
+
reject('JSX callback source sites cannot be edited automatically');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function splitNativeWindVariant(token) {
|
|
108
|
+
let bracketDepth = 0;
|
|
109
|
+
let variantSeparator = -1;
|
|
110
|
+
for (let i = 0; i < token.length; i++) {
|
|
111
|
+
if (token[i] === '\\') {
|
|
112
|
+
i++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (token[i] === '[')
|
|
116
|
+
bracketDepth++;
|
|
117
|
+
else if (token[i] === ']')
|
|
118
|
+
bracketDepth = Math.max(0, bracketDepth - 1);
|
|
119
|
+
else if (token[i] === ':' && bracketDepth === 0)
|
|
120
|
+
variantSeparator = i;
|
|
121
|
+
}
|
|
122
|
+
return variantSeparator < 0
|
|
123
|
+
? { hasVariant: false, utility: token }
|
|
124
|
+
: { hasVariant: true, utility: token.slice(variantSeparator + 1) };
|
|
125
|
+
}
|
|
126
|
+
function pressableStateObject(opening, state) {
|
|
127
|
+
if (!state)
|
|
128
|
+
reject('Pressable callback edits require an explicit pressed or rest state');
|
|
129
|
+
const tag = opening.getTagNameNode();
|
|
130
|
+
let supported = false;
|
|
131
|
+
if (Node.isIdentifier(tag)) {
|
|
132
|
+
const imported = tag.getSymbol()?.getDeclarations().find(Node.isImportSpecifier);
|
|
133
|
+
supported = Boolean(imported && ((imported.getName() === 'Pressable' && imported.getImportDeclaration().getModuleSpecifierValue() === 'react-native') || (imported.getName() === 'LayoutPressable' && imported.getImportDeclaration().getModuleSpecifierValue() === '@nativemason/runtime')));
|
|
134
|
+
}
|
|
135
|
+
else if (Node.isPropertyAccessExpression(tag)) {
|
|
136
|
+
const imported = tag.getExpression().getSymbol()?.getDeclarations().find(Node.isNamespaceImport);
|
|
137
|
+
supported = Boolean(imported && ((tag.getName() === 'Pressable' && imported.getFirstAncestorByKindOrThrow(SyntaxKind.ImportDeclaration).getModuleSpecifierValue() === 'react-native') || (tag.getName() === 'LayoutPressable' && imported.getFirstAncestorByKindOrThrow(SyntaxKind.ImportDeclaration).getModuleSpecifierValue() === '@nativemason/runtime')));
|
|
138
|
+
}
|
|
139
|
+
if (!supported)
|
|
140
|
+
reject('State callback editing requires an imported RN Pressable or LayoutPressable');
|
|
141
|
+
const attributes = opening.getAttributes().filter(attribute => Node.isJsxAttribute(attribute) && attribute.getNameNode().getText() === 'style');
|
|
142
|
+
if (attributes.length !== 1 || !Node.isJsxAttribute(attributes[0]))
|
|
143
|
+
reject('State callback editing requires one style attribute');
|
|
144
|
+
const initializer = attributes[0].getInitializer();
|
|
145
|
+
if (!initializer || !Node.isJsxExpression(initializer))
|
|
146
|
+
reject('Style callback must be a JSX expression');
|
|
147
|
+
const expression = initializer.getExpression();
|
|
148
|
+
if (!expression || (!Node.isArrowFunction(expression) && !Node.isFunctionExpression(expression)) || expression.isAsync() || (Node.isFunctionExpression(expression) && expression.isGenerator()))
|
|
149
|
+
reject('Only synchronous inline Pressable callbacks are supported');
|
|
150
|
+
const parameters = expression.getParameters();
|
|
151
|
+
if (parameters.length !== 1)
|
|
152
|
+
reject('Pressable callback needs one destructured pressed parameter');
|
|
153
|
+
if (parameters[0].getInitializer() || parameters[0].getDotDotDotToken())
|
|
154
|
+
reject('Default and rest callback parameters are unsupported');
|
|
155
|
+
const parameter = parameters[0].getNameNode();
|
|
156
|
+
if (!Node.isObjectBindingPattern(parameter) || parameter.getElements().length !== 1)
|
|
157
|
+
reject('Pressable callback needs one destructured pressed parameter');
|
|
158
|
+
const binding = parameter.getElements()[0];
|
|
159
|
+
const name = binding.getNameNode();
|
|
160
|
+
if ((binding.getPropertyNameNode()?.getText() ?? name.getText()) !== 'pressed' || !Node.isIdentifier(name) || binding.getInitializer() || binding.getDotDotDotToken())
|
|
161
|
+
reject('Unsupported pressed parameter binding');
|
|
162
|
+
const unwrap = (node) => Node.isParenthesizedExpression(node) ? unwrap(node.getExpression()) : node;
|
|
163
|
+
let body = expression.getBody();
|
|
164
|
+
if (Node.isBlock(body)) {
|
|
165
|
+
const statements = body.getStatements();
|
|
166
|
+
if (statements.length !== 1 || !Node.isReturnStatement(statements[0]) || !statements[0].getExpression())
|
|
167
|
+
reject('Callback body must contain only a static conditional return');
|
|
168
|
+
body = statements[0].getExpression();
|
|
169
|
+
}
|
|
170
|
+
body = unwrap(body);
|
|
171
|
+
if (!Node.isConditionalExpression(body))
|
|
172
|
+
reject('Callback must return pressed ? literal : literal');
|
|
173
|
+
let condition = unwrap(body.getCondition());
|
|
174
|
+
let negated = false;
|
|
175
|
+
if (Node.isPrefixUnaryExpression(condition) && condition.getOperatorToken() === SyntaxKind.ExclamationToken) {
|
|
176
|
+
condition = unwrap(condition.getOperand());
|
|
177
|
+
negated = true;
|
|
178
|
+
}
|
|
179
|
+
if (!Node.isIdentifier(condition) || condition.getText() !== name.getText())
|
|
180
|
+
reject('Callback condition must be the pressed binding');
|
|
181
|
+
const whenTrue = unwrap(body.getWhenTrue());
|
|
182
|
+
const whenFalse = unwrap(body.getWhenFalse());
|
|
183
|
+
if (!Node.isObjectLiteralExpression(whenTrue) || !Node.isObjectLiteralExpression(whenFalse))
|
|
184
|
+
reject('Callback arms must be static inline style literals');
|
|
185
|
+
validateObject(whenTrue);
|
|
186
|
+
validateObject(whenFalse);
|
|
187
|
+
return (state === 'pressed') !== negated ? whenTrue : whenFalse;
|
|
188
|
+
}
|
|
189
|
+
/** Pure planning: parses in memory and returns text; never writes the source tree. */
|
|
190
|
+
export function planSourcePatch(sourceText, sourceRef, patch, scope) {
|
|
191
|
+
const parsedSource = SourceRefSchema.safeParse(sourceRef);
|
|
192
|
+
const parsedPatch = RnStylePatchSchema.safeParse(patch);
|
|
193
|
+
if (!parsedSource.success || !parsedPatch.success)
|
|
194
|
+
return { ok: false, error: { code: 'INVALID_INPUT', message: 'Invalid source reference or RN style patch' } };
|
|
195
|
+
if (hashSource(sourceText) !== sourceRef.hash)
|
|
196
|
+
return { ok: false, error: { code: 'STALE_SOURCE', message: 'Source content no longer matches the measured revision' } };
|
|
197
|
+
try {
|
|
198
|
+
const project = new Project({ useInMemoryFileSystem: true, skipAddingFilesFromTsConfig: true, compilerOptions: { jsx: 4, noResolve: true, noLib: true } });
|
|
199
|
+
const source = project.createSourceFile('/source.tsx', sourceText);
|
|
200
|
+
if (project.getProgram().compilerObject.getSyntacticDiagnostics(source.compilerNode).length)
|
|
201
|
+
reject('Source contains syntax errors');
|
|
202
|
+
const target = source.getDescendants().find((node) => (Node.isJsxElement(node) || Node.isJsxSelfClosingElement(node)) && node.getStart() === sourceRef.start && node.getEnd() === sourceRef.end);
|
|
203
|
+
if (!target)
|
|
204
|
+
return { ok: false, error: { code: 'STALE_SOURCE', message: 'Source range does not identify the original JSX element' } };
|
|
205
|
+
const location = source.getLineAndColumnAtPos(target.getStart());
|
|
206
|
+
if (location.line !== sourceRef.line || location.column - 1 !== sourceRef.column)
|
|
207
|
+
return { ok: false, error: { code: 'STALE_SOURCE', message: 'Source location does not match the original JSX element' } };
|
|
208
|
+
if (!scope?.acknowledgeInstances)
|
|
209
|
+
rejectRepeatedSite(target);
|
|
210
|
+
const opening = Node.isJsxElement(target) ? target.getOpeningElement() : target;
|
|
211
|
+
if (opening.getAttributes().some(Node.isJsxSpreadAttribute))
|
|
212
|
+
reject('JSX spread attributes could override the trial styles');
|
|
213
|
+
if (scope?.kind === 'nativewind')
|
|
214
|
+
return planNativeWind(sourceText, sourceRef, parsedPatch.data, opening, scope);
|
|
215
|
+
let foldedObject = scope?.kind === 'pressable-state' ? pressableStateObject(opening, scope.state) : undefined;
|
|
216
|
+
if (scope?.kind === 'local' || scope?.kind === 'branch') {
|
|
217
|
+
const attrs = opening.getAttributes().filter(a => Node.isJsxAttribute(a) && a.getNameNode().getText() === 'style');
|
|
218
|
+
if (attrs.length > 1)
|
|
219
|
+
reject('Duplicate style attributes are unsupported');
|
|
220
|
+
const attribute = attrs[0];
|
|
221
|
+
const literal = '{ ' + Object.keys(parsedPatch.data).sort().map(key => key + ': ' + valueText(parsedPatch.data[key], source)).join(', ') + ' }';
|
|
222
|
+
let updatedText;
|
|
223
|
+
if (attribute && Node.isJsxAttribute(attribute)) {
|
|
224
|
+
const initializer = attribute.getInitializer();
|
|
225
|
+
if (!initializer || !Node.isJsxExpression(initializer))
|
|
226
|
+
reject('Style must be a JSX expression');
|
|
227
|
+
let expression = initializer.getExpression();
|
|
228
|
+
if (!expression)
|
|
229
|
+
reject('Style expression is empty');
|
|
230
|
+
if (scope.kind === 'branch') {
|
|
231
|
+
if (!Node.isConditionalExpression(expression) || !scope.branch)
|
|
232
|
+
reject('Branch editing requires a conditional style and an explicit branch');
|
|
233
|
+
expression = scope.branch === 'consequent' ? expression.getWhenTrue() : expression.getWhenFalse();
|
|
234
|
+
}
|
|
235
|
+
if (Node.isArrowFunction(expression) || Node.isFunctionExpression(expression))
|
|
236
|
+
reject('Style callback edits require a measured state and are unsupported');
|
|
237
|
+
// Only the final array entry can override every preceding expression.
|
|
238
|
+
const candidate = Node.isArrayLiteralExpression(expression) ? expression.getElements().at(-1) : expression;
|
|
239
|
+
if (candidate && Node.isExpression(candidate)) {
|
|
240
|
+
try {
|
|
241
|
+
const object = localStyleObject(candidate, source);
|
|
242
|
+
if (isLiteralStyleObject(object))
|
|
243
|
+
foldedObject = object;
|
|
244
|
+
}
|
|
245
|
+
catch { /* Shared/dynamic styles keep their final local override. */ }
|
|
246
|
+
}
|
|
247
|
+
const replacement = '[' + expression.getText() + ', ' + literal + ']';
|
|
248
|
+
updatedText = sourceText.slice(0, expression.getStart()) + replacement + sourceText.slice(expression.getEnd());
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
const end = opening.getTagNameNode().getEnd();
|
|
252
|
+
updatedText = sourceText.slice(0, end) + ' style={' + literal + '}' + sourceText.slice(end);
|
|
253
|
+
}
|
|
254
|
+
if (!foldedObject)
|
|
255
|
+
return { ok: true, value: { source: sourceRef, expectedHash: sourceRef.hash, updatedText, diff: createTwoFilesPatch(sourceRef.path, sourceRef.path, sourceText, updatedText) } };
|
|
256
|
+
}
|
|
257
|
+
const attributes = opening.getAttributes().filter(attribute => Node.isJsxAttribute(attribute) && attribute.getNameNode().getText() === 'style');
|
|
258
|
+
if (attributes.length > 1)
|
|
259
|
+
reject('Duplicate style attributes are unsupported');
|
|
260
|
+
const styleAttribute = attributes[0];
|
|
261
|
+
let object = foldedObject;
|
|
262
|
+
if (!object && styleAttribute && Node.isJsxAttribute(styleAttribute)) {
|
|
263
|
+
const value = styleAttribute.getInitializer();
|
|
264
|
+
if (!value || !Node.isJsxExpression(value))
|
|
265
|
+
reject('Style must be a JSX expression');
|
|
266
|
+
const expression = value.getExpression();
|
|
267
|
+
if (!expression)
|
|
268
|
+
reject('Style expression is empty');
|
|
269
|
+
object = localStyleObject(expression, source);
|
|
270
|
+
validateObject(object);
|
|
271
|
+
}
|
|
272
|
+
const edits = [];
|
|
273
|
+
const additions = [];
|
|
274
|
+
for (const key of Object.keys(parsedPatch.data).sort()) {
|
|
275
|
+
const value = parsedPatch.data[key];
|
|
276
|
+
if (value === undefined)
|
|
277
|
+
continue;
|
|
278
|
+
const existing = object?.getProperty(key);
|
|
279
|
+
const text = valueText(value, source, existing && Node.isPropertyAssignment(existing) ? existing.getInitializer() : undefined);
|
|
280
|
+
if (existing && Node.isPropertyAssignment(existing)) {
|
|
281
|
+
const initializer = existing.getInitializerOrThrow();
|
|
282
|
+
edits.push({ start: initializer.getStart(), end: initializer.getEnd(), text });
|
|
283
|
+
}
|
|
284
|
+
else
|
|
285
|
+
additions.push(`${key}: ${text}`);
|
|
286
|
+
}
|
|
287
|
+
if (object && additions.length) {
|
|
288
|
+
const closing = object.getEnd() - 1;
|
|
289
|
+
const properties = object.getProperties();
|
|
290
|
+
// Inserting before the closing brace preserves comments and all unrelated text.
|
|
291
|
+
const trailingComma = properties.length > 0 && hasTrailingComma(sourceText.slice(properties.at(-1).getEnd(), closing));
|
|
292
|
+
edits.push({ start: closing, end: closing, text: `${properties.length && !trailingComma ? ',' : ''} ${additions.join(', ')} ` });
|
|
293
|
+
}
|
|
294
|
+
else if (!object) {
|
|
295
|
+
const tag = opening.getTagNameNode();
|
|
296
|
+
edits.push({ start: tag.getEnd(), end: tag.getEnd(), text: ` style={{ ${additions.join(', ')} }}` });
|
|
297
|
+
}
|
|
298
|
+
let updatedText = sourceText;
|
|
299
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
300
|
+
updatedText = updatedText.slice(0, edit.start) + edit.text + updatedText.slice(edit.end);
|
|
301
|
+
const updated = project.createSourceFile('/updated.tsx', updatedText);
|
|
302
|
+
if (project.getProgram().compilerObject.getSyntacticDiagnostics(updated.compilerNode).length)
|
|
303
|
+
reject('Planned source edit would produce invalid syntax');
|
|
304
|
+
return { ok: true, value: { source: sourceRef, expectedHash: sourceRef.hash, updatedText, diff: createTwoFilesPatch(sourceRef.path, sourceRef.path, sourceText, updatedText, '', '', { context: 3 }) } };
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
return { ok: false, error: { code: 'UNSUPPORTED_EDIT', message: error instanceof Error ? error.message : 'Unsupported source edit' } };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function planNativeWind(text, source, patch, opening, scope) {
|
|
311
|
+
const attr = opening.getAttribute('className');
|
|
312
|
+
if (!attr || !Node.isJsxAttribute(attr))
|
|
313
|
+
reject('NativeWind requires a literal className');
|
|
314
|
+
let init = attr.getInitializer();
|
|
315
|
+
if (init && Node.isJsxExpression(init) && init.getExpression() && Node.isConditionalExpression(init.getExpression())) {
|
|
316
|
+
const conditional = init.getExpression();
|
|
317
|
+
if (!scope.branch)
|
|
318
|
+
reject('Conditional NativeWind classes require an explicit branch');
|
|
319
|
+
const expression = scope.branch === 'consequent' ? conditional.getWhenTrue() : conditional.getWhenFalse();
|
|
320
|
+
if (!Node.isStringLiteral(expression))
|
|
321
|
+
reject('NativeWind branches must be string literals');
|
|
322
|
+
init = expression;
|
|
323
|
+
}
|
|
324
|
+
let classes;
|
|
325
|
+
if (init && Node.isStringLiteral(init))
|
|
326
|
+
classes = init.getLiteralValue();
|
|
327
|
+
else if (init && Node.isJsxExpression(init) && init.getExpression() && Node.isStringLiteral(init.getExpression()))
|
|
328
|
+
classes = init.getExpression().getLiteralValue();
|
|
329
|
+
else
|
|
330
|
+
reject('Dynamic class construction is unsupported');
|
|
331
|
+
const utility = { width: 'w', height: 'h', minWidth: 'min-w', maxWidth: 'max-w', minHeight: 'min-h', maxHeight: 'max-h', gap: 'gap', rowGap: 'gap-y', columnGap: 'gap-x', padding: 'p', paddingHorizontal: 'px', paddingVertical: 'py', paddingTop: 'pt', paddingBottom: 'pb', paddingLeft: 'pl', paddingRight: 'pr', margin: 'm', marginHorizontal: 'mx', marginVertical: 'my', marginTop: 'mt', marginBottom: 'mb', marginLeft: 'ml', marginRight: 'mr', fontSize: 'text', lineHeight: 'leading', top: 'top', bottom: 'bottom', left: 'left', right: 'right', flexGrow: 'grow', flexShrink: 'shrink', paddingStart: 'ps', paddingEnd: 'pe', marginStart: 'ms', marginEnd: 'me', start: 'start', end: 'end', aspectRatio: 'aspect', flex: 'flex' };
|
|
332
|
+
const fixed = { flexDirection: { row: 'flex-row', column: 'flex-col', 'row-reverse': 'flex-row-reverse', 'column-reverse': 'flex-col-reverse' }, justifyContent: { 'flex-start': 'justify-start', 'flex-end': 'justify-end', center: 'justify-center', 'space-between': 'justify-between', 'space-around': 'justify-around', 'space-evenly': 'justify-evenly' }, alignItems: { 'flex-start': 'items-start', 'flex-end': 'items-end', center: 'items-center', stretch: 'items-stretch', baseline: 'items-baseline' }, flexWrap: { wrap: 'flex-wrap', nowrap: 'flex-nowrap', 'wrap-reverse': 'flex-wrap-reverse' }, position: { relative: 'relative', absolute: 'absolute', static: 'static' }, alignSelf: { auto: 'self-auto', 'flex-start': 'self-start', 'flex-end': 'self-end', center: 'self-center', stretch: 'self-stretch', baseline: 'self-baseline' }, alignContent: { 'flex-start': 'content-start', 'flex-end': 'content-end', center: 'content-center', stretch: 'content-stretch', 'space-between': 'content-between', 'space-around': 'content-around' }, fontWeight: { normal: 'font-normal', bold: 'font-bold', '100': 'font-thin', '200': 'font-extralight', '300': 'font-light', '400': 'font-normal', '500': 'font-medium', '600': 'font-semibold', '700': 'font-bold', '800': 'font-extrabold', '900': 'font-black' } };
|
|
333
|
+
let tokens = classes.split(/\s+/).filter(Boolean);
|
|
334
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
335
|
+
if (value === undefined)
|
|
336
|
+
continue;
|
|
337
|
+
let token;
|
|
338
|
+
let matches;
|
|
339
|
+
if (key === 'fontFamily') {
|
|
340
|
+
if (typeof value !== 'string' || !/^[A-Za-z][A-Za-z0-9]*(?:[ _-][A-Za-z0-9]+)*$/.test(value))
|
|
341
|
+
reject('NativeWind font families must be literal names without CSS expressions');
|
|
342
|
+
const weights = new Set(['thin', 'extralight', 'light', 'normal', 'medium', 'semibold', 'bold', 'extrabold', 'black']);
|
|
343
|
+
const family = (candidate) => /^font-(?:sans|serif|mono)$/.test(candidate) || /^font-\[family-name:[A-Za-z][A-Za-z0-9]*(?:(?:_|\\_|-)[A-Za-z0-9]+)*\]$/.test(candidate);
|
|
344
|
+
for (const existing of tokens) {
|
|
345
|
+
const base = splitNativeWindVariant(existing).utility;
|
|
346
|
+
if (base.startsWith('font-') && !weights.has(base.slice(5)) && !/^font-\[\d+\]$/.test(base) && !family(base))
|
|
347
|
+
reject('Configured or dynamic font utilities require explicit static theme evidence');
|
|
348
|
+
}
|
|
349
|
+
token = 'font-[family-name:' + value.replaceAll('_', '\\_').replaceAll(' ', '_') + ']';
|
|
350
|
+
matches = family;
|
|
351
|
+
}
|
|
352
|
+
else if (fixed[key]) {
|
|
353
|
+
token = fixed[key][String(value)];
|
|
354
|
+
if (!token)
|
|
355
|
+
reject('Unsupported NativeWind value for ' + key);
|
|
356
|
+
const values = Object.values(fixed[key]);
|
|
357
|
+
matches = s => values.includes(s);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
const prefix = utility[key];
|
|
361
|
+
if (!prefix)
|
|
362
|
+
reject('Unsupported NativeWind property ' + key);
|
|
363
|
+
token = prefix + '-[' + String(value) + (typeof value === 'number' && !['flexGrow', 'flexShrink', 'aspectRatio', 'flex'].includes(key) ? 'px' : '') + ']';
|
|
364
|
+
matches = s => s.startsWith(prefix + '-') && !s.startsWith(prefix + '-x-') && !s.startsWith(prefix + '-y-') && (key !== 'fontSize' || /^text-(?:\[[-.\d]|xs$|sm$|base$|lg$|[2-9]?xl$)/.test(s));
|
|
365
|
+
}
|
|
366
|
+
const splitTokens = tokens.map(splitNativeWindVariant);
|
|
367
|
+
if (splitTokens.some(({ hasVariant, utility: base }) => hasVariant && matches(base)))
|
|
368
|
+
reject('Conflicting variant utilities require an explicit variant-specific edit');
|
|
369
|
+
tokens = tokens.filter((_, index) => splitTokens[index].hasVariant || !matches(splitTokens[index].utility));
|
|
370
|
+
tokens.push(token);
|
|
371
|
+
}
|
|
372
|
+
const updatedText = text.slice(0, init.getStart()) + valueText(tokens.join(' '), opening.getSourceFile(), init) + text.slice(init.getEnd());
|
|
373
|
+
return { ok: true, value: { source, expectedHash: source.hash, updatedText, diff: createTwoFilesPatch(source.path, source.path, text, updatedText) } };
|
|
374
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Node, type SourceFile, type ObjectLiteralExpression } from 'ts-morph';
|
|
2
|
+
/** JSX attribute quotes do not establish the JavaScript string convention. */
|
|
3
|
+
export declare function sourceQuote(source: SourceFile, preferred?: Node): "'" | '"';
|
|
4
|
+
export declare function valueText(value: unknown, source: SourceFile, preferred?: Node): string;
|
|
5
|
+
/** Only primitive literals may be folded without removing expression evaluation. */
|
|
6
|
+
export declare function isLiteralStyleObject(object: ObjectLiteralExpression): boolean;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Node, SyntaxKind } from 'ts-morph';
|
|
2
|
+
/** JSX attribute quotes do not establish the JavaScript string convention. */
|
|
3
|
+
export function sourceQuote(source, preferred) {
|
|
4
|
+
if (preferred && Node.isStringLiteral(preferred))
|
|
5
|
+
return preferred.getText().startsWith("'") ? "'" : '"';
|
|
6
|
+
const strings = source.getDescendantsOfKind(SyntaxKind.StringLiteral).filter(node => !Node.isJsxAttribute(node.getParent()));
|
|
7
|
+
const singles = strings.filter(node => node.getText().startsWith("'")).length;
|
|
8
|
+
return strings.length - singles > singles ? '"' : "'";
|
|
9
|
+
}
|
|
10
|
+
export function valueText(value, source, preferred) {
|
|
11
|
+
if (typeof value !== 'string')
|
|
12
|
+
return JSON.stringify(value);
|
|
13
|
+
const encoded = JSON.stringify(value);
|
|
14
|
+
return sourceQuote(source, preferred) === '"' ? encoded : "'" + encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("'", "\\'") + "'";
|
|
15
|
+
}
|
|
16
|
+
/** Only primitive literals may be folded without removing expression evaluation. */
|
|
17
|
+
export function isLiteralStyleObject(object) {
|
|
18
|
+
const names = new Set();
|
|
19
|
+
return object.getProperties().every(property => {
|
|
20
|
+
if (!Node.isPropertyAssignment(property))
|
|
21
|
+
return false;
|
|
22
|
+
const name = property.getNameNode();
|
|
23
|
+
if (!Node.isIdentifier(name) && !Node.isStringLiteral(name))
|
|
24
|
+
return false;
|
|
25
|
+
const key = Node.isStringLiteral(name) ? name.getLiteralValue() : name.getText();
|
|
26
|
+
if (names.has(key))
|
|
27
|
+
return false;
|
|
28
|
+
names.add(key);
|
|
29
|
+
const value = property.getInitializer();
|
|
30
|
+
return Boolean(value && (Node.isStringLiteral(value) || Node.isNumericLiteral(value) || Node.isNoSubstitutionTemplateLiteral(value) || [SyntaxKind.TrueKeyword, SyntaxKind.FalseKeyword, SyntaxKind.NullKeyword].includes(value.getKind()) || (Node.isPrefixUnaryExpression(value) && ['+', '-'].includes(value.getText().slice(0, 1)) && Node.isNumericLiteral(value.getOperand()))));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, open, readFile, realpath, rename, stat, unlink } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { BridgeFault, EditPlanSchema } from '@nativemason/contracts';
|
|
5
|
+
import { hashSource } from './planner.js';
|
|
6
|
+
/** Serialized, hash-checked single-file writes. External editors are checked again immediately before rename. */
|
|
7
|
+
export function createSourcePort(projectRoot) {
|
|
8
|
+
const rootPromise = realpath(projectRoot);
|
|
9
|
+
const receipts = new Map();
|
|
10
|
+
let queue = Promise.resolve();
|
|
11
|
+
async function resolveSource(relative) {
|
|
12
|
+
const root = await rootPromise;
|
|
13
|
+
if (path.isAbsolute(relative) || relative.split(/[\\/]/).includes('..') || relative.includes('\0'))
|
|
14
|
+
throw new BridgeFault('SOURCE_OUTSIDE_PROJECT', 'Source paths must be project-relative without traversal');
|
|
15
|
+
const resolved = await realpath(path.resolve(root, relative));
|
|
16
|
+
const within = path.relative(root, resolved);
|
|
17
|
+
if (!within || within.startsWith(`..${path.sep}`) || within === '..' || path.isAbsolute(within))
|
|
18
|
+
throw new BridgeFault('SOURCE_OUTSIDE_PROJECT', 'Source resolves outside the project');
|
|
19
|
+
if (!(await stat(resolved)).isFile())
|
|
20
|
+
throw new BridgeFault('INVALID_INPUT', 'Source is not a regular file');
|
|
21
|
+
return resolved;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
async read(relative) {
|
|
25
|
+
const file = await resolveSource(relative);
|
|
26
|
+
const text = await readFile(file, 'utf8');
|
|
27
|
+
return { path: relative, text, hash: hashSource(text) };
|
|
28
|
+
},
|
|
29
|
+
apply(plan) {
|
|
30
|
+
const work = async () => {
|
|
31
|
+
const validated = EditPlanSchema.safeParse(plan);
|
|
32
|
+
if (!validated.success || plan.expectedHash !== plan.source.hash)
|
|
33
|
+
throw new BridgeFault('INVALID_INPUT', 'Invalid source edit plan');
|
|
34
|
+
const file = await resolveSource(plan.source.path);
|
|
35
|
+
const before = await readFile(file, 'utf8');
|
|
36
|
+
const currentHash = hashSource(before);
|
|
37
|
+
const newHash = hashSource(plan.updatedText);
|
|
38
|
+
const key = hashSource(`${file}\0${plan.expectedHash}\0${newHash}`);
|
|
39
|
+
const replay = receipts.get(key);
|
|
40
|
+
if (replay) {
|
|
41
|
+
if (currentHash !== replay.newHash)
|
|
42
|
+
throw new BridgeFault('STALE_SOURCE', 'Previously committed source has changed');
|
|
43
|
+
return { ...replay };
|
|
44
|
+
}
|
|
45
|
+
if (currentHash !== plan.expectedHash)
|
|
46
|
+
throw new BridgeFault('STALE_SOURCE', 'Source changed after the trial was captured');
|
|
47
|
+
const original = await lstat(file);
|
|
48
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`);
|
|
49
|
+
try {
|
|
50
|
+
const handle = await open(temporary, 'wx', original.mode & 0o777);
|
|
51
|
+
try {
|
|
52
|
+
await handle.writeFile(plan.updatedText, 'utf8');
|
|
53
|
+
await handle.chmod(original.mode & 0o777);
|
|
54
|
+
await handle.sync();
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await handle.close();
|
|
58
|
+
}
|
|
59
|
+
if (await resolveSource(plan.source.path) !== file)
|
|
60
|
+
throw new BridgeFault('STALE_SOURCE', 'Source symlink changed during commit');
|
|
61
|
+
const latest = await lstat(file);
|
|
62
|
+
if (latest.ino !== original.ino || latest.dev !== original.dev || hashSource(await readFile(file, 'utf8')) !== plan.expectedHash)
|
|
63
|
+
throw new BridgeFault('STALE_SOURCE', 'Source changed during commit');
|
|
64
|
+
await rename(temporary, file);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT')
|
|
68
|
+
throw error; });
|
|
69
|
+
}
|
|
70
|
+
const receipt = { commitId: randomUUID(), path: plan.source.path, oldHash: plan.expectedHash, newHash, diff: plan.diff, nativeStatus: 'pending' };
|
|
71
|
+
receipts.set(key, receipt);
|
|
72
|
+
return { ...receipt };
|
|
73
|
+
};
|
|
74
|
+
const result = queue.then(work, work);
|
|
75
|
+
queue = result.catch(() => undefined);
|
|
76
|
+
return result;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type SourceFile, type SourceMutation, type TransactionPlan, type Result } from '@nativemason/contracts';
|
|
2
|
+
/** All project inputs are supplied by the adapter. Planning never reads or executes application code. */
|
|
3
|
+
export declare function planTransaction(files: readonly SourceFile[], mutations: readonly SourceMutation[]): Result<TransactionPlan>;
|