@0xcraft/powershot 1.1.0 → 1.1.2
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/README.md +43 -18
- package/dist/cli/reports.js +3 -0
- package/dist/cli/review-command.js +8 -2
- package/dist/cli/session-command.js +2 -0
- package/dist/config.js +5 -0
- package/dist/ground.js +297 -95
- package/dist/judges/tools.js +7 -7
- package/dist/lang/packs.js +97 -14
- package/dist/lang/parse-worker.js +15 -0
- package/dist/lang/python-deps.js +21 -8
- package/dist/manifest.js +49 -0
- package/dist/plan.js +7 -0
- package/dist/report/markdown.js +18 -2
- package/dist/report/terminal.js +12 -1
- package/dist/report/viewer.js +11 -1
- package/dist/review.js +43 -20
- package/dist/selftest.js +402 -8
- package/dist/session.js +2 -0
- package/dist/verifiers/foreign-phantom-dep.js +8 -2
- package/dist/verifiers/phantom-config.js +7 -6
- package/docs/architecture.md +52 -12
- package/docs/ci.md +21 -4
- package/examples/github-actions/cli.yml +1 -1
- package/examples/gitlab/.gitlab-ci.yml +1 -1
- package/package.json +1 -1
package/dist/ground.js
CHANGED
|
@@ -1,33 +1,174 @@
|
|
|
1
1
|
import { Project, SyntaxKind } from 'ts-morph';
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
3
|
import { decode } from './text.js';
|
|
4
|
-
import {
|
|
5
|
-
import { packFor,
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { PACKS, packFor, parseIsolated } from './lang/packs.js';
|
|
6
6
|
import { insideRepo, isSymlink, repoPath } from './fspolicy.js';
|
|
7
7
|
const CODE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
8
|
+
const TS_CONFIG = /^tsconfig(?:\..+)?\.json$/i;
|
|
9
|
+
const MISSING_TYPE_PREFIXES = [
|
|
10
|
+
'Cannot find global type',
|
|
11
|
+
'Cannot find global value',
|
|
12
|
+
'Cannot find module',
|
|
13
|
+
'Cannot find name',
|
|
14
|
+
'Cannot find type definition file',
|
|
15
|
+
'Could not find a declaration file for module',
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* A property diagnostic is only exact when the file's ambient types resolved.
|
|
19
|
+
* Missing modules, globals, or declaration files can remove interface
|
|
20
|
+
* augmentations and manufacture downstream errors such as `ImportMeta.url`.
|
|
21
|
+
*/
|
|
22
|
+
function hasTypeEnvironmentGap(sf) {
|
|
23
|
+
return sf.getPreEmitDiagnostics().some((diagnostic) => {
|
|
24
|
+
const message = diagnostic.getMessageText();
|
|
25
|
+
const head = typeof message === 'string' ? message : message.getMessageText();
|
|
26
|
+
return MISSING_TYPE_PREFIXES.some((prefix) => head.startsWith(prefix)) || /^File .+ not found/.test(head);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
8
29
|
export function normalizeName(n) {
|
|
9
30
|
return n.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
10
31
|
}
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
let dir =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
if (dir === stop)
|
|
25
|
-
return undefined;
|
|
32
|
+
/** A lexical repository containment check for paths that have already been resolved. */
|
|
33
|
+
function isWithin(root, path) {
|
|
34
|
+
const rel = relative(root, path);
|
|
35
|
+
return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
|
|
36
|
+
}
|
|
37
|
+
/** Directories from a changed file up to the repository root, nearest first. */
|
|
38
|
+
function ancestorDirectories(root, absPath) {
|
|
39
|
+
const out = [];
|
|
40
|
+
let dir = dirname(absPath);
|
|
41
|
+
while (isWithin(root, dir)) {
|
|
42
|
+
out.push(dir);
|
|
43
|
+
if (dir === root)
|
|
44
|
+
break;
|
|
26
45
|
const parent = dirname(dir);
|
|
27
46
|
if (parent === dir)
|
|
28
|
-
|
|
47
|
+
break;
|
|
29
48
|
dir = parent;
|
|
30
49
|
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** Only inspect the directory chain of changed files; never crawl the monorepo. */
|
|
53
|
+
function configsInDirectory(dir, cache) {
|
|
54
|
+
const known = cache.get(dir);
|
|
55
|
+
if (known)
|
|
56
|
+
return known;
|
|
57
|
+
let configs = [];
|
|
58
|
+
try {
|
|
59
|
+
configs = readdirSync(dir, { withFileTypes: true })
|
|
60
|
+
.filter((entry) => entry.isFile() && TS_CONFIG.test(entry.name))
|
|
61
|
+
.map((entry) => join(dir, entry.name))
|
|
62
|
+
.sort();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// An unreadable ancestor contributes no project; the file remains syntax-only.
|
|
66
|
+
}
|
|
67
|
+
cache.set(dir, configs);
|
|
68
|
+
return configs;
|
|
69
|
+
}
|
|
70
|
+
function configuredProject(root, configPath) {
|
|
71
|
+
try {
|
|
72
|
+
const project = new Project({ tsConfigFilePath: configPath });
|
|
73
|
+
const owned = new Set();
|
|
74
|
+
const sourceAncestors = new Set();
|
|
75
|
+
for (const sf of project.getSourceFiles()) {
|
|
76
|
+
const abs = resolve(sf.getFilePath());
|
|
77
|
+
const safe = insideRepo(root, abs);
|
|
78
|
+
if (!safe || repoPath(root, abs).split('/').includes('node_modules'))
|
|
79
|
+
continue;
|
|
80
|
+
owned.add(repoPath(root, abs));
|
|
81
|
+
let dir = dirname(abs);
|
|
82
|
+
while (isWithin(root, dir)) {
|
|
83
|
+
sourceAncestors.add(dir);
|
|
84
|
+
if (dir === root)
|
|
85
|
+
break;
|
|
86
|
+
const parent = dirname(dir);
|
|
87
|
+
if (parent === dir)
|
|
88
|
+
break;
|
|
89
|
+
dir = parent;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { configPath, project, owned, sourceAncestors, sourceCount: owned.size };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// A broken candidate beside a usable leaf config must not take down the review.
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function directoryDepth(root, dir) {
|
|
100
|
+
const rel = repoPath(root, dir);
|
|
101
|
+
return rel === '' ? 0 : rel.split('/').length;
|
|
102
|
+
}
|
|
103
|
+
function sourceAffinity(root, candidate, absPath) {
|
|
104
|
+
let dir = dirname(absPath);
|
|
105
|
+
while (isWithin(root, dir)) {
|
|
106
|
+
if (candidate.sourceAncestors.has(dir))
|
|
107
|
+
return directoryDepth(root, dir);
|
|
108
|
+
if (dir === root)
|
|
109
|
+
break;
|
|
110
|
+
const parent = dirname(dir);
|
|
111
|
+
if (parent === dir)
|
|
112
|
+
break;
|
|
113
|
+
dir = parent;
|
|
114
|
+
}
|
|
115
|
+
return -1;
|
|
116
|
+
}
|
|
117
|
+
function nameAffinity(root, configPath, absPath) {
|
|
118
|
+
const base = basename(configPath);
|
|
119
|
+
const name = base.slice('tsconfig'.length, -'.json'.length).replace(/^\./, '');
|
|
120
|
+
if (name === '')
|
|
121
|
+
return 0;
|
|
122
|
+
const tokens = new Set(repoPath(root, absPath).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
123
|
+
return name
|
|
124
|
+
.toLowerCase()
|
|
125
|
+
.split(/[^a-z0-9]+/)
|
|
126
|
+
.filter(Boolean)
|
|
127
|
+
.filter((token) => tokens.has(token)).length;
|
|
128
|
+
}
|
|
129
|
+
/** Prefer the leaf project whose existing sources live closest to this file. */
|
|
130
|
+
function bestProject(root, candidates, absPath) {
|
|
131
|
+
return [...candidates].sort((a, b) => sourceAffinity(root, b, absPath) - sourceAffinity(root, a, absPath) ||
|
|
132
|
+
nameAffinity(root, b.configPath, absPath) - nameAffinity(root, a.configPath, absPath) ||
|
|
133
|
+
a.sourceCount - b.sourceCount ||
|
|
134
|
+
a.configPath.localeCompare(b.configPath))[0];
|
|
135
|
+
}
|
|
136
|
+
function looksLikeTest(path) {
|
|
137
|
+
return /(?:^|[/\\])(?:__tests__|tests?)(?:[/\\]|$)/i.test(path) || /\.(?:test|spec)\.[^.]+$/i.test(path);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Resolve one changed file at the nearest useful project boundary.
|
|
141
|
+
*
|
|
142
|
+
* Solution configs (`files: []`) do not claim a type environment. If a sibling leaf
|
|
143
|
+
* config owns the file it wins; if every leaf excludes the file (common for tests),
|
|
144
|
+
* the closest suitable non-empty leaf supplies compiler options and references.
|
|
145
|
+
* Selecting that local leaf avoids opening a repository-wide parent.
|
|
146
|
+
*/
|
|
147
|
+
function projectForFile(root, absPath, directoryCache, projectCache) {
|
|
148
|
+
const rel = repoPath(root, absPath);
|
|
149
|
+
for (const dir of ancestorDirectories(root, absPath)) {
|
|
150
|
+
const configPaths = [...configsInDirectory(dir, directoryCache)].sort((a, b) => nameAffinity(root, b, absPath) - nameAffinity(root, a, absPath) || a.localeCompare(b));
|
|
151
|
+
const projects = [];
|
|
152
|
+
for (const configPath of configPaths) {
|
|
153
|
+
if (!projectCache.has(configPath)) {
|
|
154
|
+
projectCache.set(configPath, configuredProject(root, configPath));
|
|
155
|
+
}
|
|
156
|
+
const project = projectCache.get(configPath);
|
|
157
|
+
if (!project)
|
|
158
|
+
continue;
|
|
159
|
+
// Config names such as `test`, `app`, and `node` are ranked against the file
|
|
160
|
+
// path, so the first owner is the most specific without opening every sibling.
|
|
161
|
+
if (project.owned.has(rel))
|
|
162
|
+
return project;
|
|
163
|
+
projects.push(project);
|
|
164
|
+
}
|
|
165
|
+
const boundaryDepth = directoryDepth(root, dir);
|
|
166
|
+
const leaves = projects.filter((project) => project.sourceCount > 0 &&
|
|
167
|
+
(sourceAffinity(root, project, absPath) > boundaryDepth || looksLikeTest(absPath)));
|
|
168
|
+
if (leaves.length > 0)
|
|
169
|
+
return bestProject(root, leaves, absPath);
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
31
172
|
}
|
|
32
173
|
function depsIn(pkgPath) {
|
|
33
174
|
const deps = new Set();
|
|
@@ -70,36 +211,45 @@ function makeDepsFor(root) {
|
|
|
70
211
|
};
|
|
71
212
|
}
|
|
72
213
|
/**
|
|
73
|
-
* Build the oracle once per run:
|
|
74
|
-
* a syntax-only project
|
|
214
|
+
* Build the oracle once per run: focused type-checked projects for the changed
|
|
215
|
+
* packages, a syntax-only project for unconfigured changes and the base-ref trees,
|
|
216
|
+
* and one deduplicated symbol index over the relevant project closures.
|
|
75
217
|
*/
|
|
76
218
|
export async function buildGround(root, changed, signal) {
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
// reading, but the checker has no program for it — asking one for diagnostics
|
|
86
|
-
// throws from inside TypeScript, which took the whole review down with it.
|
|
87
|
-
const owned = new Set(project.getSourceFiles().map((f) => repoPath(root, String(f.getFilePath()))));
|
|
88
|
-
// A changed file may be new, or excluded from tsconfig — make sure it is present.
|
|
89
|
-
// `readable` is the gate for every file that becomes reviewable: the glob above
|
|
90
|
-
// follows symlinks, so passing this on the way in is not enough on its own.
|
|
219
|
+
root = resolve(root);
|
|
220
|
+
const directoryCache = new Map();
|
|
221
|
+
const projectCache = new Map();
|
|
222
|
+
const selectedProjects = new Set();
|
|
223
|
+
const assigned = new Map();
|
|
224
|
+
const syntaxProject = new Project({ compilerOptions: { allowJs: true, checkJs: false } });
|
|
225
|
+
// `readable` is the gate for every file that becomes reviewable. A tsconfig glob
|
|
226
|
+
// can follow symlinks, so selected project closures are checked again below too.
|
|
91
227
|
const readable = (path) => {
|
|
92
228
|
const abs = insideRepo(root, path);
|
|
93
229
|
return abs && !isSymlink(abs) ? abs : undefined;
|
|
94
230
|
};
|
|
95
231
|
for (const c of changed) {
|
|
232
|
+
if (signal?.aborted)
|
|
233
|
+
break;
|
|
96
234
|
if (!CODE_EXT.test(c.path))
|
|
97
235
|
continue;
|
|
98
236
|
const abs = readable(c.path);
|
|
99
|
-
if (!abs)
|
|
237
|
+
if (!abs || !existsSync(abs))
|
|
100
238
|
continue;
|
|
101
|
-
|
|
102
|
-
|
|
239
|
+
const configured = projectForFile(root, abs, directoryCache, projectCache);
|
|
240
|
+
if (configured) {
|
|
241
|
+
selectedProjects.add(configured);
|
|
242
|
+
assigned.set(c.path, configured);
|
|
243
|
+
// Tests and tooling files are often excluded from the production build config.
|
|
244
|
+
// Adding one explicitly keeps the leaf project's compiler options and imports.
|
|
245
|
+
if (!configured.project.getSourceFile(abs))
|
|
246
|
+
configured.project.addSourceFileAtPath(abs);
|
|
247
|
+
}
|
|
248
|
+
else if (!syntaxProject.getSourceFile(abs)) {
|
|
249
|
+
// No recursive glob: a configless million-file repository still loads only the
|
|
250
|
+
// files in the review.
|
|
251
|
+
syntaxProject.addSourceFileAtPath(abs);
|
|
252
|
+
}
|
|
103
253
|
}
|
|
104
254
|
const beforeProject = new Project({ useInMemoryFileSystem: true });
|
|
105
255
|
const files = [];
|
|
@@ -109,79 +259,78 @@ export async function buildGround(root, changed, signal) {
|
|
|
109
259
|
const abs = readable(c.path);
|
|
110
260
|
if (!abs)
|
|
111
261
|
continue;
|
|
112
|
-
const
|
|
262
|
+
const configured = assigned.get(c.path);
|
|
263
|
+
const sf = configured?.project.getSourceFile(abs) ?? syntaxProject.getSourceFile(abs);
|
|
113
264
|
if (!sf)
|
|
114
265
|
continue;
|
|
115
266
|
const before = c.before === undefined ? undefined : beforeProject.createSourceFile(`/before/${c.path}`, c.before, { overwrite: true });
|
|
116
|
-
files.push({
|
|
267
|
+
files.push({
|
|
268
|
+
sf,
|
|
269
|
+
changed: c,
|
|
270
|
+
before,
|
|
271
|
+
// A bound program with unresolved ambient types is not an exact type oracle.
|
|
272
|
+
// Keep the file reviewable, but make type-dependent checks explicitly partial.
|
|
273
|
+
typed: configured !== undefined && !hasTypeEnvironmentGap(sf),
|
|
274
|
+
});
|
|
117
275
|
}
|
|
276
|
+
const projects = [...selectedProjects].map((selected) => selected.project);
|
|
277
|
+
if (syntaxProject.getSourceFiles().length > 0)
|
|
278
|
+
projects.push(syntaxProject);
|
|
279
|
+
const sourceFiles = uniqueSourceFiles(root, files.map((file) => file.sf), projects);
|
|
280
|
+
const configFiles = [...selectedProjects]
|
|
281
|
+
.map((selected) => repoPath(root, selected.configPath))
|
|
282
|
+
.sort();
|
|
283
|
+
const typed = files.some((file) => file.typed);
|
|
284
|
+
const depsFor = makeDepsFor(root);
|
|
118
285
|
return {
|
|
119
286
|
root,
|
|
120
|
-
|
|
287
|
+
sourceFiles,
|
|
288
|
+
configFiles,
|
|
121
289
|
beforeProject,
|
|
122
290
|
changed,
|
|
123
291
|
files,
|
|
124
|
-
symbolIndex: buildSymbolIndex(
|
|
125
|
-
deps:
|
|
126
|
-
depsFor
|
|
292
|
+
symbolIndex: buildSymbolIndex(sourceFiles, root),
|
|
293
|
+
deps: depsFor(join(root, 'x.ts')),
|
|
294
|
+
depsFor,
|
|
127
295
|
typed,
|
|
128
|
-
internalPrefixes: pathAliasPrefixes(
|
|
296
|
+
internalPrefixes: pathAliasPrefixes(projects),
|
|
129
297
|
foreign: await parseForeign(root, changed, signal),
|
|
130
298
|
envManifest: readEnvManifest(root),
|
|
131
299
|
};
|
|
132
300
|
}
|
|
301
|
+
/** Prefer changed-file SourceFiles, then add each relevant project source once. */
|
|
302
|
+
function uniqueSourceFiles(root, preferred, projects) {
|
|
303
|
+
const byPath = new Map();
|
|
304
|
+
const add = (sf) => {
|
|
305
|
+
const abs = resolve(sf.getFilePath());
|
|
306
|
+
if (!insideRepo(root, abs))
|
|
307
|
+
return;
|
|
308
|
+
const rel = repoPath(root, abs);
|
|
309
|
+
if (rel.split('/').includes('node_modules') || byPath.has(rel))
|
|
310
|
+
return;
|
|
311
|
+
byPath.set(rel, sf);
|
|
312
|
+
};
|
|
313
|
+
for (const sf of preferred)
|
|
314
|
+
add(sf);
|
|
315
|
+
for (const project of projects)
|
|
316
|
+
for (const sf of project.getSourceFiles())
|
|
317
|
+
add(sf);
|
|
318
|
+
return [...byPath.values()];
|
|
319
|
+
}
|
|
133
320
|
/**
|
|
134
321
|
* Prefixes that `compilerOptions.paths` maps back into the repo — `@/*` and friends.
|
|
135
322
|
* They look like package names but resolve to local files, so treating them as
|
|
136
323
|
* dependencies would be wrong.
|
|
137
324
|
*/
|
|
138
|
-
function pathAliasPrefixes(
|
|
325
|
+
function pathAliasPrefixes(projects) {
|
|
139
326
|
const prefixes = new Set();
|
|
140
|
-
for (const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// a workspace declares its aliases in each package's tsconfig, and the root config
|
|
144
|
-
// often only `extends` a shared base — so the root's paths are not the whole story
|
|
145
|
-
for (const file of tsconfigsIn(root)) {
|
|
146
|
-
try {
|
|
147
|
-
const raw = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1');
|
|
148
|
-
const parsed = JSON.parse(raw.replace(/,(\s*[}\]])/g, '$1'));
|
|
149
|
-
for (const pattern of Object.keys(parsed?.compilerOptions?.paths ?? {})) {
|
|
150
|
-
prefixes.add(String(pattern).replace(/\*$/, ''));
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
catch {
|
|
154
|
-
// an unreadable or non-standard tsconfig simply contributes no aliases
|
|
327
|
+
for (const project of projects) {
|
|
328
|
+
for (const pattern of Object.keys(project.getCompilerOptions().paths ?? {})) {
|
|
329
|
+
prefixes.add(pattern.replace(/\*$/, ''));
|
|
155
330
|
}
|
|
156
331
|
}
|
|
157
332
|
return [...prefixes];
|
|
158
333
|
}
|
|
159
|
-
/** every tsconfig in the repo, capped so a huge monorepo cannot stall the run */
|
|
160
|
-
function tsconfigsIn(root) {
|
|
161
|
-
const out = [];
|
|
162
|
-
const walk = (dir, depth) => {
|
|
163
|
-
if (depth > 4 || out.length > 60)
|
|
164
|
-
return;
|
|
165
|
-
let entries;
|
|
166
|
-
try {
|
|
167
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
168
|
-
}
|
|
169
|
-
catch {
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
for (const e of entries) {
|
|
173
|
-
if (e.name === 'node_modules' || e.name.startsWith('.'))
|
|
174
|
-
continue;
|
|
175
|
-
const full = join(dir, e.name);
|
|
176
|
-
if (e.isDirectory())
|
|
177
|
-
walk(full, depth + 1);
|
|
178
|
-
else if (e.name === 'tsconfig.json')
|
|
179
|
-
out.push(full);
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
walk(root, 0);
|
|
183
|
-
return out;
|
|
184
|
-
}
|
|
185
334
|
/**
|
|
186
335
|
* Changed files the TypeScript project cannot hold. Nine of the checks need only a
|
|
187
336
|
* parse tree, so a Python or Go file is reviewable the moment its grammar loads —
|
|
@@ -211,7 +360,7 @@ export function readEnvManifest(root) {
|
|
|
211
360
|
return undefined;
|
|
212
361
|
}
|
|
213
362
|
async function parseForeign(root, changed, signal) {
|
|
214
|
-
const
|
|
363
|
+
const byLanguage = new Map();
|
|
215
364
|
for (const c of changed) {
|
|
216
365
|
// parsing thousands of files is where a large scan spends its time, so a signal
|
|
217
366
|
// has to be honoured here rather than only once the checks begin
|
|
@@ -227,17 +376,70 @@ async function parseForeign(root, changed, signal) {
|
|
|
227
376
|
// costs seconds to produce findings nobody acts on
|
|
228
377
|
if ((statSync(abs, { throwIfNoEntry: false })?.size ?? 0) > 512 * 1024)
|
|
229
378
|
continue;
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
379
|
+
const list = byLanguage.get(pack.name) ?? [];
|
|
380
|
+
list.push({
|
|
381
|
+
changed: c,
|
|
382
|
+
source: decode(readFileSync(abs)),
|
|
383
|
+
// A generated base can be arbitrarily larger than the reviewed result. Do not
|
|
384
|
+
// smuggle it past the current-file limit through the before/after channel.
|
|
385
|
+
beforeSource: c.before !== undefined && Buffer.byteLength(c.before) <= 512 * 1024
|
|
386
|
+
? c.before
|
|
387
|
+
: undefined,
|
|
388
|
+
});
|
|
389
|
+
byLanguage.set(pack.name, list);
|
|
235
390
|
}
|
|
236
|
-
|
|
391
|
+
const parsed = new Map();
|
|
392
|
+
// A worker holds one grammar and a bounded source batch. This keeps both WASM
|
|
393
|
+
// compilation and structured-clone payloads independent of monorepo size.
|
|
394
|
+
const MAX_BATCH_BYTES = 8 * 1024 * 1024;
|
|
395
|
+
const MAX_BATCH_FILES = 128;
|
|
396
|
+
for (const pack of PACKS) {
|
|
397
|
+
const candidates = byLanguage.get(pack.name) ?? [];
|
|
398
|
+
for (let start = 0; start < candidates.length;) {
|
|
399
|
+
let end = start;
|
|
400
|
+
let bytes = 0;
|
|
401
|
+
while (end < candidates.length && end - start < MAX_BATCH_FILES) {
|
|
402
|
+
const candidate = candidates[end];
|
|
403
|
+
const next = Buffer.byteLength(candidate.source) + Buffer.byteLength(candidate.beforeSource ?? '');
|
|
404
|
+
if (end > start && bytes + next > MAX_BATCH_BYTES)
|
|
405
|
+
break;
|
|
406
|
+
bytes += next;
|
|
407
|
+
end++;
|
|
408
|
+
}
|
|
409
|
+
const batch = candidates.slice(start, end);
|
|
410
|
+
const sources = batch.flatMap((candidate) => candidate.beforeSource === undefined
|
|
411
|
+
? [candidate.source]
|
|
412
|
+
: [candidate.source, candidate.beforeSource]);
|
|
413
|
+
const trees = await parseIsolated(pack, sources, signal);
|
|
414
|
+
let index = 0;
|
|
415
|
+
for (const candidate of batch) {
|
|
416
|
+
const tree = trees[index++];
|
|
417
|
+
const beforeTree = candidate.beforeSource === undefined ? undefined : trees[index++];
|
|
418
|
+
if (!tree)
|
|
419
|
+
continue;
|
|
420
|
+
parsed.set(candidate.changed.path, {
|
|
421
|
+
path: candidate.changed.path,
|
|
422
|
+
pack,
|
|
423
|
+
tree,
|
|
424
|
+
beforeTree,
|
|
425
|
+
changed: candidate.changed,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
start = end;
|
|
429
|
+
if (signal?.aborted)
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
if (signal?.aborted)
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
return changed.flatMap((file) => {
|
|
436
|
+
const result = parsed.get(file.path);
|
|
437
|
+
return result ? [result] : [];
|
|
438
|
+
});
|
|
237
439
|
}
|
|
238
|
-
function buildSymbolIndex(
|
|
440
|
+
function buildSymbolIndex(sourceFiles, root) {
|
|
239
441
|
const index = new Map();
|
|
240
|
-
for (const sf of
|
|
442
|
+
for (const sf of sourceFiles) {
|
|
241
443
|
const path = String(sf.getFilePath());
|
|
242
444
|
// the project glob follows symlinked directories, so what it loaded is not
|
|
243
445
|
// proof of where the file is
|
package/dist/judges/tools.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { relPath } from '#app/ground.js';
|
|
3
|
-
import { insideRepo } from '#app/fspolicy.js';
|
|
3
|
+
import { insideRepo, repoPath } from '#app/fspolicy.js';
|
|
4
4
|
const MAX_BYTES = 60_000;
|
|
5
5
|
const MAX_MATCHES = 40;
|
|
6
6
|
export const TOOLS = [
|
|
@@ -58,7 +58,7 @@ function readFile(g, input) {
|
|
|
58
58
|
const abs = insideRepo(g.root, path);
|
|
59
59
|
if (!abs)
|
|
60
60
|
return 'Refused: that path is outside the repository or is not readable for review.';
|
|
61
|
-
const inProject = g.
|
|
61
|
+
const inProject = g.sourceFiles.find((file) => relPath(file, g.root) === repoPath(g.root, abs));
|
|
62
62
|
const text = inProject ? inProject.getFullText() : readFileSync(abs, 'utf8');
|
|
63
63
|
const lines = text.split('\n');
|
|
64
64
|
const start = Math.max(1, Number(input.start ?? 1));
|
|
@@ -79,7 +79,7 @@ function grep(g, input) {
|
|
|
79
79
|
}
|
|
80
80
|
const filter = input.path_contains === undefined ? undefined : String(input.path_contains);
|
|
81
81
|
const hits = [];
|
|
82
|
-
for (const sf of g.
|
|
82
|
+
for (const sf of g.sourceFiles) {
|
|
83
83
|
const abs = sf.getFilePath();
|
|
84
84
|
// the project glob can pull in a file through a symlinked directory, so the
|
|
85
85
|
// boundary is re-checked here rather than trusted from how the file got loaded
|
|
@@ -98,13 +98,13 @@ function grep(g, input) {
|
|
|
98
98
|
return hits.join('\n') + '\n… more matches not shown';
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
-
return hits.length > 0 ? hits.join('\n') : 'No matches.';
|
|
101
|
+
return hits.length > 0 ? hits.join('\n') : 'No matches in the relevant project files.';
|
|
102
102
|
}
|
|
103
103
|
function references(g, input) {
|
|
104
104
|
const symbol = String(input.symbol ?? '');
|
|
105
105
|
if (!symbol)
|
|
106
106
|
return 'No symbol given.';
|
|
107
|
-
for (const sf of g.
|
|
107
|
+
for (const sf of g.sourceFiles) {
|
|
108
108
|
const path = String(sf.getFilePath());
|
|
109
109
|
if (path.includes('/node_modules/') || !insideRepo(g.root, path))
|
|
110
110
|
continue;
|
|
@@ -117,9 +117,9 @@ function references(g, input) {
|
|
|
117
117
|
.map((n) => relPath(n.getSourceFile(), g.root) + ':' + n.getStartLineNumber())
|
|
118
118
|
.filter((r) => !r.includes('node_modules'));
|
|
119
119
|
return refs.length === 0
|
|
120
|
-
? symbol + ' is declared in ' + relPath(sf, g.root) + ' and referenced nowhere.'
|
|
120
|
+
? symbol + ' is declared in ' + relPath(sf, g.root) + ' and referenced nowhere in its project.'
|
|
121
121
|
: symbol + ' declared in ' + relPath(sf, g.root) + ', referenced at:\n' + [...new Set(refs)].slice(0, MAX_MATCHES).join('\n');
|
|
122
122
|
}
|
|
123
|
-
return 'No declaration named ' + symbol + ' found in the
|
|
123
|
+
return 'No declaration named ' + symbol + ' found in the relevant projects.';
|
|
124
124
|
}
|
|
125
125
|
//# sourceMappingURL=tools.js.map
|
package/dist/lang/packs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
+
import { Worker } from 'node:worker_threads';
|
|
3
4
|
/** Shared defaults; a pack overrides only what its grammar spells differently. */
|
|
4
5
|
const COMMON_NODES = {
|
|
5
6
|
identifier: ['identifier'],
|
|
@@ -498,15 +499,6 @@ export function packFor(path) {
|
|
|
498
499
|
}
|
|
499
500
|
let ready;
|
|
500
501
|
const parsers = new Map();
|
|
501
|
-
/**
|
|
502
|
-
* Measured, not guessed, and the measurement is worth writing down because the naive
|
|
503
|
-
* one is misleading. Loading grammars is cheap — all eleven load for ~143MB. Parsing
|
|
504
|
-
* with them is not: V8 tiers up each wasm module in the background, and RSS climbed
|
|
505
|
-
* 63 → 690MB across eleven before the process died inside that compilation. Six
|
|
506
|
-
* grammars sat at ~131MB and were comfortable.
|
|
507
|
-
*/
|
|
508
|
-
const MAX_GRAMMARS = 6;
|
|
509
|
-
export const skippedLanguages = [];
|
|
510
502
|
/**
|
|
511
503
|
* Grammars load lazily and once. A repository with no Python pays nothing for
|
|
512
504
|
* Python, and the wasm runtime is only initialised when a foreign file appears.
|
|
@@ -515,11 +507,6 @@ async function parserFor(pack) {
|
|
|
515
507
|
const cached = parsers.get(pack.name);
|
|
516
508
|
if (cached)
|
|
517
509
|
return cached;
|
|
518
|
-
if (parsers.size >= MAX_GRAMMARS) {
|
|
519
|
-
if (!skippedLanguages.includes(pack.name))
|
|
520
|
-
skippedLanguages.push(pack.name);
|
|
521
|
-
return undefined;
|
|
522
|
-
}
|
|
523
510
|
try {
|
|
524
511
|
if (!ready) {
|
|
525
512
|
ready = (async () => {
|
|
@@ -554,4 +541,100 @@ export async function parse(pack, source) {
|
|
|
554
541
|
return undefined;
|
|
555
542
|
}
|
|
556
543
|
}
|
|
544
|
+
/** Turn a native WASM-backed tree into data that can cross a worker boundary. */
|
|
545
|
+
export function serializeTree(tree) {
|
|
546
|
+
const copy = (raw, field) => {
|
|
547
|
+
const node = raw;
|
|
548
|
+
const children = [];
|
|
549
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
550
|
+
const child = node.child(i);
|
|
551
|
+
if (child)
|
|
552
|
+
children.push(copy(child, node.fieldNameForChild(i) ?? undefined));
|
|
553
|
+
}
|
|
554
|
+
return {
|
|
555
|
+
type: node.type,
|
|
556
|
+
startIndex: node.startIndex,
|
|
557
|
+
endIndex: node.endIndex,
|
|
558
|
+
startPosition: { ...node.startPosition },
|
|
559
|
+
endPosition: { ...node.endPosition },
|
|
560
|
+
named: node.isNamed,
|
|
561
|
+
field,
|
|
562
|
+
children,
|
|
563
|
+
};
|
|
564
|
+
};
|
|
565
|
+
return { root: copy(tree.rootNode) };
|
|
566
|
+
}
|
|
567
|
+
/** Restore the small Node interface the language-independent verifiers consume. */
|
|
568
|
+
function hydrateTree(source, tree) {
|
|
569
|
+
const hydrate = (data) => {
|
|
570
|
+
const children = data.children.map(hydrate);
|
|
571
|
+
return {
|
|
572
|
+
type: data.type,
|
|
573
|
+
get text() { return source.slice(data.startIndex, data.endIndex); },
|
|
574
|
+
startPosition: { ...data.startPosition },
|
|
575
|
+
endPosition: { ...data.endPosition },
|
|
576
|
+
childCount: children.length,
|
|
577
|
+
child: (index) => children[index] ?? null,
|
|
578
|
+
namedChildren: children.filter((_, index) => data.children[index]?.named),
|
|
579
|
+
childForFieldName: (name) => {
|
|
580
|
+
const index = data.children.findIndex((child) => child.field === name);
|
|
581
|
+
return index < 0 ? null : (children[index] ?? null);
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
};
|
|
585
|
+
return { rootNode: hydrate(tree.root) };
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Parse one language in a disposable worker.
|
|
589
|
+
*
|
|
590
|
+
* V8 keeps compiled WASM grammars alive longer than their JS parsers. Eleven
|
|
591
|
+
* grammars in one process reached ~690MB and killed real mixed-language runs.
|
|
592
|
+
* One worker owns one grammar, returns plain trees, and is then terminated, so
|
|
593
|
+
* compiled-grammar memory is bounded by one language rather than by the monorepo's
|
|
594
|
+
* language count. Plain trees still scale with the selected diff.
|
|
595
|
+
*/
|
|
596
|
+
export function parseIsolated(pack, sources, signal) {
|
|
597
|
+
if (sources.length === 0)
|
|
598
|
+
return Promise.resolve([]);
|
|
599
|
+
if (signal?.aborted)
|
|
600
|
+
return Promise.resolve(sources.map(() => undefined));
|
|
601
|
+
return new Promise((resolve) => {
|
|
602
|
+
let worker;
|
|
603
|
+
let settled = false;
|
|
604
|
+
const empty = () => sources.map(() => undefined);
|
|
605
|
+
const finish = (trees) => {
|
|
606
|
+
if (settled)
|
|
607
|
+
return;
|
|
608
|
+
settled = true;
|
|
609
|
+
signal?.removeEventListener('abort', abort);
|
|
610
|
+
// Resolve only after V8 has released this worker's WASM grammar. Otherwise a
|
|
611
|
+
// fast next batch can overlap termination and recreate the memory spike this
|
|
612
|
+
// isolation boundary exists to prevent.
|
|
613
|
+
void worker.terminate().then(() => resolve(trees), () => resolve(trees));
|
|
614
|
+
};
|
|
615
|
+
const abort = () => finish(empty());
|
|
616
|
+
try {
|
|
617
|
+
worker = new Worker(new URL('./parse-worker.js', import.meta.url), {
|
|
618
|
+
workerData: { language: pack.name, sources },
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
resolve(empty());
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
626
|
+
worker.once('message', (raw) => {
|
|
627
|
+
if (!Array.isArray(raw) || raw.length !== sources.length) {
|
|
628
|
+
finish(empty());
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
finish(raw.map((tree, index) => tree ? hydrateTree(sources[index], tree) : undefined));
|
|
632
|
+
});
|
|
633
|
+
worker.once('error', () => finish(empty()));
|
|
634
|
+
worker.once('exit', () => finish(empty()));
|
|
635
|
+
// Close the narrow race between the early check and listener registration.
|
|
636
|
+
if (signal?.aborted)
|
|
637
|
+
abort();
|
|
638
|
+
});
|
|
639
|
+
}
|
|
557
640
|
//# sourceMappingURL=packs.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { PACKS, parse, serializeTree } from './packs.js';
|
|
3
|
+
async function run(input) {
|
|
4
|
+
const pack = PACKS.find((candidate) => candidate.name === input.language);
|
|
5
|
+
if (!pack || !Array.isArray(input.sources))
|
|
6
|
+
return [];
|
|
7
|
+
const trees = [];
|
|
8
|
+
for (const source of input.sources) {
|
|
9
|
+
const tree = await parse(pack, source);
|
|
10
|
+
trees.push(tree ? serializeTree(tree) : undefined);
|
|
11
|
+
}
|
|
12
|
+
return trees;
|
|
13
|
+
}
|
|
14
|
+
void run(workerData).then((trees) => parentPort?.postMessage(trees), () => parentPort?.postMessage([]));
|
|
15
|
+
//# sourceMappingURL=parse-worker.js.map
|