diffninja 0.3.1 → 0.4.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/README.md +65 -11
- package/dist/executables.d.ts +18 -0
- package/dist/executables.js +32 -0
- package/dist/git.d.ts +26 -1
- package/dist/git.js +56 -4
- package/dist/languages/child-env.d.ts +11 -0
- package/dist/languages/child-env.js +60 -0
- package/dist/languages/grammar-lock.d.ts +569 -0
- package/dist/languages/grammar-lock.js +574 -0
- package/dist/languages/grammars.d.ts +69 -9
- package/dist/languages/grammars.js +186 -119
- package/dist/review/call-flow-html.d.ts +3 -1
- package/dist/review/call-flow-html.js +13 -11
- package/dist/review/change-facts.d.ts +21 -1
- package/dist/review/change-facts.js +271 -49
- package/dist/review/cli.js +13 -1
- package/dist/review/connected-analysis.d.ts +4 -1
- package/dist/review/connected-analysis.js +4 -2
- package/dist/review/connected-html.d.ts +15 -4
- package/dist/review/connected-html.js +342 -35
- package/dist/review/connected.js +47 -14
- package/dist/review/escape-html.d.ts +5 -1
- package/dist/review/escape-html.js +7 -2
- package/dist/review/explanation.d.ts +4 -0
- package/dist/review/explanation.js +6 -1
- package/dist/review/github.d.ts +56 -0
- package/dist/review/github.js +234 -33
- package/dist/review/grammars-command.d.ts +12 -0
- package/dist/review/grammars-command.js +60 -0
- package/dist/review/hidden-characters.d.ts +31 -0
- package/dist/review/hidden-characters.js +113 -0
- package/dist/review/history.js +7 -3
- package/dist/review/html.d.ts +3 -2
- package/dist/review/html.js +19 -18
- package/dist/review/input.js +5 -2
- package/dist/review/intent.d.ts +7 -0
- package/dist/review/intent.js +25 -3
- package/dist/review/markdown.js +11 -0
- package/dist/review/mcp-cli.js +4 -1
- package/dist/review/mcp.d.ts +7 -1
- package/dist/review/mcp.js +145 -59
- package/dist/review/pipeline.d.ts +2 -1
- package/dist/review/pipeline.js +4 -3
- package/dist/review/pr-input.d.ts +7 -0
- package/dist/review/pr-input.js +25 -3
- package/dist/review/process-html.d.ts +1 -5
- package/dist/review/process-html.js +3 -12
- package/dist/review/questions.js +11 -3
- package/dist/review/reference-check.d.ts +5 -1
- package/dist/review/reference-check.js +40 -16
- package/dist/review/report-pages.d.ts +24 -9
- package/dist/review/report-pages.js +111 -28
- package/dist/review/result-budget.d.ts +28 -0
- package/dist/review/result-budget.js +136 -0
- package/dist/review/service.js +26 -2
- package/dist/review/setup.d.ts +1 -1
- package/dist/review/setup.js +9 -4
- package/dist/review/types.d.ts +19 -5
- package/dist/review/types.js +2 -1
- package/dist/review/update-check.d.ts +35 -0
- package/dist/review/update-check.js +76 -0
- package/dist/run.js +11 -5
- package/npm-shrinkwrap.json +3483 -0
- package/package.json +3 -2
|
@@ -1,55 +1,135 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { homedir } from "node:os";
|
|
5
6
|
import { dirname, isAbsolute, join } from "node:path";
|
|
6
7
|
import { z } from "zod";
|
|
8
|
+
import { packageVersion } from "../review/version.js";
|
|
9
|
+
import { npmEnvironment } from "./child-env.js";
|
|
10
|
+
import { GRAMMAR_BUILD_ONLY, GRAMMAR_PACKAGE_JSON, GRAMMAR_PACKAGE_LOCK, GRAMMAR_PINS } from "./grammar-lock.js";
|
|
7
11
|
function errnoCode(err) {
|
|
8
12
|
// SAFETY: Node require/fs failures are ErrnoException with optional string code.
|
|
9
13
|
return err.code;
|
|
10
14
|
}
|
|
11
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Grammars that ship inside diffninja's own package (regular dependencies). Any
|
|
17
|
+
* other grammar comes only from the cache below, never from whatever
|
|
18
|
+
* `node_modules` happens to sit above the install.
|
|
19
|
+
*/
|
|
20
|
+
const BUNDLED_GRAMMARS = new Set(["tree-sitter-javascript", "tree-sitter-typescript"]);
|
|
21
|
+
/**
|
|
22
|
+
* Where `diffninja grammars install` puts the pinned grammars. Private to
|
|
23
|
+
* diffninja: the shared calldiff cache holds whatever an unpinned install once
|
|
24
|
+
* fetched, and is never read.
|
|
25
|
+
*/
|
|
12
26
|
export function grammarCacheDir() {
|
|
13
|
-
const override = process.env.
|
|
27
|
+
const override = process.env.DIFFNINJA_GRAMMAR_CACHE;
|
|
14
28
|
if (override)
|
|
15
29
|
return override;
|
|
16
|
-
return join(homedir(), ".cache", "
|
|
30
|
+
return join(homedir(), ".cache", "diffninja", "grammars");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Written last by the installer, so a cache without it (or with another lock's) is not trusted.
|
|
34
|
+
* It is a consistency check, not authentication. It holds only public data (the lock's digest),
|
|
35
|
+
* so anyone who can write the directory can write one. Ownership is what cacheDirectoryProblem checks.
|
|
36
|
+
*/
|
|
37
|
+
const CACHE_MARKER = ".diffninja-grammars.json";
|
|
38
|
+
const markerSchema = z.object({ format: z.literal(1), lockSha256: z.string(), installedAt: z.string(), built: z.boolean().optional() });
|
|
39
|
+
/**
|
|
40
|
+
* Why grammars in this directory must not be loaded, or undefined when it is safe to.
|
|
41
|
+
* It must belong to the user running diffninja and be writable by no one else. A missing
|
|
42
|
+
* directory is no problem, since there is nothing in it to trust. Windows has no uid or mode bits,
|
|
43
|
+
* so nothing is checked there.
|
|
44
|
+
*/
|
|
45
|
+
export function cacheDirectoryProblem(dir) {
|
|
46
|
+
const uid = process.getuid?.();
|
|
47
|
+
const stats = uid === undefined ? undefined : statSync(dir, { throwIfNoEntry: false });
|
|
48
|
+
if (stats === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
if (stats.uid !== uid)
|
|
51
|
+
return `${dir} belongs to another user (uid ${stats.uid})`;
|
|
52
|
+
if ((stats.mode & 0o022) !== 0)
|
|
53
|
+
return `other users can write to ${dir} (mode ${(stats.mode & 0o777).toString(8)})`;
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
/** The exact version diffninja pins for a grammar package, or undefined when it is not one of them. */
|
|
57
|
+
export function pinnedVersion(npmPackage) {
|
|
58
|
+
// hasOwn: "constructor" and the like must not read as pinned through the prototype.
|
|
59
|
+
// SAFETY: hasOwn just proved npmPackage is one of GRAMMAR_PINS's own keys.
|
|
60
|
+
return Object.hasOwn(GRAMMAR_PINS, npmPackage) ? GRAMMAR_PINS[npmPackage] : undefined;
|
|
61
|
+
}
|
|
62
|
+
function needsBuild(npmPackage) {
|
|
63
|
+
// SAFETY: widening a tuple of string literals to string[] only to test membership.
|
|
64
|
+
return GRAMMAR_BUILD_ONLY.includes(npmPackage);
|
|
17
65
|
}
|
|
18
|
-
|
|
19
|
-
|
|
66
|
+
let cachedDigest;
|
|
67
|
+
/** Fingerprint of the lock this build of diffninja installs from. */
|
|
68
|
+
export function grammarLockDigest() {
|
|
69
|
+
cachedDigest ??= createHash("sha256").update(JSON.stringify(GRAMMAR_PACKAGE_LOCK)).digest("hex");
|
|
70
|
+
return cachedDigest;
|
|
71
|
+
}
|
|
72
|
+
/** The marker of a cache this diffninja's lock installed, or undefined for anything else. */
|
|
73
|
+
function readCacheMarker(cacheDir) {
|
|
74
|
+
if (cacheDirectoryProblem(cacheDir) !== undefined)
|
|
75
|
+
return undefined;
|
|
76
|
+
try {
|
|
77
|
+
const marker = markerSchema.safeParse(JSON.parse(readFileSync(join(cacheDir, CACHE_MARKER), "utf8")));
|
|
78
|
+
return marker.success && marker.data.lockSha256 === grammarLockDigest() ? marker.data : undefined;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const packageVersionSchema = z.object({ version: z.string() });
|
|
85
|
+
/** The installed directory of a pinned grammar, when the cache is one diffninja installed and holds exactly the pinned version. */
|
|
86
|
+
function pinnedGrammarRoot(cacheDir, npmPackage) {
|
|
87
|
+
const pinned = pinnedVersion(npmPackage);
|
|
88
|
+
const marker = pinned === undefined ? undefined : readCacheMarker(cacheDir);
|
|
89
|
+
if (pinned === undefined || marker === undefined)
|
|
90
|
+
return undefined;
|
|
91
|
+
// A grammar with no prebuilt binary loads only after the person asked for its source build.
|
|
92
|
+
if (needsBuild(npmPackage) && marker.built !== true)
|
|
93
|
+
return undefined;
|
|
94
|
+
const root = join(cacheDir, "node_modules", npmPackage);
|
|
95
|
+
try {
|
|
96
|
+
const manifest = packageVersionSchema.safeParse(JSON.parse(readFileSync(join(root, "package.json"), "utf8")));
|
|
97
|
+
return manifest.success && manifest.data.version === pinned ? root : undefined;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
20
102
|
}
|
|
21
|
-
const cacheManifestSchema = z
|
|
22
|
-
.object({ allowScripts: z.record(z.string(), z.unknown()).optional() })
|
|
23
|
-
.catchall(z.unknown());
|
|
24
103
|
/**
|
|
25
|
-
* The
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* one. Earlier npm ignores the field. A cache written before this keeps its
|
|
29
|
-
* other fields and gains the entry.
|
|
104
|
+
* The command that installs these grammars where this diffninja reads them. It names this
|
|
105
|
+
* version, because a cache is read only by a diffninja with the same lock, and `@latest` or
|
|
106
|
+
* whatever `diffninja` is on PATH may have another. Kotlin and Perl need `--build`.
|
|
30
107
|
*/
|
|
31
|
-
export function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
108
|
+
export function grammarsInstallCommand(npmPackages) {
|
|
109
|
+
return `npx -y diffninja@${packageVersion()} grammars install${npmPackages.some(needsBuild) ? " --build" : ""}`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A grammar this review needed is not installed. diffninja never downloads code
|
|
113
|
+
* while it reviews; the person installs the pinned set once, on purpose.
|
|
114
|
+
*/
|
|
115
|
+
export class GrammarNotInstalledError extends Error {
|
|
116
|
+
npmPackage;
|
|
117
|
+
constructor(npmPackage) {
|
|
118
|
+
super(pinnedVersion(npmPackage) === undefined
|
|
119
|
+
? `${npmPackage} is not one of the grammars diffninja installs, so call flows skip its files.`
|
|
120
|
+
: needsBuild(npmPackage)
|
|
121
|
+
? `The ${npmPackage} grammar ships no prebuilt binary, so call flows skip its files until it is compiled on this machine. diffninja does not download or build code while it reviews: run \`${grammarsInstallCommand([npmPackage])}\` once (needs Python and a C/C++ compiler), then review again.`
|
|
122
|
+
: `The ${npmPackage} grammar is not installed, so call flows skip its files. diffninja does not download code while it reviews: run \`${grammarsInstallCommand([npmPackage])}\` once to add the pinned grammars, then review again.`);
|
|
123
|
+
this.npmPackage = npmPackage;
|
|
124
|
+
this.name = "GrammarNotInstalledError";
|
|
48
125
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
126
|
+
}
|
|
127
|
+
const missingGrammars = new Set();
|
|
128
|
+
/** The grammars that were needed and absent since the last call, sorted; clears the record. */
|
|
129
|
+
export function takeMissingGrammars() {
|
|
130
|
+
const names = [...missingGrammars].sort();
|
|
131
|
+
missingGrammars.clear();
|
|
132
|
+
return names;
|
|
53
133
|
}
|
|
54
134
|
/**
|
|
55
135
|
* Absolute path to one of npm's JS entry points (`npm-cli.js`, `npx-cli.js`),
|
|
@@ -215,98 +295,90 @@ function requireGrammar(require, npmPackage, packageRoot) {
|
|
|
215
295
|
return binding;
|
|
216
296
|
}
|
|
217
297
|
else {
|
|
218
|
-
// A
|
|
219
|
-
//
|
|
220
|
-
// the bad artifact and let the load retry compile from source.
|
|
298
|
+
// A prebuild for another CPU cannot load, and repairing it means compiling:
|
|
299
|
+
// say so instead of editing the cache.
|
|
221
300
|
const bad = mislabeledPrebuild(packageRoot);
|
|
222
301
|
if (bad !== null) {
|
|
223
|
-
|
|
224
|
-
try {
|
|
225
|
-
// SAFETY: grammar packages export a module compatible with GrammarModule.
|
|
226
|
-
return require(npmPackage);
|
|
227
|
-
}
|
|
228
|
-
catch {
|
|
229
|
-
const binding = loadNativeBinding(packageRoot);
|
|
230
|
-
if (binding)
|
|
231
|
-
return binding;
|
|
232
|
-
}
|
|
302
|
+
throw new Error(`${npmPackage} ships a native build for another CPU (${bad}), so it cannot load on ${process.platform}/${process.arch}.`);
|
|
233
303
|
}
|
|
234
304
|
}
|
|
235
305
|
throw new Error(withNativeLoadHints(npmPackage, failure));
|
|
236
306
|
}
|
|
237
307
|
}
|
|
238
|
-
function
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// 0.4+ is ESM-with-TLA; 0.2.0 is CJS and loads via createRequire.
|
|
244
|
-
return "@tree-sitter-grammars/tree-sitter-lua@0.2.0";
|
|
245
|
-
default:
|
|
246
|
-
return npmPackage;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
function sleepMs(ms) {
|
|
250
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
251
|
-
}
|
|
252
|
-
/** Why this grammar's install can fail beyond npm itself, for the error text. */
|
|
253
|
-
function installFailureHint(npmPackage) {
|
|
254
|
-
if (npmPackage === "tree-sitter-swift") {
|
|
255
|
-
return ("tree-sitter-swift downloads its parser CLI from GitHub Releases during " +
|
|
256
|
-
"install; a proxy or offline network fails that download. Retry with " +
|
|
257
|
-
"network access to github.com, or pre-populate CALLDIFF_GRAMMAR_CACHE.");
|
|
308
|
+
function runNpm(cwd, args, build) {
|
|
309
|
+
const npm = npmSpawnSpec(args);
|
|
310
|
+
try {
|
|
311
|
+
// A minimal environment: no token of the engineer's reaches npm or any install script.
|
|
312
|
+
execFileSync(npm.file, npm.args, { cwd, env: npmEnvironment(process.env, { npm_config_global: "false", npm_config_location: "project" }, { build }), stdio: ["ignore", "pipe", "pipe"], timeout: 300_000, maxBuffer: 32 * 1024 * 1024 });
|
|
258
313
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
314
|
+
catch (err) {
|
|
315
|
+
// SAFETY: catch bindings are unknown; execFileSync errors carry the child's stderr.
|
|
316
|
+
const failure = err;
|
|
317
|
+
const tail = String(failure.stderr ?? "").split("\n").filter(line => line.trim() !== "").slice(-6).join("\n");
|
|
318
|
+
throw new Error(`npm could not install the grammars: ${failure.message}${tail === "" ? "" : `\n${tail}`}`);
|
|
263
319
|
}
|
|
264
|
-
return null;
|
|
265
320
|
}
|
|
266
321
|
/**
|
|
267
|
-
* Install
|
|
268
|
-
*
|
|
269
|
-
*
|
|
322
|
+
* Install exactly the pinned grammars, and nothing else, into the cache. This is
|
|
323
|
+
* the only place diffninja downloads grammar code, and only when a person runs
|
|
324
|
+
* `diffninja grammars install`. `npm ci` from the shipped lock refuses any
|
|
325
|
+
* tarball whose sha512 differs, `--ignore-scripts` keeps every install script
|
|
326
|
+
* (the packages' own and their dependencies') from running, and the grammars
|
|
327
|
+
* load from their bundled prebuilt binaries. Reviews never call this.
|
|
270
328
|
*/
|
|
271
|
-
function
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
329
|
+
export function installPinnedGrammars(options = {}) {
|
|
330
|
+
const cacheDir = options.cacheDir ?? grammarCacheDir();
|
|
331
|
+
mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
|
332
|
+
// mkdir leaves an existing directory's mode alone, so tighten one's own and refuse anyone else's.
|
|
333
|
+
if (statSync(cacheDir).uid === process.getuid?.())
|
|
334
|
+
chmodSync(cacheDir, 0o700);
|
|
335
|
+
const problem = cacheDirectoryProblem(cacheDir);
|
|
336
|
+
if (problem !== undefined)
|
|
337
|
+
throw new Error(`Refusing to install grammars: ${problem}, so its contents could not be trusted. Set DIFFNINJA_GRAMMAR_CACHE to a directory of your own.`);
|
|
338
|
+
// Untrusted until the install below finishes and verifies.
|
|
339
|
+
rmSync(join(cacheDir, CACHE_MARKER), { force: true });
|
|
340
|
+
writeFileSync(join(cacheDir, "package.json"), JSON.stringify(GRAMMAR_PACKAGE_JSON, null, 2) + "\n", "utf8");
|
|
341
|
+
writeFileSync(join(cacheDir, "package-lock.json"), JSON.stringify(GRAMMAR_PACKAGE_LOCK, null, 2) + "\n", "utf8");
|
|
342
|
+
// No --prefix: `npm ci` rejects it. The working directory is the project, whatever the user's npm config says.
|
|
343
|
+
const npm = options.runNpm ?? ((cwd, args) => runNpm(cwd, args, options.build === true));
|
|
344
|
+
npm(cacheDir, ["ci", "--ignore-scripts", "--legacy-peer-deps", "--no-audit", "--no-fund"]);
|
|
345
|
+
// The one place an install script runs: the source build of the grammars with no prebuilt
|
|
346
|
+
// binary, for exactly those packages, and only because the person asked for --build.
|
|
347
|
+
if (options.build === true)
|
|
348
|
+
npm(cacheDir, ["rebuild", "--legacy-peer-deps", "--no-audit", "--no-fund", ...GRAMMAR_BUILD_ONLY]);
|
|
349
|
+
const packages = [];
|
|
350
|
+
for (const [name, version] of Object.entries(GRAMMAR_PINS)) {
|
|
351
|
+
let installed;
|
|
283
352
|
try {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
env: process.env,
|
|
287
|
-
timeout: 300_000,
|
|
288
|
-
});
|
|
289
|
-
return;
|
|
353
|
+
const manifest = packageVersionSchema.safeParse(JSON.parse(readFileSync(join(cacheDir, "node_modules", name, "package.json"), "utf8")));
|
|
354
|
+
installed = manifest.success ? manifest.data.version : undefined;
|
|
290
355
|
}
|
|
291
|
-
catch
|
|
292
|
-
|
|
293
|
-
// SAFETY: catch bindings are unknown; normalize to Error at the boundary.
|
|
294
|
-
const failure = err instanceof Error ? err : new Error(String(err));
|
|
295
|
-
const hint = installFailureHint(npmPackage);
|
|
296
|
-
throw new Error(`Could not install the ${npmPackage} grammar into ${cacheDir}: ${failure.message}` +
|
|
297
|
-
(hint ? ` ${hint}` : ""));
|
|
298
|
-
}
|
|
299
|
-
sleepMs(2000 * attempt);
|
|
356
|
+
catch {
|
|
357
|
+
installed = undefined;
|
|
300
358
|
}
|
|
359
|
+
if (installed !== version)
|
|
360
|
+
throw new Error(`${name}@${version} is not in ${cacheDir} after the install (found ${installed ?? "nothing"}); leaving the cache untrusted.`);
|
|
361
|
+
packages.push({ name, version });
|
|
301
362
|
}
|
|
363
|
+
writeFileSync(join(cacheDir, CACHE_MARKER), JSON.stringify({ format: 1, lockSha256: grammarLockDigest(), installedAt: new Date().toISOString(), built: options.build === true }) + "\n", "utf8");
|
|
364
|
+
return { cacheDir, packages };
|
|
365
|
+
}
|
|
366
|
+
export function grammarStatus(cacheDir = grammarCacheDir()) {
|
|
367
|
+
return {
|
|
368
|
+
cacheDir,
|
|
369
|
+
trusted: readCacheMarker(cacheDir) !== undefined,
|
|
370
|
+
packages: Object.entries(GRAMMAR_PINS).map(([name, version]) => ({ name, version, installed: pinnedGrammarRoot(cacheDir, name) !== undefined, needsBuild: needsBuild(name) })),
|
|
371
|
+
};
|
|
302
372
|
}
|
|
303
373
|
/**
|
|
304
|
-
*
|
|
305
|
-
*
|
|
374
|
+
* The grammar package's module. Bundled grammars come from diffninja's own
|
|
375
|
+
* dependencies; every other one from the cache `diffninja grammars install`
|
|
376
|
+
* filled, and only when that cache holds the exact pinned version. Nothing is
|
|
377
|
+
* ever downloaded here: a missing grammar throws GrammarNotInstalledError, which
|
|
378
|
+
* call-flow analysis reports once instead of skipping silently.
|
|
306
379
|
*/
|
|
307
380
|
export function loadGrammarPackage(npmPackage) {
|
|
308
|
-
|
|
309
|
-
try {
|
|
381
|
+
if (BUNDLED_GRAMMARS.has(npmPackage)) {
|
|
310
382
|
const localRequire = createRequire(import.meta.url);
|
|
311
383
|
try {
|
|
312
384
|
// SAFETY: local dependency resolves to a tree-sitter grammar module.
|
|
@@ -316,27 +388,22 @@ export function loadGrammarPackage(npmPackage) {
|
|
|
316
388
|
// SAFETY: catch bindings are unknown; normalize to Error at the boundary.
|
|
317
389
|
const failure = err instanceof Error ? err : new Error(String(err));
|
|
318
390
|
const code = errnoCode(failure);
|
|
319
|
-
if (code === "ERR_REQUIRE_ASYNC_MODULE" ||
|
|
320
|
-
|
|
321
|
-
const entry = localRequire.resolve(npmPackage);
|
|
322
|
-
const packageRoot = join(entry, "..", "..");
|
|
391
|
+
if (code === "ERR_REQUIRE_ASYNC_MODULE" || failure.message.includes("top-level await")) {
|
|
392
|
+
const packageRoot = join(localRequire.resolve(npmPackage), "..", "..");
|
|
323
393
|
const binding = loadNativeBinding(packageRoot);
|
|
324
394
|
if (binding)
|
|
325
395
|
return binding;
|
|
326
396
|
}
|
|
327
|
-
throw
|
|
397
|
+
throw failure;
|
|
328
398
|
}
|
|
329
399
|
}
|
|
330
|
-
catch {
|
|
331
|
-
// fall through to cache
|
|
332
|
-
}
|
|
333
400
|
const cacheDir = grammarCacheDir();
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
401
|
+
const packageRoot = pinnedGrammarRoot(cacheDir, npmPackage);
|
|
402
|
+
if (packageRoot === undefined) {
|
|
403
|
+
missingGrammars.add(npmPackage);
|
|
404
|
+
throw new GrammarNotInstalledError(npmPackage);
|
|
337
405
|
}
|
|
338
406
|
const require = createRequire(join(cacheDir, "package.json"));
|
|
339
|
-
const packageRoot = join(cacheDir, "node_modules", npmPackage);
|
|
340
407
|
return requireGrammar(require, npmPackage, packageRoot);
|
|
341
408
|
}
|
|
342
409
|
/** Resolve the value to pass to parser.setLanguage. */
|
|
@@ -21,7 +21,9 @@ import type { ReviewReport } from "./types.js";
|
|
|
21
21
|
* reaches the reviewer as a glyph plus color for a call that was added,
|
|
22
22
|
* removed, or contains a change, and plain dimmed text for an unchanged call.
|
|
23
23
|
*/
|
|
24
|
-
export declare function renderCallFlows(report: ReviewReport
|
|
24
|
+
export declare function renderCallFlows(report: ReviewReport, options?: {
|
|
25
|
+
readonly treeOnly?: boolean;
|
|
26
|
+
}): string;
|
|
25
27
|
export declare const CALL_FLOW_STYLES = "\n.cf {\n display: flex; flex-direction: column; gap: 12px; margin-top: 18px;\n --cf-add: #1a7f37; --cf-add-soft: #dafbe1;\n --cf-del: #cf222e; --cf-del-soft: #ffebe9;\n --cf-chg: #9a6700; --cf-chg-soft: #fff8c5;\n --cf-quiet: var(--ink-soft);\n}\n@media (prefers-color-scheme: dark) {\n .cf {\n --cf-add: #3fb950; --cf-add-soft: rgba(46, 160, 67, 0.15);\n --cf-del: #f85149; --cf-del-soft: rgba(248, 81, 73, 0.12);\n --cf-chg: #d29922; --cf-chg-soft: rgba(187, 128, 9, 0.15);\n }\n}\n/* Mode sections, folded files and the breadcrumb are toggled with the hidden\n attribute, so the module carries that rule itself instead of relying on the page. */\n.cf [hidden] { display: none !important; }\n.cf-head { display: flex; flex-direction: column; gap: 4px; }\n.cf-prov { font-size: 12px; color: var(--ink-soft); }\n.cf-note { font-size: 12px; color: var(--ink-soft); overflow-wrap: anywhere; }\n.cf-absence { padding: 16px 0; }\n.cf-bounds {\n margin: 10px 0 0;\n padding: 2px 0 2px 10px;\n border-left: 2px solid var(--cf-chg);\n color: var(--ink-soft);\n font-size: 12px;\n overflow-wrap: anywhere;\n}\n.cf-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }\n.cf-ready .cf-controls { position: sticky; top: var(--toolbar-height, 0px); z-index: 4; background: var(--bg); padding: 8px 0; }\n.cf, .cf-files, .cf-file-body, .cf-mode { min-width: 0; }\n.cf-modes {\n display: inline-flex;\n align-items: stretch;\n border: 1px solid var(--line);\n border-radius: 6px;\n overflow: hidden;\n background: var(--panel);\n}\n.cf-mode-link {\n display: inline-flex;\n align-items: center;\n padding: 5px 14px;\n font-size: 12px;\n color: var(--ink-soft);\n text-decoration: none;\n border-right: 1px solid var(--line);\n}\n.cf-mode-link:last-child { border-right: 0; }\n.cf-mode-link:hover, .cf-mode-link:focus-visible { color: var(--ink); background: var(--sunken); }\n.js .cf-mode-link[aria-current] { color: var(--ink); background: var(--sunken); font-weight: 600; }\n.cf-jump ul {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n list-style: none;\n margin: 0;\n padding: 0;\n max-height: 22vh;\n overflow-y: auto;\n}\n.cf-jump-link {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n max-width: 100%;\n min-width: 0;\n padding: 4px 10px 4px 9px;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--panel);\n color: var(--ink);\n font-size: 12px;\n text-decoration: none;\n}\n.cf-jump-link:hover, .cf-jump-link:focus-visible { border-color: var(--cursor); }\n.cf-jump-path { min-width: 0; max-width: 32ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.cf-jump-count { color: var(--ink-soft); white-space: nowrap; }\n.cf-crumbs {\n display: flex;\n align-items: center;\n flex-wrap: nowrap;\n gap: 6px;\n font-size: 12.5px;\n padding-bottom: 3px;\n overflow-x: auto;\n overscroll-behavior-x: contain;\n}\n.cf-crumb {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n flex: 0 0 auto;\n max-width: 34ch;\n font: inherit;\n font-size: 12px;\n padding: 3px 10px;\n border: 1px solid var(--line-strong);\n border-radius: 999px;\n background: var(--panel);\n color: var(--ink);\n cursor: pointer;\n white-space: nowrap;\n}\n.cf-crumb[aria-current] { border-color: var(--cursor); font-weight: 600; }\n.cf-crumb-label { overflow: hidden; text-overflow: ellipsis; }\n.cf-crumb-file {\n flex: 0 0 auto;\n max-width: 22ch;\n overflow: hidden;\n text-overflow: ellipsis;\n font-size: 11px;\n color: var(--ink-soft);\n}\n.cf-sep { flex: 0 0 auto; color: var(--ink-soft); }\n.cf-files { display: flex; flex-direction: column; gap: 12px; }\n.cf-file {\n background: var(--panel);\n border: 1px solid var(--line);\n border-left: 3px solid var(--line-strong);\n border-radius: 6px;\n overflow: hidden;\n}\n.cf-file { --cf-color: var(--ink-soft); --cf-fill: var(--sunken); }\n.cf-file-attention { border-left-color: var(--alarm); --cf-color: var(--alarm); --cf-fill: var(--alarm-bg); }\n.cf-file-uncertain { border-left-color: var(--warn); --cf-color: var(--warn); --cf-fill: var(--warn-bg); }\n.cf-file-low { border-left-color: var(--line-strong); }\n.cf-file-passed { border-left-color: var(--teal); --cf-color: var(--teal); --cf-fill: var(--teal-bg); }\n.cf-file-head {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 10px;\n padding: 8px 12px;\n background: var(--sunken);\n cursor: pointer;\n list-style: none;\n}\n.cf-file-head::-webkit-details-marker { display: none; }\n.cf-file-head::after {\n content: \"\u25B8\";\n margin-left: auto;\n font-size: 11px;\n color: var(--ink-soft);\n transition: transform 0.15s ease;\n}\n.cf-file[open] > .cf-file-head::after { transform: rotate(90deg); }\n.cf-file-head:focus-visible { outline: 2px solid var(--cursor); outline-offset: -2px; }\n.cf-file-path { flex: 1 1 260px; min-width: 0; font-size: 12.5px; font-weight: 600; overflow-wrap: anywhere; }\n.cf-file-count { font-size: 12px; color: var(--ink-soft); white-space: nowrap; }\n.cf-diff-link { font-size: 12px; white-space: nowrap; }\n.cf-file-body { border-top: 1px solid var(--line); padding: 0 12px 12px; }\n.cf-mode { padding-top: 12px; }\n.cf-noscript { display: block; }\n.cf-noscript .cf-note { margin: 6px 0 0; }\n.cf-mode + .cf-mode { margin-top: 12px; border-top: 1px dashed var(--line); }\n.cf-mode-head {\n margin: 0 0 8px;\n font-size: 11.5px;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--ink-soft);\n}\n.cf-ready .cf-mode-head { display: none; }\n.cf-ready .cf-mode + .cf-mode { margin-top: 0; border-top: 0; }\n.cf-tree, .cf-children { list-style: none; margin: 0; padding: 0; }\n.cf-children { margin-left: 15px; padding-left: 10px; border-left: 1px dashed var(--line); }\n.cf-row { display: flex; align-items: center; flex-wrap: wrap; gap: 0 8px; padding: 2px 6px; border-radius: 6px; min-width: 0; }\n.cf-row:hover { background: var(--sunken); }\n.cf-fold > .cf-row { cursor: pointer; list-style: none; }\n.cf-fold > .cf-row::-webkit-details-marker { display: none; }\n.cf-fold > .cf-row::before { content: \"\u25B8\"; font-size: 10px; color: var(--ink-soft); transition: transform 0.15s ease; }\n.cf-fold[open] > .cf-row::before { transform: rotate(90deg); }\n.cf-badge, .cf-chip-badge {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n min-width: 16px;\n height: 16px;\n border: 1px solid transparent;\n border-radius: 4px;\n font-family: var(--mono);\n font-size: 11px;\n font-weight: 700;\n}\n.cf-badge, .cf-chip-badge { color: var(--cf-node-color); background: var(--cf-node-fill); }\n.cf-label {\n font: 400 12.5px/1.5 var(--mono);\n padding: 0 2px;\n border: 1px solid transparent;\n border-radius: 4px;\n background: none;\n text-align: left;\n /* Capped so a long call never pushes its row onto a second line; the\n location takes what is left and truncates first. */\n flex: 0 1 auto;\n min-width: 0;\n max-width: 62%;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.js .cf-label { cursor: pointer; }\n.js .cf-label:hover, .cf-label:focus-visible { text-decoration: underline; }\n.cf-label:focus-visible { outline: 2px solid var(--cursor); outline-offset: 1px; }\n.cf-loc { flex: 1 1 0; min-width: 0; font-size: 11px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-loc-none { font-style: italic; }\n.cf-node, .cf-gnode, .cf-chip { --cf-node-color: var(--cf-chg); --cf-node-fill: var(--cf-chg-soft); --cf-node-weight: 600; --cf-node-decoration: none; }\n.cf-st-added { --cf-node-color: var(--cf-add); --cf-node-fill: var(--cf-add-soft); }\n.cf-st-removed { --cf-node-color: var(--cf-del); --cf-node-fill: var(--cf-del-soft); --cf-node-decoration: line-through; }\n.cf-node.cf-st-same, .cf-gnode.cf-st-same, .cf-chip.cf-st-same { --cf-node-color: var(--ink-soft); --cf-node-fill: var(--sunken); --cf-node-weight: 400; }\n.cf-label { color: var(--cf-node-color); font-weight: var(--cf-node-weight); text-decoration: var(--cf-node-decoration); }\n.cf-node.cf-ancestor > .cf-fold > .cf-row { display: none; }\n.cf-node.cf-ancestor > .cf-fold > .cf-children { margin: 0; padding: 0; border: 0; }\n.cf-svg-wrap {\n max-width: 100%;\n overflow: auto;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--sunken);\n overscroll-behavior: contain;\n}\n.cf-svg { display: block; max-width: none; }\n.cf-ready .cf-svg-wrap { height: clamp(220px, 42vh, 400px); overflow: hidden; touch-action: none; cursor: grab; }\n.cf-diagram-btn, .cf-diagram-close {\n display: inline-flex; align-items: center; gap: 6px; height: 29px; padding: 0 12px;\n border: 1px solid var(--line); border-radius: 6px; background: var(--panel);\n font: 500 12px/1 var(--sans, inherit); color: var(--ink); cursor: pointer;\n}\n.cf-diagram-btn { color: var(--cursor); border-color: var(--cursor); }\n.cf-diagram-btn::after { content: \"\\2197\"; font-size: 12px; }\n.cf-diagram-btn:hover, .cf-diagram-close:hover { background: var(--sunken); }\n.cf-diagram-close { margin-left: auto; }\n.cf-diagram-hint { margin: 0 0 0 auto; font-size: 12px; color: var(--ink-soft); }\n.cf-diagram-hint + .cf-diagram-close { margin-left: 12px; }\n.cf.cf-diagram-open .cf-head { display: none; }\n/* The diagram view fills the browser window, and the graph gets nearly all of its height. */\n.cf.cf-diagram-open {\n position: fixed; inset: 0; z-index: 50; margin: 0; padding: 12px 20px 20px;\n background: var(--bg); overflow: auto; overscroll-behavior: contain;\n}\n.cf.cf-diagram-open .cf-controls { position: sticky; top: -12px; z-index: 5; background: var(--bg); padding: 10px 0; }\n.cf.cf-diagram-open .cf-modes, .cf.cf-diagram-open .cf-diagram-btn { display: none !important; }\n.cf.cf-diagram-open { padding-left: 292px; }\n.cf.cf-diagram-open .cf-svg-wrap { height: calc(100vh - 150px); min-height: 320px; }\n/* One canvas at a time: the entry point picked in the list, in its own file. */\n.cf.cf-diagram-open .cf-jump, .cf.cf-diagram-open .cf-bounds, .cf.cf-diagram-open .cf-omitted { display: none !important; }\n.cf.cf-diagram-open .cf-file:not(.cf-file-current) { display: none; }\n.cf.cf-diagram-open .cf-graph-frame:not(.cf-frame-current) { display: none; }\n.cf.cf-diagram-open .cf-file-body { padding-bottom: 0; }\n.cf-diagram-nav {\n position: fixed; top: 0; bottom: 0; left: 0; width: 276px; overflow-y: auto; z-index: 6;\n padding: 16px 12px; background: var(--sunken); border-right: 1px solid var(--line);\n}\n.cf-diagram-nav-head { margin: 0 0 10px; font-size: 13px; font-weight: 600; color: var(--ink); }\n.cf-diagram-nav-file {\n margin: 14px 0 4px; font: 600 12px var(--mono); color: var(--ink-soft);\n white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n}\n.cf-diagram-nav ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }\n.cf-diagram-nav button {\n display: flex; flex-direction: column; align-items: flex-start; gap: 1px; width: 100%;\n padding: 6px 8px; border: 0; border-radius: 6px; background: none; color: var(--ink); text-align: left; cursor: pointer;\n}\n.cf-diagram-nav button:hover { background: var(--panel); }\n.cf-diagram-nav button[aria-current] { background: var(--panel); box-shadow: inset 3px 0 0 var(--cursor); }\n.cf-diagram-nav-name { max-width: 100%; font: 600 12px/1.35 var(--mono); overflow-wrap: anywhere; }\n.cf-diagram-nav-count { max-width: 100%; font-size: 11px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n@media (max-width: 760px) {\n .cf.cf-diagram-open { padding-left: 12px; padding-top: 12px; }\n .cf-diagram-nav { position: static; width: auto; max-height: 30vh; margin: 0 0 8px; border: 1px solid var(--line); border-radius: 6px; }\n}\n.cf-ready .cf-svg-wrap:focus-visible { outline: 2px solid var(--cursor); outline-offset: 2px; }\n.cf-ready .cf-svg-wrap.cf-dragging { cursor: grabbing; user-select: none; }\n.cf-ready .cf-svg { width: 100%; height: 100%; }\n.cf-graph-frame + .cf-graph-frame { margin-top: 14px; }\n.cf-camera-tools { align-items: center; flex-wrap: wrap; gap: 5px; margin-bottom: 6px; }\n.cf-camera-tools button { min-height: 28px; padding: 0 10px; font-size: 12px; }\n.cf-scale { min-width: 4ch; text-align: center; font: 11px var(--mono); color: var(--ink-soft); }\n.cf-omitted { display: none; }\n.cf-ready .cf-omitted { display: block; }\n.cf-node.cf-selected > .cf-row, .cf-node.cf-selected > .cf-fold > .cf-row,\n.cf-chip.cf-selected { outline: 2px solid var(--cursor); outline-offset: 1px; border-radius: 5px; }\n.cf-edge { fill: none; stroke: var(--line-strong); stroke-width: 1.5; }\n.cf-gnode rect { fill: var(--panel); stroke: var(--line-strong); stroke-width: 1.5; }\n.cf-gnode:focus-visible { outline: none; }\n.cf-gzoom circle { fill: var(--panel); stroke: var(--line-strong); }\n.cf-gzoom text { fill: var(--ink); font: 14px var(--mono); }\n.cf-gzoom:hover circle, .cf-gzoom:focus-visible circle { stroke: var(--cursor); stroke-width: 2; }\n.cf-gzoom:focus-visible { outline: none; }\n.cf-glabel { font-family: var(--mono); font-size: 12px; fill: var(--ink); }\n.cf-gloc { font-family: var(--mono); font-size: 10px; fill: var(--ink-soft); }\n.cf-gnode.cf-st-same rect { stroke: var(--line); fill: transparent; }\n.cf-gnode.cf-st-same .cf-glabel { fill: var(--ink-soft); }\n.cf-gnode:not(.cf-st-same) rect { stroke: var(--cf-node-color); fill: var(--panel); }\n.cf-gnode:not(.cf-st-same) .cf-glabel { fill: var(--cf-node-color); font-weight: 600; }\n.cf-gnode:hover rect, .cf-gnode:focus-visible rect { stroke: var(--cursor); stroke-width: 2.5; }\n.cf-gnode.cf-selected rect { stroke: var(--cursor); stroke-width: 3; }\n.cf-paths { display: flex; flex-direction: column; gap: 6px; list-style: none; margin: 0; padding: 0; }\n.cf-path {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 4px 6px;\n padding: 6px 8px;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--panel);\n}\n.cf-chip {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n max-width: 100%;\n min-width: 0;\n padding: 2px 8px 2px 4px;\n border: 1px solid var(--line);\n border-left: 3px solid var(--cf-node-color);\n border-radius: 6px;\n background: var(--panel);\n color: var(--ink);\n}\n.cf-chip:hover, .cf-chip:focus-within { border-color: var(--cursor); }\n.cf-chip-zoom {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n max-width: 100%;\n min-width: 0;\n color: inherit;\n text-decoration: none;\n}\n.cf-chip-label { min-width: 0; max-width: 36ch; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-chip-loc { min-width: 0; max-width: 28ch; font-size: 10.5px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-chip.cf-st-same .cf-chip-label { color: var(--ink-soft); }\n.cf-chip:not(.cf-st-same) .cf-chip-label { color: var(--cf-node-color); font-weight: 600; }\n.cf-chip .cf-desc { max-width: 28ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-arrow { color: var(--ink-soft); font-size: 12px; }\n/* One description line per call, only when the backend attached one. */\n.cf-desc { font-size: 11.5px; color: var(--ink-soft); font-style: italic; overflow-wrap: anywhere; }\n.cf-business { display: flex; flex-direction: column; gap: 6px; margin: 4px 0 10px; }\n.cf-business-note { font-size: 13px; color: var(--ink-soft); }\n.cf-plumbing-toggle { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--ink-soft); cursor: pointer; width: fit-content; }\n.cf:has(.cf-show-plumbing:not(:checked)) .cf-node.cf-plumbing { display: none; }\n.cf-purpose { font-family: var(--sans); font-size: 13.5px; font-weight: 550; color: var(--ink); overflow-wrap: anywhere; }\n.cf-row > .cf-purpose { flex: 1 0 100%; order: -1; padding-left: 26px; }\n.cf-fold > .cf-row > .cf-purpose { padding-left: 16px; }\n.cf-node.cf-explained > .cf-row .cf-label, .cf-node.cf-explained > .cf-fold > .cf-row .cf-label { font-size: 11.5px; }\n.cf-chip .cf-purpose { font-size: 12.5px; max-width: 34ch; }\n.cf-gpurpose { font-family: var(--sans); font-size: 12px; font-weight: 600; fill: var(--ink); }\n.cf-diagram-nav-name.cf-diagram-nav-purpose { font-family: var(--sans); white-space: normal; }\n.cf-row > .cf-desc { flex: 1 0 100%; order: 5; padding-left: 26px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.cf-row > .cf-src-link { order: 4; }\n.cf-depth { align-items: center; gap: 4px; }\n.cf-depth-label { font-size: 12px; color: var(--ink-soft); }\n.cf-depth-btn { padding: 3px 9px; font-size: 12px; }\n.cf-depth-btn[aria-pressed=\"true\"] { border-color: var(--cursor); color: var(--cursor); font-weight: 600; }\n/* Numbered, clickable call order on the graph edges. */\n.cf-edge-num circle { fill: var(--panel); stroke: var(--line-strong); stroke-width: 1.5; }\n.cf-edge-num text { font-family: var(--mono); font-size: 10.5px; fill: var(--ink-soft); }\n.cf-edge-num:hover circle, .cf-edge-num:focus-visible circle { stroke: var(--cursor); stroke-width: 2; }\n.cf-edge-num:hover text, .cf-edge-num:focus-visible text { fill: var(--cursor); font-weight: 700; }\n.cf-edge-num:focus-visible { outline: none; }\n.cf-gdesc { font-family: var(--mono); font-size: 10px; font-style: italic; fill: var(--ink-soft); }\n/* Resolved definition of one call: a native disclosure without the script, and\n the body the script clones into its single shared panel. */\n.cf-source { margin: 3px 0 3px 24px; }\n.cf-ready .cf-source { display: none; }\n.cf-src-sum { width: fit-content; cursor: pointer; list-style: none; font-size: 11.5px; color: var(--ink-soft); }\n.cf-src-sum::-webkit-details-marker { display: none; }\n.cf-src-sum::before { content: \"\u25B8\"; margin-right: 6px; font-size: 10px; transition: transform 0.15s ease; display: inline-block; }\n.cf-source[open] > .cf-src-sum::before { transform: rotate(90deg); }\n.cf-src-sum:hover, .cf-src-sum:focus-visible { color: var(--cursor); }\n.cf-src-body { padding: 6px 0 4px; }\n.cf-src-ref { margin: 0 0 5px; font-size: 11.5px; color: var(--ink-soft); overflow-wrap: anywhere; }\n.cf-src-code {\n margin: 0;\n padding: 6px 9px;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--sunken);\n font-family: var(--mono);\n font-size: 11.5px;\n line-height: 1.5;\n overflow-x: auto;\n}\n.cf-src-line { display: block; white-space: pre; }\n.cf-src-no {\n display: inline-block;\n width: 4ch;\n margin-right: 10px;\n text-align: right;\n color: var(--ink-soft);\n user-select: none;\n}\n.cf-src-note { margin: 0; font-size: 11.5px; color: var(--ink-soft); font-style: italic; }\n.cf-src-panel {\n padding: 8px 12px 10px;\n border: 1px solid var(--line-strong);\n border-radius: 6px;\n background: var(--panel);\n}\n.cf-src-panel-sum { width: fit-content; cursor: pointer; font-size: 12.5px; font-weight: 600; }\n.cf-src-panel-body { padding-top: 6px; }\n.cf-src-panel-body { max-height: 40vh; overflow: auto; }\n.cf-src-head-line { margin: 0 0 6px; font-size: 12px; color: var(--ink-soft); overflow-wrap: anywhere; }\n.cf-src-missing { margin: 0; }\n.cf-src-link {\n flex: 0 0 auto;\n font-size: 11px;\n color: var(--ink-soft);\n text-decoration: none;\n white-space: nowrap;\n padding: 0 6px;\n border: 1px solid var(--line);\n border-radius: 999px;\n}\n.cf-src-link:hover { text-decoration: none; }\n.cf-src-link:hover, .cf-src-link:focus-visible { color: var(--cursor); }\n@media (max-width: 680px) {\n .cf-jump ul { max-height: 38vh; }\n .cf-jump-link { width: 100%; }\n .cf-jump-path { max-width: none; flex: 1 1 auto; }\n .cf-file-body { padding: 0 11px 12px; }\n .cf-crumb { max-width: 24ch; }\n .cf-crumb-file { max-width: 12ch; }\n .cf-source { margin-left: 10px; }\n .cf-src-no { width: 3ch; margin-right: 7px; }\n .cf-chip, .cf-chip-zoom { min-width: 0; flex-wrap: wrap; }\n .cf-chip-loc { overflow-wrap: anywhere; }\n}\n";
|
|
26
28
|
/**
|
|
27
29
|
* Progressive enhancement for the rendered call flow. Nothing here is
|
|
@@ -24,7 +24,8 @@ import { nodeFunctionId, purposesOf, wrapPurpose } from "./explanation.js";
|
|
|
24
24
|
* reaches the reviewer as a glyph plus color for a call that was added,
|
|
25
25
|
* removed, or contains a change, and plain dimmed text for an unchanged call.
|
|
26
26
|
*/
|
|
27
|
-
export function renderCallFlows(report) {
|
|
27
|
+
export function renderCallFlows(report, options = {}) {
|
|
28
|
+
const treeOnly = options.treeOnly === true;
|
|
28
29
|
const purposes = purposesOf(report);
|
|
29
30
|
const files = collectFiles(report.callFlows, report.items, purposes);
|
|
30
31
|
if (files.length === 0) {
|
|
@@ -35,7 +36,7 @@ export function renderCallFlows(report) {
|
|
|
35
36
|
'<div class="cf">',
|
|
36
37
|
renderSummary(files, new Set(report.items.map(item => item.file)).size),
|
|
37
38
|
renderPlumbingToggle(plumbing, report.agentExplanation?.explainedBy),
|
|
38
|
-
renderControls(),
|
|
39
|
+
treeOnly ? "" : renderControls(),
|
|
39
40
|
renderJump(files),
|
|
40
41
|
'<nav class="cf-crumbs" id="cf-crumbs" aria-label="Visited call trail" hidden></nav>',
|
|
41
42
|
// One panel the script fills with the focused call's definition; without the
|
|
@@ -45,7 +46,7 @@ export function renderCallFlows(report) {
|
|
|
45
46
|
'<div class="cf-src-panel-body" id="cf-src-panel-body"></div>',
|
|
46
47
|
"</details>",
|
|
47
48
|
'<div class="cf-files">',
|
|
48
|
-
files.map((view, index) => renderFile(view, index + 1)).join("\n"),
|
|
49
|
+
files.map((view, index) => renderFile(view, index + 1, treeOnly)).join("\n"),
|
|
49
50
|
"</div>",
|
|
50
51
|
"</div>",
|
|
51
52
|
].join("\n");
|
|
@@ -85,6 +86,7 @@ const AVAILABILITY_NOTE = {
|
|
|
85
86
|
available: "No call tree reaches a changed file in this range. This is not evidence of safety.",
|
|
86
87
|
"needs-git-range": "Call paths need a git range. This input was a patch, so no call trees were built.",
|
|
87
88
|
"no-changes": "No call path reaches a changed file. This is not evidence of safety.",
|
|
89
|
+
partial: "No call tree was drawn, but call flows left out some source files of this revision (very large files, or files past the per-revision limit), so a path through them is absent. This is not evidence of safety.",
|
|
88
90
|
failed: "Call-flow analysis failed, so no call trees were built. The diff review is unaffected.",
|
|
89
91
|
};
|
|
90
92
|
/** Call paths drawn per file in Sequence mode before the omitted-path note. */
|
|
@@ -267,7 +269,7 @@ function renderJump(files) {
|
|
|
267
269
|
].join("\n");
|
|
268
270
|
}
|
|
269
271
|
/** One file section: severity, path, call count and its diff link. */
|
|
270
|
-
function renderFile(view, at) {
|
|
272
|
+
function renderFile(view, at, treeOnly) {
|
|
271
273
|
const { dot, tone, count } = fileFacts(view);
|
|
272
274
|
const status = view.status;
|
|
273
275
|
const diff = `<a class="cf-diff-link" data-open-hunk href="#item-${view.rank}">View diff #${view.rank}</a>`;
|
|
@@ -284,8 +286,8 @@ function renderFile(view, at) {
|
|
|
284
286
|
view.truncated
|
|
285
287
|
? `<p class="cf-bounds">Bounds reached for this file: ${escapeHtml(BOUNDS_TEXT)}. Calls cut at a bound are omitted.</p>`
|
|
286
288
|
: "",
|
|
287
|
-
modesNote(),
|
|
288
|
-
renderModes(view, at),
|
|
289
|
+
treeOnly ? "" : modesNote(),
|
|
290
|
+
treeOnly ? renderTreeMode(view, at, false) : renderModes(view, at),
|
|
289
291
|
"</div>",
|
|
290
292
|
"</details>",
|
|
291
293
|
]
|
|
@@ -303,7 +305,7 @@ function renderFile(view, at) {
|
|
|
303
305
|
*/
|
|
304
306
|
function renderModes(view, at) {
|
|
305
307
|
return [
|
|
306
|
-
renderTreeMode(view, at),
|
|
308
|
+
renderTreeMode(view, at, true),
|
|
307
309
|
lazyMode("graph", at, renderGraphMode(view, at)),
|
|
308
310
|
lazyMode("sequence", at, renderSequenceMode(view, at)),
|
|
309
311
|
].join("\n");
|
|
@@ -321,10 +323,10 @@ function modesNote() {
|
|
|
321
323
|
`resolved definition opens on its own. The diff holds every changed line.` +
|
|
322
324
|
`</p></noscript>`);
|
|
323
325
|
}
|
|
324
|
-
function modeSection(mode, at, body) {
|
|
326
|
+
function modeSection(mode, at, body, headed = true) {
|
|
325
327
|
return [
|
|
326
328
|
`<section class="cf-mode cf-mode-${mode}" id="cf-f${at}-${mode}" data-cf-mode-body="${mode}">`,
|
|
327
|
-
`<h3 class="cf-mode-head">${MODE_LABEL[mode]}</h3
|
|
329
|
+
headed ? `<h3 class="cf-mode-head">${MODE_LABEL[mode]}</h3>` : "",
|
|
328
330
|
body,
|
|
329
331
|
"</section>",
|
|
330
332
|
].join("\n");
|
|
@@ -333,11 +335,11 @@ function modeSection(mode, at, body) {
|
|
|
333
335
|
function lazyMode(mode, at, section) {
|
|
334
336
|
return `<template data-cf-lazy-mode="${mode}" data-cf-file="${at}">${section}</template>`;
|
|
335
337
|
}
|
|
336
|
-
function renderTreeMode(view, at) {
|
|
338
|
+
function renderTreeMode(view, at, headed) {
|
|
337
339
|
const trees = view.trees
|
|
338
340
|
.map((tree, root) => renderTreeNode(tree, [root], at, view.file, view.purposes))
|
|
339
341
|
.join("\n");
|
|
340
|
-
return modeSection("tree", at, `<ul class="cf-tree">${trees}</ul
|
|
342
|
+
return modeSection("tree", at, `<ul class="cf-tree">${trees}</ul>`, headed);
|
|
341
343
|
}
|
|
342
344
|
/** The resolved definition of one call. It is a native disclosure, so the actual
|
|
343
345
|
* source is readable without the script, and the script clones this body into
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
*
|
|
18
18
|
* - only file types listed here are read; any other file answers no question
|
|
19
19
|
* at all, never `no`;
|
|
20
|
+
* - a hunk with a changed line longer than {@link MAX_READ_LINE_CHARS} is not
|
|
21
|
+
* read either, rather than answered from part of that line;
|
|
20
22
|
* - `no` means the changed lines the hunk shows contain no such pattern, never
|
|
21
23
|
* that the property is absent from the file or the program;
|
|
22
24
|
* - a line moved without change cancels out, because every question compares
|
|
@@ -45,7 +47,10 @@ export interface ChangeFactEvidence {
|
|
|
45
47
|
readonly text: string;
|
|
46
48
|
}
|
|
47
49
|
export interface ChangeFacts {
|
|
48
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Null when this analysis does not read the hunk: a file type it cannot read,
|
|
52
|
+
* or a changed line longer than {@link MAX_READ_LINE_CHARS}. Then no question is answered.
|
|
53
|
+
*/
|
|
49
54
|
readonly language: ChangeFactLanguage | null;
|
|
50
55
|
/**
|
|
51
56
|
* True when the removed and added text are the same once comments and layout
|
|
@@ -62,8 +67,23 @@ export interface ChangeFacts {
|
|
|
62
67
|
readonly answers: Readonly<Partial<Record<ChangeFactQuestion, ChangeFactAnswer>>>;
|
|
63
68
|
readonly evidence: Readonly<Partial<Record<ChangeFactQuestion, ChangeFactEvidence>>>;
|
|
64
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The longest changed line the analysis reads. A longer one is minified or
|
|
72
|
+
* generated text, or padding that would push a change past any bound on how
|
|
73
|
+
* much of a line is read, so its hunk is left unread and uncertain instead.
|
|
74
|
+
*/
|
|
75
|
+
export declare const MAX_READ_LINE_CHARS = 4000;
|
|
76
|
+
/** What diffninja does not read in a hunk whose facts have no language, as a phrase after "does not read". */
|
|
77
|
+
export declare function unreadCause(file: string): string;
|
|
65
78
|
export declare function changeFactLanguageOf(file: string): ChangeFactLanguage | null;
|
|
66
79
|
/** The questions a file of this kind is asked; none for an unread type. */
|
|
67
80
|
export declare function factQuestionsFor(language: ChangeFactLanguage | null): readonly ChangeFactQuestion[];
|
|
81
|
+
/**
|
|
82
|
+
* The line without its `--` comment, as `line.replace(/--.*$/, "")` does it, in
|
|
83
|
+
* linear time. `.` refuses the four line terminators, so the comment must start
|
|
84
|
+
* after the last one; the regex rescanned the rest of the line from every `--`
|
|
85
|
+
* whenever a terminator followed, quadratic on a long line of them.
|
|
86
|
+
*/
|
|
87
|
+
export declare function withoutSqlComment(line: string): string;
|
|
68
88
|
/** The facts for one hunk, each `yes` with the changed line it rests on. */
|
|
69
89
|
export declare function changeFactsOf(unit: Pick<ReviewUnit, "file" | "diff">): ChangeFacts;
|