flake-monster 0.1.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 +21 -0
- package/README.md +281 -0
- package/bin/flake-monster.js +6 -0
- package/package.json +48 -0
- package/src/adapters/adapter-interface.js +86 -0
- package/src/adapters/javascript/codegen.js +13 -0
- package/src/adapters/javascript/index.js +76 -0
- package/src/adapters/javascript/injector.js +438 -0
- package/src/adapters/javascript/parser.js +19 -0
- package/src/adapters/javascript/remover.js +128 -0
- package/src/adapters/registry.js +64 -0
- package/src/cli/commands/inject.js +64 -0
- package/src/cli/commands/restore.js +107 -0
- package/src/cli/commands/test.js +215 -0
- package/src/cli/index.js +19 -0
- package/src/core/config.js +57 -0
- package/src/core/engine.js +156 -0
- package/src/core/flake-analyzer.js +64 -0
- package/src/core/manifest.js +137 -0
- package/src/core/parsers/index.js +40 -0
- package/src/core/parsers/jest.js +52 -0
- package/src/core/parsers/node-test.js +64 -0
- package/src/core/parsers/tap.js +92 -0
- package/src/core/profile.js +72 -0
- package/src/core/reporter.js +75 -0
- package/src/core/seed.js +59 -0
- package/src/core/workspace.js +139 -0
- package/src/runtime/javascript/flake-monster.runtime.js +5 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { simple } from 'acorn-walk';
|
|
2
|
+
import { deriveSeed, createRng } from '../../core/seed.js';
|
|
3
|
+
|
|
4
|
+
const MARKER_PREFIX = '@flake-monster[jt92-se2j!] v1';
|
|
5
|
+
const DELAY_OBJECT = '__FlakeMonster__';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compute the delay (in ms) for a specific injection point.
|
|
9
|
+
* Uses the same deterministic derivation as before, but now runs at
|
|
10
|
+
* injection time so the value is embedded directly in the source.
|
|
11
|
+
* @param {number} seed
|
|
12
|
+
* @param {string} filePath
|
|
13
|
+
* @param {string} fnName
|
|
14
|
+
* @param {number} index
|
|
15
|
+
* @param {{ minMs: number, maxMs: number }} delayConfig
|
|
16
|
+
* @returns {number}
|
|
17
|
+
*/
|
|
18
|
+
function computeDelayMs(seed, filePath, fnName, index, delayConfig) {
|
|
19
|
+
const context = `${filePath}:${fnName}:${index}`;
|
|
20
|
+
const contextSeed = deriveSeed(seed, context);
|
|
21
|
+
const rng = createRng(contextSeed);
|
|
22
|
+
const lo = delayConfig.minMs ?? 0;
|
|
23
|
+
const hi = delayConfig.maxMs ?? 50;
|
|
24
|
+
return Math.round(lo + rng() * (hi - lo));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build an AST node for the marker block comment.
|
|
29
|
+
* @returns {Object}
|
|
30
|
+
*/
|
|
31
|
+
function createMarkerComment() {
|
|
32
|
+
return {
|
|
33
|
+
type: 'Block',
|
|
34
|
+
value: MARKER_PREFIX,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build the AST for: await __FlakeMonster__(delayMs)
|
|
40
|
+
* Returns an ExpressionStatement node with the marker comment attached.
|
|
41
|
+
* @param {number} delayMs
|
|
42
|
+
* @param {Object} comment - the marker comment to attach
|
|
43
|
+
* @returns {Object}
|
|
44
|
+
*/
|
|
45
|
+
function createDelayStatement(delayMs, comment) {
|
|
46
|
+
return {
|
|
47
|
+
type: 'ExpressionStatement',
|
|
48
|
+
expression: {
|
|
49
|
+
type: 'AwaitExpression',
|
|
50
|
+
argument: {
|
|
51
|
+
type: 'CallExpression',
|
|
52
|
+
callee: { type: 'Identifier', name: DELAY_OBJECT },
|
|
53
|
+
arguments: [{ type: 'Literal', value: delayMs }],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
comments: [comment],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Decide whether to inject a delay before a given statement based on mode.
|
|
62
|
+
* @param {string} mode
|
|
63
|
+
* @param {Object} stmt - AST statement node
|
|
64
|
+
* @param {number} index - position in the function body
|
|
65
|
+
* @returns {boolean}
|
|
66
|
+
*/
|
|
67
|
+
function shouldInject(mode, stmt, index) {
|
|
68
|
+
if (mode === 'light') {
|
|
69
|
+
return index === 0;
|
|
70
|
+
}
|
|
71
|
+
if (mode === 'medium') {
|
|
72
|
+
return stmt.type !== 'ReturnStatement' && stmt.type !== 'ThrowStatement';
|
|
73
|
+
}
|
|
74
|
+
if (mode === 'hardcore') {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get a human-readable name for a function node.
|
|
82
|
+
* @param {Object} node
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function getFnName(node) {
|
|
86
|
+
if (node.id && node.id.name) return node.id.name;
|
|
87
|
+
return '<anonymous>';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Process an array of statements: build a new array with delay statements spliced in.
|
|
92
|
+
* @param {Object[]} bodyArray - array of AST statement nodes
|
|
93
|
+
* @param {string} fnName - context name for seed derivation
|
|
94
|
+
* @param {Object} options - inject options
|
|
95
|
+
* @returns {{ newBody: Object[], points: Object[] }}
|
|
96
|
+
*/
|
|
97
|
+
function processStatements(bodyArray, fnName, options) {
|
|
98
|
+
const points = [];
|
|
99
|
+
const newBody = [];
|
|
100
|
+
let injectionIndex = 0;
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < bodyArray.length; i++) {
|
|
103
|
+
const stmt = bodyArray[i];
|
|
104
|
+
|
|
105
|
+
if (shouldInject(options.mode, stmt, i)) {
|
|
106
|
+
const delayMs = computeDelayMs(
|
|
107
|
+
options.seed,
|
|
108
|
+
options.filePath,
|
|
109
|
+
fnName,
|
|
110
|
+
injectionIndex,
|
|
111
|
+
options.delayConfig,
|
|
112
|
+
);
|
|
113
|
+
const comment = createMarkerComment();
|
|
114
|
+
const delayStmt = createDelayStatement(delayMs, comment);
|
|
115
|
+
|
|
116
|
+
newBody.push(delayStmt);
|
|
117
|
+
|
|
118
|
+
points.push({
|
|
119
|
+
fnName,
|
|
120
|
+
index: injectionIndex,
|
|
121
|
+
delayMs,
|
|
122
|
+
line: stmt.loc?.start.line ?? 0,
|
|
123
|
+
column: stmt.loc?.start.column ?? 0,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
injectionIndex++;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
newBody.push(stmt);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { newBody, points };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Process a single async function body: splice delay statements into its body array.
|
|
137
|
+
* @param {Object} fnNode
|
|
138
|
+
* @param {string} fnName
|
|
139
|
+
* @param {Object} options - inject options
|
|
140
|
+
* @returns {Object[]} injection points
|
|
141
|
+
*/
|
|
142
|
+
function processBody(fnNode, fnName, options) {
|
|
143
|
+
const { newBody, points } = processStatements(fnNode.body.body, fnName, options);
|
|
144
|
+
fnNode.body.body = newBody;
|
|
145
|
+
return points;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Process top-level (module scope) statements: inject delays between non-import statements.
|
|
150
|
+
* Top-level await is valid in ES modules, so we can inject `await __FlakeMonster__(N)`
|
|
151
|
+
* at the module level just like inside async function bodies.
|
|
152
|
+
* @param {Object} ast - ESTree Program node
|
|
153
|
+
* @param {Object} options - inject options
|
|
154
|
+
* @returns {Object[]} injection points
|
|
155
|
+
*/
|
|
156
|
+
function processTopLevel(ast, options) {
|
|
157
|
+
// Find where imports end
|
|
158
|
+
let firstNonImport = 0;
|
|
159
|
+
for (let i = 0; i < ast.body.length; i++) {
|
|
160
|
+
if (ast.body[i].type === 'ImportDeclaration') {
|
|
161
|
+
firstNonImport = i + 1;
|
|
162
|
+
} else {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const stmts = ast.body.slice(firstNonImport);
|
|
168
|
+
if (stmts.length === 0) return [];
|
|
169
|
+
|
|
170
|
+
const { newBody, points } = processStatements(stmts, '<top-level>', options);
|
|
171
|
+
ast.body = [...ast.body.slice(0, firstNonImport), ...newBody];
|
|
172
|
+
return points;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Inject delay statements into the AST: both at the module top level
|
|
177
|
+
* (using top-level await) and inside async function bodies.
|
|
178
|
+
* Mutates the AST in place.
|
|
179
|
+
*
|
|
180
|
+
* @param {Object} ast - ESTree AST (Program node)
|
|
181
|
+
* @param {Object} options - { filePath, mode, seed, delayConfig, skipTryCatch, skipGenerators }
|
|
182
|
+
* @returns {import('../adapter-interface.js').InjectionPoint[]}
|
|
183
|
+
*/
|
|
184
|
+
export function injectDelays(ast, options) {
|
|
185
|
+
const allPoints = [];
|
|
186
|
+
|
|
187
|
+
// Inject at module top level (top-level await)
|
|
188
|
+
const topLevelPoints = processTopLevel(ast, options);
|
|
189
|
+
allPoints.push(...topLevelPoints);
|
|
190
|
+
|
|
191
|
+
// Inject inside async function bodies
|
|
192
|
+
simple(ast, {
|
|
193
|
+
FunctionDeclaration(node) {
|
|
194
|
+
if (!node.async) return;
|
|
195
|
+
if (options.skipGenerators && node.generator) return;
|
|
196
|
+
const points = processBody(node, getFnName(node), options);
|
|
197
|
+
allPoints.push(...points);
|
|
198
|
+
},
|
|
199
|
+
FunctionExpression(node) {
|
|
200
|
+
if (!node.async) return;
|
|
201
|
+
if (options.skipGenerators && node.generator) return;
|
|
202
|
+
const points = processBody(node, getFnName(node), options);
|
|
203
|
+
allPoints.push(...points);
|
|
204
|
+
},
|
|
205
|
+
ArrowFunctionExpression(node) {
|
|
206
|
+
if (!node.async) return;
|
|
207
|
+
if (node.body.type !== 'BlockStatement') return;
|
|
208
|
+
const points = processBody(node, '<arrow>', options);
|
|
209
|
+
allPoints.push(...points);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return allPoints;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Add the runtime import as the first statement in the program body.
|
|
218
|
+
* @param {Object} ast
|
|
219
|
+
* @param {string} runtimeImportPath - relative path to runtime, e.g. './flake-monster.runtime.js' or '../flake-monster.runtime.js'
|
|
220
|
+
*/
|
|
221
|
+
export function addRuntimeImport(ast, runtimeImportPath = './flake-monster.runtime.js') {
|
|
222
|
+
// Check if it's already there
|
|
223
|
+
for (const node of ast.body) {
|
|
224
|
+
if (
|
|
225
|
+
node.type === 'ImportDeclaration' &&
|
|
226
|
+
node.source.value.includes('flake-monster.runtime')
|
|
227
|
+
) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const importNode = {
|
|
233
|
+
type: 'ImportDeclaration',
|
|
234
|
+
specifiers: [
|
|
235
|
+
{
|
|
236
|
+
type: 'ImportSpecifier',
|
|
237
|
+
imported: { type: 'Identifier', name: DELAY_OBJECT },
|
|
238
|
+
local: { type: 'Identifier', name: DELAY_OBJECT },
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
source: { type: 'Literal', value: runtimeImportPath },
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// Insert after any existing imports, or at the very top
|
|
245
|
+
let insertIndex = 0;
|
|
246
|
+
for (let i = 0; i < ast.body.length; i++) {
|
|
247
|
+
if (ast.body[i].type === 'ImportDeclaration') {
|
|
248
|
+
insertIndex = i + 1;
|
|
249
|
+
} else {
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
ast.body.splice(insertIndex, 0, importNode);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Text-based injection (preserves original formatting) ──
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Extract the whitespace indentation before a given character offset.
|
|
261
|
+
* Scans backward to the preceding newline.
|
|
262
|
+
* @param {string} source
|
|
263
|
+
* @param {number} offset
|
|
264
|
+
* @returns {string}
|
|
265
|
+
*/
|
|
266
|
+
function getIndent(source, offset) {
|
|
267
|
+
let lineStart = offset;
|
|
268
|
+
while (lineStart > 0 && source[lineStart - 1] !== '\n') {
|
|
269
|
+
lineStart--;
|
|
270
|
+
}
|
|
271
|
+
return source.slice(lineStart, offset);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Collect text insertion descriptors for an array of statements.
|
|
276
|
+
* Same selection logic as processStatements, but produces text offsets
|
|
277
|
+
* instead of AST mutations.
|
|
278
|
+
* @param {Object[]} bodyArray
|
|
279
|
+
* @param {string} fnName
|
|
280
|
+
* @param {string} source - original source text
|
|
281
|
+
* @param {Object} options
|
|
282
|
+
* @returns {{ insertions: { offset: number, text: string }[], points: Object[] }}
|
|
283
|
+
*/
|
|
284
|
+
function collectInsertionsForStatements(bodyArray, fnName, source, options) {
|
|
285
|
+
const insertions = [];
|
|
286
|
+
const points = [];
|
|
287
|
+
let injectionIndex = 0;
|
|
288
|
+
|
|
289
|
+
for (let i = 0; i < bodyArray.length; i++) {
|
|
290
|
+
const stmt = bodyArray[i];
|
|
291
|
+
|
|
292
|
+
if (shouldInject(options.mode, stmt, i)) {
|
|
293
|
+
const delayMs = computeDelayMs(
|
|
294
|
+
options.seed,
|
|
295
|
+
options.filePath,
|
|
296
|
+
fnName,
|
|
297
|
+
injectionIndex,
|
|
298
|
+
options.delayConfig,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const indent = getIndent(source, stmt.start);
|
|
302
|
+
const text = `/* ${MARKER_PREFIX} */\n${indent}await ${DELAY_OBJECT}(${delayMs});\n${indent}`;
|
|
303
|
+
|
|
304
|
+
insertions.push({ offset: stmt.start, text });
|
|
305
|
+
|
|
306
|
+
points.push({
|
|
307
|
+
fnName,
|
|
308
|
+
index: injectionIndex,
|
|
309
|
+
delayMs,
|
|
310
|
+
line: stmt.loc?.start.line ?? 0,
|
|
311
|
+
column: stmt.loc?.start.column ?? 0,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
injectionIndex++;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { insertions, points };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Compute text insertions for the entire AST without mutating it.
|
|
323
|
+
* Walks the AST to find injection targets (same logic as injectDelays)
|
|
324
|
+
* and returns insertion descriptors + metadata points.
|
|
325
|
+
*
|
|
326
|
+
* @param {Object} ast - ESTree AST (Program node), not mutated
|
|
327
|
+
* @param {string} source - original source text
|
|
328
|
+
* @param {Object} options - { filePath, mode, seed, delayConfig, skipTryCatch, skipGenerators }
|
|
329
|
+
* @returns {{ insertions: { offset: number, text: string }[], points: Object[] }}
|
|
330
|
+
*/
|
|
331
|
+
export function computeInjections(ast, source, options) {
|
|
332
|
+
const allInsertions = [];
|
|
333
|
+
const allPoints = [];
|
|
334
|
+
|
|
335
|
+
// Top-level statements (skip imports)
|
|
336
|
+
let firstNonImport = 0;
|
|
337
|
+
for (let i = 0; i < ast.body.length; i++) {
|
|
338
|
+
if (ast.body[i].type === 'ImportDeclaration') {
|
|
339
|
+
firstNonImport = i + 1;
|
|
340
|
+
} else {
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const topStmts = ast.body.slice(firstNonImport);
|
|
345
|
+
if (topStmts.length > 0) {
|
|
346
|
+
const { insertions, points } = collectInsertionsForStatements(topStmts, '<top-level>', source, options);
|
|
347
|
+
allInsertions.push(...insertions);
|
|
348
|
+
allPoints.push(...points);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Async function bodies
|
|
352
|
+
simple(ast, {
|
|
353
|
+
FunctionDeclaration(node) {
|
|
354
|
+
if (!node.async) return;
|
|
355
|
+
if (options.skipGenerators && node.generator) return;
|
|
356
|
+
const { insertions, points } = collectInsertionsForStatements(node.body.body, getFnName(node), source, options);
|
|
357
|
+
allInsertions.push(...insertions);
|
|
358
|
+
allPoints.push(...points);
|
|
359
|
+
},
|
|
360
|
+
FunctionExpression(node) {
|
|
361
|
+
if (!node.async) return;
|
|
362
|
+
if (options.skipGenerators && node.generator) return;
|
|
363
|
+
const { insertions, points } = collectInsertionsForStatements(node.body.body, getFnName(node), source, options);
|
|
364
|
+
allInsertions.push(...insertions);
|
|
365
|
+
allPoints.push(...points);
|
|
366
|
+
},
|
|
367
|
+
ArrowFunctionExpression(node) {
|
|
368
|
+
if (!node.async) return;
|
|
369
|
+
if (node.body.type !== 'BlockStatement') return;
|
|
370
|
+
const { insertions, points } = collectInsertionsForStatements(node.body.body, '<arrow>', source, options);
|
|
371
|
+
allInsertions.push(...insertions);
|
|
372
|
+
allPoints.push(...points);
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
return { insertions: allInsertions, points: allPoints };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Compute a text insertion for the runtime import line.
|
|
381
|
+
* Places it after the last ImportDeclaration in the AST.
|
|
382
|
+
*
|
|
383
|
+
* @param {Object} ast - ESTree AST (Program node), not mutated
|
|
384
|
+
* @param {string} source - original source text
|
|
385
|
+
* @param {string} runtimeImportPath
|
|
386
|
+
* @returns {{ offset: number, text: string } | null}
|
|
387
|
+
*/
|
|
388
|
+
export function computeRuntimeImportInsertion(ast, source, runtimeImportPath) {
|
|
389
|
+
// Don't duplicate
|
|
390
|
+
for (const node of ast.body) {
|
|
391
|
+
if (
|
|
392
|
+
node.type === 'ImportDeclaration' &&
|
|
393
|
+
node.source.value.includes('flake-monster.runtime')
|
|
394
|
+
) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let lastImportEnd = 0;
|
|
400
|
+
for (const node of ast.body) {
|
|
401
|
+
if (node.type === 'ImportDeclaration') {
|
|
402
|
+
lastImportEnd = node.end;
|
|
403
|
+
} else {
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Find the newline after the last import (or start of file)
|
|
409
|
+
let insertOffset = lastImportEnd;
|
|
410
|
+
while (insertOffset < source.length && source[insertOffset] !== '\n') {
|
|
411
|
+
insertOffset++;
|
|
412
|
+
}
|
|
413
|
+
if (insertOffset < source.length) {
|
|
414
|
+
insertOffset++; // past the \n
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const text = `import { ${DELAY_OBJECT} } from '${runtimeImportPath}';\n`;
|
|
418
|
+
return { offset: insertOffset, text };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Apply text insertions to a source string.
|
|
423
|
+
* Insertions are applied back-to-front so earlier offsets stay valid.
|
|
424
|
+
*
|
|
425
|
+
* @param {string} source
|
|
426
|
+
* @param {{ offset: number, text: string }[]} insertions
|
|
427
|
+
* @returns {string}
|
|
428
|
+
*/
|
|
429
|
+
export function applyInsertions(source, insertions) {
|
|
430
|
+
const sorted = [...insertions].sort((a, b) => b.offset - a.offset);
|
|
431
|
+
let result = source;
|
|
432
|
+
for (const { offset, text } of sorted) {
|
|
433
|
+
result = result.slice(0, offset) + text + result.slice(offset);
|
|
434
|
+
}
|
|
435
|
+
return result;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export { MARKER_PREFIX, DELAY_OBJECT };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as acorn from 'acorn';
|
|
2
|
+
import { attachComments } from 'astravel';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse JS source to ESTree AST with comments attached to nodes.
|
|
6
|
+
* @param {string} source
|
|
7
|
+
* @returns {{ ast: Object, comments: Object[] }}
|
|
8
|
+
*/
|
|
9
|
+
export function parseSource(source) {
|
|
10
|
+
const comments = [];
|
|
11
|
+
const ast = acorn.parse(source, {
|
|
12
|
+
ecmaVersion: 'latest',
|
|
13
|
+
sourceType: 'module',
|
|
14
|
+
locations: true,
|
|
15
|
+
onComment: comments,
|
|
16
|
+
});
|
|
17
|
+
attachComments(ast, comments);
|
|
18
|
+
return { ast, comments };
|
|
19
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { DELAY_OBJECT } from './injector.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stamp fragment used for line matching.
|
|
5
|
+
* This substring is unique enough that any line containing it
|
|
6
|
+
* is almost certainly injected by FlakeMonster.
|
|
7
|
+
*/
|
|
8
|
+
const RECOVERY_STAMP = 'jt92-se2j!';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Classify why a line matches recovery patterns.
|
|
12
|
+
* @param {string} trimmed
|
|
13
|
+
* @returns {string|null} reason string, or null if no match
|
|
14
|
+
*/
|
|
15
|
+
function classifyRecoveryMatch(trimmed) {
|
|
16
|
+
if (trimmed.includes(RECOVERY_STAMP)) return 'stamp';
|
|
17
|
+
// Match the delay call pattern: await __FlakeMonster__ (
|
|
18
|
+
// Allows whitespace between tokens (linter reformatting) but requires
|
|
19
|
+
// __FlakeMonster__ as the callee so we don't false-positive on test code
|
|
20
|
+
// that merely references the identifier in strings or assertions.
|
|
21
|
+
if (new RegExp(`await\\s+${DELAY_OBJECT}\\s*\\(`).test(trimmed)) return 'identifier';
|
|
22
|
+
if (/import\s.*flake-monster\.runtime/.test(trimmed)) return 'runtime-import';
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Count unmatched opening braces on a line (braces opened minus braces closed).
|
|
28
|
+
* @param {string} line
|
|
29
|
+
* @returns {number}
|
|
30
|
+
*/
|
|
31
|
+
function netBraceDepth(line) {
|
|
32
|
+
let depth = 0;
|
|
33
|
+
for (const ch of line) {
|
|
34
|
+
if (ch === '{' || ch === '(') depth++;
|
|
35
|
+
else if (ch === '}' || ch === ')') depth--;
|
|
36
|
+
}
|
|
37
|
+
return depth;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Walk lines and collect indices that should be removed by recovery.
|
|
42
|
+
* Handles multi-line spans: when an identifier match opens a brace block
|
|
43
|
+
* (e.g. `await __FlakeMonster__.delay({`), continuation lines through
|
|
44
|
+
* the matching close are also marked for removal.
|
|
45
|
+
*
|
|
46
|
+
* @param {string[]} lines
|
|
47
|
+
* @returns {{ index: number, reason: string }[]}
|
|
48
|
+
*/
|
|
49
|
+
function collectRecoveryIndices(lines) {
|
|
50
|
+
const hits = [];
|
|
51
|
+
let depth = 0;
|
|
52
|
+
let spanning = false;
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < lines.length; i++) {
|
|
55
|
+
const trimmed = lines[i].trim();
|
|
56
|
+
|
|
57
|
+
// If we're inside a multi-line span, consume until braces close
|
|
58
|
+
if (spanning) {
|
|
59
|
+
hits.push({ index: i, reason: 'identifier' });
|
|
60
|
+
depth += netBraceDepth(trimmed);
|
|
61
|
+
if (depth <= 0) {
|
|
62
|
+
spanning = false;
|
|
63
|
+
depth = 0;
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const reason = classifyRecoveryMatch(trimmed);
|
|
69
|
+
if (reason) {
|
|
70
|
+
hits.push({ index: i, reason });
|
|
71
|
+
// Check if this identifier line opens a multi-line call
|
|
72
|
+
if (reason === 'identifier') {
|
|
73
|
+
const net = netBraceDepth(trimmed);
|
|
74
|
+
if (net > 0) {
|
|
75
|
+
spanning = true;
|
|
76
|
+
depth = net;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return hits;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Scan source for lines that recovery mode would remove.
|
|
87
|
+
* Returns match metadata without modifying the source.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} source
|
|
90
|
+
* @returns {{ line: number, content: string, reason: string }[]}
|
|
91
|
+
*/
|
|
92
|
+
export function scanForRecovery(source) {
|
|
93
|
+
const lines = source.split('\n');
|
|
94
|
+
return collectRecoveryIndices(lines).map(({ index, reason }) => ({
|
|
95
|
+
line: index + 1,
|
|
96
|
+
content: lines[index],
|
|
97
|
+
reason,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Recovery mode: text-based removal for when AST matching fails.
|
|
103
|
+
* Uses loose pattern matching to find and remove injected lines
|
|
104
|
+
* even if an AI or manual edit has mangled the AST structure.
|
|
105
|
+
*
|
|
106
|
+
* Targets lines containing:
|
|
107
|
+
* - The recovery stamp (jt92-se2j!)
|
|
108
|
+
* - The __FlakeMonster__ identifier in an await-like context
|
|
109
|
+
* - Import of flake-monster.runtime
|
|
110
|
+
*
|
|
111
|
+
* Also removes continuation lines when a matched line opens a
|
|
112
|
+
* multi-line block (e.g. linter-reformatted delay calls).
|
|
113
|
+
*
|
|
114
|
+
* @param {string} source
|
|
115
|
+
* @returns {{ source: string, recoveredCount: number }}
|
|
116
|
+
*/
|
|
117
|
+
export function recoverDelays(source) {
|
|
118
|
+
const lines = source.split('\n');
|
|
119
|
+
const hits = collectRecoveryIndices(lines);
|
|
120
|
+
const removeSet = new Set(hits.map((h) => h.index));
|
|
121
|
+
|
|
122
|
+
const filtered = lines.filter((_, i) => !removeSet.has(i));
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
source: filtered.join('\n'),
|
|
126
|
+
recoveredCount: removeSet.size,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { REQUIRED_ADAPTER_PROPERTIES } from './adapter-interface.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Registry that maps file types to language adapters.
|
|
5
|
+
* Used by the engine to route files to the correct adapter.
|
|
6
|
+
*/
|
|
7
|
+
export class AdapterRegistry {
|
|
8
|
+
#adapters = new Map();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Register an adapter. Validates it implements the required contract.
|
|
12
|
+
* @param {import('./adapter-interface.js').LanguageAdapter} adapter
|
|
13
|
+
*/
|
|
14
|
+
register(adapter) {
|
|
15
|
+
for (const prop of REQUIRED_ADAPTER_PROPERTIES) {
|
|
16
|
+
if (!(prop in adapter)) {
|
|
17
|
+
throw new Error(`Adapter "${adapter.id || '?'}" is missing required property: ${prop}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (typeof adapter.id !== 'string' || !adapter.id) {
|
|
21
|
+
throw new Error('Adapter id must be a non-empty string');
|
|
22
|
+
}
|
|
23
|
+
if (!Array.isArray(adapter.fileExtensions) || adapter.fileExtensions.length === 0) {
|
|
24
|
+
throw new Error(`Adapter "${adapter.id}" must have at least one fileExtension`);
|
|
25
|
+
}
|
|
26
|
+
this.#adapters.set(adapter.id, adapter);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Find the adapter for a given file path.
|
|
31
|
+
* Tries extension match first, then canHandle() for ambiguous cases.
|
|
32
|
+
* @param {string} filePath
|
|
33
|
+
* @returns {import('./adapter-interface.js').LanguageAdapter|null}
|
|
34
|
+
*/
|
|
35
|
+
getAdapterForFile(filePath) {
|
|
36
|
+
// Fast path: match by extension
|
|
37
|
+
for (const adapter of this.#adapters.values()) {
|
|
38
|
+
if (adapter.fileExtensions.some((ext) => filePath.endsWith(ext))) {
|
|
39
|
+
return adapter;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Slow path: ask each adapter
|
|
43
|
+
for (const adapter of this.#adapters.values()) {
|
|
44
|
+
if (adapter.canHandle(filePath)) {
|
|
45
|
+
return adapter;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get adapter by ID.
|
|
53
|
+
* @param {string} id
|
|
54
|
+
* @returns {import('./adapter-interface.js').LanguageAdapter|null}
|
|
55
|
+
*/
|
|
56
|
+
getAdapter(id) {
|
|
57
|
+
return this.#adapters.get(id) || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** List all registered adapter IDs. */
|
|
61
|
+
list() {
|
|
62
|
+
return [...this.#adapters.keys()];
|
|
63
|
+
}
|
|
64
|
+
}
|