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.
@@ -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
+ }