codetac 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/package.json +60 -0
- package/readme.md +172 -0
- package/src/action-view.mjs +104 -0
- package/src/ai.mjs +504 -0
- package/src/boundaries.mjs +483 -0
- package/src/browser/bar.js +593 -0
- package/src/cli.mjs +556 -0
- package/src/detect.mjs +198 -0
- package/src/diagnose.mjs +121 -0
- package/src/digest.mjs +317 -0
- package/src/home.mjs +15 -0
- package/src/origins.mjs +182 -0
- package/src/page.mjs +220 -0
- package/src/panel.mjs +800 -0
- package/src/recording.mjs +124 -0
- package/src/redact.mjs +35 -0
- package/src/register.mjs +94 -0
- package/src/runtime.mjs +353 -0
- package/src/sentences.mjs +209 -0
- package/src/store.mjs +376 -0
- package/src/transform.mjs +255 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { parse } from 'acorn';
|
|
2
|
+
import { fullAncestor } from 'acorn-walk';
|
|
3
|
+
import MagicString from 'magic-string';
|
|
4
|
+
import { AnyMap, originalPositionFor } from '@jridgewell/trace-mapping';
|
|
5
|
+
import { relative, isAbsolute, sep } from 'node:path';
|
|
6
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
8
|
+
import { libraryPointFor, libraryPoints } from './boundaries.mjs';
|
|
9
|
+
|
|
10
|
+
export const runtimeKey = 'codetac.phase0.runtime';
|
|
11
|
+
|
|
12
|
+
// Only real comments count: the same text inside a string literal (for
|
|
13
|
+
// example code passed to eval) is not this module's source map.
|
|
14
|
+
function inputMap(comments, filename) {
|
|
15
|
+
const matches = comments.filter(comment => comment.type === 'Line')
|
|
16
|
+
.map(comment => comment.value.match(/^[#@]\s*sourceMappingURL=(\S+)/)).filter(Boolean);
|
|
17
|
+
if (!matches.length) return null;
|
|
18
|
+
const reference = matches.at(-1)[1];
|
|
19
|
+
const base = pathToFileURL(filename).href;
|
|
20
|
+
if (reference.startsWith('data:')) {
|
|
21
|
+
const comma = reference.indexOf(',');
|
|
22
|
+
const raw = reference.slice(0, comma).includes(';base64')
|
|
23
|
+
? Buffer.from(reference.slice(comma + 1), 'base64').toString('utf8')
|
|
24
|
+
: decodeURIComponent(reference.slice(comma + 1));
|
|
25
|
+
return new AnyMap(JSON.parse(raw), base);
|
|
26
|
+
}
|
|
27
|
+
const url = new URL(reference, base);
|
|
28
|
+
if (url.protocol !== 'file:') throw new Error('non-local source map');
|
|
29
|
+
return new AnyMap(JSON.parse(readFileSync(url, 'utf8')), url.href);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const localFiles = new Map();
|
|
33
|
+
function isLocalFile(file) {
|
|
34
|
+
if (!localFiles.has(file)) {
|
|
35
|
+
let result = false;
|
|
36
|
+
try { result = statSync(file).isFile(); } catch {}
|
|
37
|
+
localFiles.set(file, result);
|
|
38
|
+
}
|
|
39
|
+
return localFiles.get(file);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Code passed to eval as a string literal with its own inline source map (for
|
|
43
|
+
// example bundlers' "eval" development modes) never reaches the module hooks.
|
|
44
|
+
// It is instrumented like a module, with the same origin filter.
|
|
45
|
+
function evalLiteral(node) {
|
|
46
|
+
const argument = node.arguments[0];
|
|
47
|
+
return node.callee.type === 'Identifier' && node.callee.name === 'eval' && node.arguments.length === 1
|
|
48
|
+
&& argument.type === 'Literal' && typeof argument.value === 'string'
|
|
49
|
+
&& /\/\/[#@]\s*sourceMappingURL=data:/.test(argument.value) ? argument : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function contains(outer, inner) { return inner.start >= outer.start && inner.end <= outer.end; }
|
|
53
|
+
|
|
54
|
+
export function transform(source, filename, format = 'module', projectRoot = null, register = null) {
|
|
55
|
+
// The declared format can disagree with the syntax when another loader
|
|
56
|
+
// (tsx, ts-node...) converts ESM to CommonJS after this hook. The wrappers
|
|
57
|
+
// inserted below are valid in both, so parse with whichever one fits.
|
|
58
|
+
const options = sourceType => ({ ecmaVersion: 'latest', sourceType, allowHashBang: true,
|
|
59
|
+
allowReturnOutsideFunction: sourceType === 'script' && format === 'commonjs' });
|
|
60
|
+
let sourceType = format === 'module' ? 'module' : 'script';
|
|
61
|
+
let tokens = [];
|
|
62
|
+
let comments = [];
|
|
63
|
+
let ast;
|
|
64
|
+
try {
|
|
65
|
+
ast = parse(source, { ...options(sourceType), onToken: tokens, onComment: comments, locations: true });
|
|
66
|
+
} catch (error) {
|
|
67
|
+
sourceType = sourceType === 'module' ? 'script' : 'module';
|
|
68
|
+
tokens = [];
|
|
69
|
+
comments = [];
|
|
70
|
+
try { ast = parse(source, { ...options(sourceType), onToken: tokens, onComment: comments, locations: true }); }
|
|
71
|
+
catch { throw error; }
|
|
72
|
+
}
|
|
73
|
+
const output = new MagicString(source);
|
|
74
|
+
const diagnostics = [];
|
|
75
|
+
let map;
|
|
76
|
+
try { map = inputMap(comments, filename); }
|
|
77
|
+
catch { return { source, count: 0, diagnostics: [{ reason: 'source-map-unreadable', file: filename }] }; }
|
|
78
|
+
let count = 0;
|
|
79
|
+
fullAncestor(ast, node => {
|
|
80
|
+
const literal = node.type === 'CallExpression' && evalLiteral(node);
|
|
81
|
+
if (!literal) return;
|
|
82
|
+
try {
|
|
83
|
+
const inner = transform(literal.value, filename, 'script', projectRoot, register);
|
|
84
|
+
diagnostics.push(...inner.diagnostics);
|
|
85
|
+
if (inner.count) {
|
|
86
|
+
output.overwrite(literal.start, literal.end, JSON.stringify(inner.source));
|
|
87
|
+
count += inner.count;
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
diagnostics.push({ reason: 'eval-code-transform-failed', file: filename, line: literal.loc.start.line });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
fullAncestor(ast, (node, _state, ancestors) => {
|
|
94
|
+
if (!['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node.type)) return;
|
|
95
|
+
const parent = ancestors.at(-2);
|
|
96
|
+
let name = node.id?.name || parent?.id?.name || parent?.key?.name || parent?.key?.value || '<anonymous>';
|
|
97
|
+
// Decide eligibility first: skipped constructs are only reported for
|
|
98
|
+
// project code, not for bundler runtimes or dependencies inside a bundle.
|
|
99
|
+
let file = filename;
|
|
100
|
+
let line = node.loc.start.line;
|
|
101
|
+
let column = node.loc.start.column + 1;
|
|
102
|
+
let mapped = false;
|
|
103
|
+
let endLine = node.loc.end.line;
|
|
104
|
+
if (map) {
|
|
105
|
+
const end = originalPositionFor(map, { line: node.loc.end.line, column: Math.max(0, node.loc.end.column - 1) });
|
|
106
|
+
endLine = end.line ?? null;
|
|
107
|
+
const original = originalPositionFor(map, { line, column: column - 1 });
|
|
108
|
+
if (original.source && original.line != null) {
|
|
109
|
+
file = original.source.startsWith('file:') ? fileURLToPath(original.source) : original.source;
|
|
110
|
+
line = original.line;
|
|
111
|
+
column = original.column + 1;
|
|
112
|
+
name = original.name || name;
|
|
113
|
+
mapped = true;
|
|
114
|
+
if (end.source !== original.source) endLine = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
let libraryIndex = -1;
|
|
118
|
+
if (projectRoot) {
|
|
119
|
+
const path = relative(projectRoot, file);
|
|
120
|
+
// Bundles may mix application and dependency code. Only original local
|
|
121
|
+
// project sources are traced, regardless of the bundler's identity.
|
|
122
|
+
// Unmapped code inside hidden folders (.next, .nuxt, .svelte-kit...) is
|
|
123
|
+
// build output or bundler glue, not source the person wrote.
|
|
124
|
+
const parts = path.split(sep);
|
|
125
|
+
const project = isAbsolute(file) && path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)
|
|
126
|
+
&& !parts.includes('node_modules') && (!map || (mapped && isLocalFile(file)))
|
|
127
|
+
&& (mapped || !parts.slice(0, -1).some(part => part.startsWith('.')));
|
|
128
|
+
if (!project) {
|
|
129
|
+
// Known library functions become boundaries, wherever they are loaded from.
|
|
130
|
+
libraryIndex = libraryPointFor(file, String(name));
|
|
131
|
+
if (libraryIndex < 0 || node.type === 'ArrowFunctionExpression' || node.generator || node.body.type !== 'BlockStatement') return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const global = `globalThis[Symbol.for(${JSON.stringify(runtimeKey)})]`;
|
|
135
|
+
if (libraryIndex >= 0) {
|
|
136
|
+
const assignments = (libraryPoints[libraryIndex].callbacks ?? []).map(index => node.params[index])
|
|
137
|
+
.map(parameter => parameter?.type === 'AssignmentPattern' ? parameter.left : parameter)
|
|
138
|
+
.filter(parameter => parameter?.type === 'Identifier')
|
|
139
|
+
.map(({ name: parameter }) => `if(typeof ${parameter}==='function')${parameter}=${global}.callback(${parameter});`).join('');
|
|
140
|
+
insertBody(`${global}.library(${libraryIndex},this,arguments,${JSON.stringify(String(name))},${node.async ? 'async ' : ''}()=>{${assignments}`);
|
|
141
|
+
count++;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (node.generator || (parent?.type === 'MethodDefinition' && parent.kind === 'constructor')) {
|
|
145
|
+
diagnostics.push({ reason: node.generator ? 'generator-skipped' : 'constructor-skipped', file, line });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// Direct eval is scope-sensitive; do not move it to an inner closure.
|
|
149
|
+
const parameterNames = new Set();
|
|
150
|
+
for (const parameter of node.params) fullAncestor(parameter, child => {
|
|
151
|
+
if (child.type === 'Identifier') parameterNames.add(child.name);
|
|
152
|
+
});
|
|
153
|
+
let parameterRedeclaration = false;
|
|
154
|
+
fullAncestor(node.body, (child, _state, chain) => {
|
|
155
|
+
if (chain.some(ancestor => ancestor !== child && ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(ancestor.type))) return;
|
|
156
|
+
if (child.type === 'VariableDeclaration' && child.kind === 'var') {
|
|
157
|
+
for (const declaration of child.declarations) fullAncestor(declaration.id, binding => {
|
|
158
|
+
if (binding.type === 'Identifier' && parameterNames.has(binding.name)) parameterRedeclaration = true;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (child.type === 'FunctionDeclaration' && parameterNames.has(child.id?.name)) parameterRedeclaration = true;
|
|
162
|
+
});
|
|
163
|
+
if (parameterRedeclaration) {
|
|
164
|
+
diagnostics.push({ reason: 'parameter-redeclaration-skipped', file, line });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
let directEval = false;
|
|
168
|
+
let opaqueEval = false;
|
|
169
|
+
fullAncestor(node.body, child => {
|
|
170
|
+
if (child.type === 'CallExpression' && child.callee.type === 'Identifier' && child.callee.name === 'eval') {
|
|
171
|
+
directEval = true;
|
|
172
|
+
if (!evalLiteral(child)) opaqueEval = true;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
if (directEval) {
|
|
176
|
+
// A wrapper whose only evals are mapped literals is bundler glue: the
|
|
177
|
+
// evaluated code was handled above, so it is not a gap in the project.
|
|
178
|
+
if (opaqueEval) diagnostics.push({ reason: 'direct-eval-skipped', file, line });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// The end line lets the panel show the whole function; it is only kept
|
|
182
|
+
// when it maps to the same original file, after the start.
|
|
183
|
+
if (map && !(endLine >= line)) endLine = null;
|
|
184
|
+
// Detail on request (Phase 4): the callback receives a capture object,
|
|
185
|
+
// null unless detail was asked for this function. It records the
|
|
186
|
+
// parameters (bound names, after defaults and destructuring) and the
|
|
187
|
+
// original line of each statement that runs. When off, each costs a
|
|
188
|
+
// null check.
|
|
189
|
+
const bound = [];
|
|
190
|
+
for (const parameter of node.params) fullAncestor(parameter, (child, _state, chain) => {
|
|
191
|
+
const holder = chain.at(-2);
|
|
192
|
+
if (child.type !== 'Identifier') return;
|
|
193
|
+
// Skip keys of object patterns and default values: only bound names.
|
|
194
|
+
if (holder?.type === 'Property' && holder.key === child && holder.value !== child) return;
|
|
195
|
+
if (holder?.type === 'AssignmentPattern' && holder.right === child) return;
|
|
196
|
+
if (holder?.type === 'MemberExpression' || holder?.type === 'CallExpression') return;
|
|
197
|
+
if (chain.slice(0, -1).some(item => item.type === 'AssignmentPattern' && item.right && contains(item.right, child))) return;
|
|
198
|
+
if (!bound.includes(child.name)) bound.push(child.name);
|
|
199
|
+
});
|
|
200
|
+
const capture = '__codetac$c';
|
|
201
|
+
const details = { function: String(name), file, line, endLine, column, mapped, async: node.async, params: bound };
|
|
202
|
+
// With a runtime registry, each call passes a numeric id instead of
|
|
203
|
+
// allocating and serialising the metadata again.
|
|
204
|
+
const meta = register ? String(register(details)) : JSON.stringify(details);
|
|
205
|
+
const call = `${global}.run(${meta},${node.async ? 'async ' : ''}(${capture})=>{${capture}&&${capture}.a([${bound.join(',')}]);`;
|
|
206
|
+
const originalLine = position => {
|
|
207
|
+
if (!map) return position.line;
|
|
208
|
+
const found = originalPositionFor(map, { line: position.line, column: position.column });
|
|
209
|
+
if (found.source == null || found.line == null) return null;
|
|
210
|
+
const source = found.source.startsWith('file:') ? fileURLToPath(found.source) : found.source;
|
|
211
|
+
return source === file ? found.line : null;
|
|
212
|
+
};
|
|
213
|
+
// A statement that spans lines marks all of them, unless it holds other
|
|
214
|
+
// statements (if, loops, try…), which mark their own lines.
|
|
215
|
+
const mark = (at, until) => {
|
|
216
|
+
const original = originalLine(at);
|
|
217
|
+
if (original == null) return '';
|
|
218
|
+
const last = until ? originalLine(until) : null;
|
|
219
|
+
return last != null && last > original ? `${capture}&&${capture}.l(${original},${last});` : `${capture}&&${capture}.l(${original});`;
|
|
220
|
+
};
|
|
221
|
+
const HOLDERS = /^(If|For|ForIn|ForOf|While|DoWhile|Try|Switch|Block|Labeled|With)Statement$/;
|
|
222
|
+
function insertBody(call) {
|
|
223
|
+
let insertion = node.body.start + 1;
|
|
224
|
+
for (const statement of node.body.body) {
|
|
225
|
+
if (!statement.directive) break;
|
|
226
|
+
insertion = statement.end;
|
|
227
|
+
}
|
|
228
|
+
output.appendLeft(insertion, `;return ${call}`);
|
|
229
|
+
output.prependRight(node.body.end - 1, '});');
|
|
230
|
+
}
|
|
231
|
+
if (node.body.type === 'BlockStatement') {
|
|
232
|
+
insertBody(call);
|
|
233
|
+
// Statements of this function (not of functions inside it), in lists
|
|
234
|
+
// where a statement can be inserted before them.
|
|
235
|
+
fullAncestor(node.body, (child, _state, chain) => {
|
|
236
|
+
const holder = chain.at(-2);
|
|
237
|
+
if (!holder || !/Statement|Declaration|Directive/.test(child.type) || child.directive) return;
|
|
238
|
+
const list = holder.type === 'BlockStatement' || holder.type === 'SwitchCase' ? (holder.body ?? holder.consequent) : null;
|
|
239
|
+
if (!list || !list.includes(child)) return;
|
|
240
|
+
if (chain.slice(0, -1).some(item => item !== node && item !== node.body && /Function|StaticBlock|Class/.test(item.type))) return;
|
|
241
|
+
if (child.type === 'FunctionDeclaration' || child.type === 'ClassDeclaration') return;
|
|
242
|
+
const text = mark(child.loc.start, HOLDERS.test(child.type) ? null : child.loc.end);
|
|
243
|
+
if (text) output.prependRight(child.start, text);
|
|
244
|
+
});
|
|
245
|
+
} else {
|
|
246
|
+
const arrow = tokens.findLast(token => token.type.label === '=>' && token.start >= node.start && token.end <= node.body.start);
|
|
247
|
+
output.prependRight(arrow.end, `{return ${call}${mark(node.body.loc.start, node.body.loc.end)}return (`);
|
|
248
|
+
output.appendLeft(node.end, ');});}');
|
|
249
|
+
}
|
|
250
|
+
count++;
|
|
251
|
+
});
|
|
252
|
+
const generated = output.toString();
|
|
253
|
+
parse(generated, options(sourceType));
|
|
254
|
+
return { source: generated, count, diagnostics };
|
|
255
|
+
}
|