@pablocm90/visual-review 0.0.0-stage → 0.1.1

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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/dist/api-paths.d.ts +18 -0
  4. package/dist/api-paths.js +22 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +95 -0
  7. package/dist/config.d.ts +85 -0
  8. package/dist/config.js +150 -0
  9. package/dist/config.schema.json +98 -0
  10. package/dist/crate-graph.d.ts +8 -0
  11. package/dist/crate-graph.js +51 -0
  12. package/dist/crate-modules.d.ts +8 -0
  13. package/dist/crate-modules.js +103 -0
  14. package/dist/diff-graphs.d.ts +31 -0
  15. package/dist/diff-graphs.js +72 -0
  16. package/dist/graph.d.ts +24 -0
  17. package/dist/graph.js +9 -0
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.js +1 -0
  20. package/dist/linker.d.ts +32 -0
  21. package/dist/linker.js +1 -0
  22. package/dist/linkers/fetch-axum.d.ts +2 -0
  23. package/dist/linkers/fetch-axum.js +38 -0
  24. package/dist/notes.d.ts +37 -0
  25. package/dist/notes.js +82 -0
  26. package/dist/notes.schema.json +68 -0
  27. package/dist/on-path.d.ts +1 -0
  28. package/dist/on-path.js +16 -0
  29. package/dist/prune-reviews.d.ts +6 -0
  30. package/dist/prune-reviews.js +12 -0
  31. package/dist/pull-request.d.ts +17 -0
  32. package/dist/pull-request.js +41 -0
  33. package/dist/read-range.d.ts +29 -0
  34. package/dist/read-range.js +158 -0
  35. package/dist/reader.d.ts +30 -0
  36. package/dist/reader.js +1 -0
  37. package/dist/readers/rust.d.ts +2 -0
  38. package/dist/readers/rust.js +66 -0
  39. package/dist/readers/typescript.d.ts +2 -0
  40. package/dist/readers/typescript.js +124 -0
  41. package/dist/rust-syntax.d.ts +10 -0
  42. package/dist/rust-syntax.js +11 -0
  43. package/dist/rust-tests.d.ts +2 -0
  44. package/dist/rust-tests.js +24 -0
  45. package/dist/skipped.d.ts +7 -0
  46. package/dist/skipped.js +1 -0
  47. package/dist/sources-in.d.ts +5 -0
  48. package/dist/sources-in.js +7 -0
  49. package/dist/test-statements.d.ts +46 -0
  50. package/dist/test-statements.js +121 -0
  51. package/dist/viewer/review-data.d.ts +18 -0
  52. package/dist/viewer/review-data.js +1 -0
  53. package/dist/viewer/visible-diff.d.ts +16 -0
  54. package/dist/viewer/visible-diff.js +74 -0
  55. package/dist/viewer.html +52 -0
  56. package/dist/web-graph.d.ts +19 -0
  57. package/dist/web-graph.js +59 -0
  58. package/dist/working-tree.d.ts +1 -0
  59. package/dist/working-tree.js +19 -0
  60. package/package.json +65 -3
@@ -0,0 +1,158 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, join, relative } from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { CONFIG_FILE, checkConfig, configuredNodes, parseConfig } from './config.js';
8
+ import { stackOfIn } from './graph.js';
9
+ import { fetchAxumLinker } from './linkers/fetch-axum.js';
10
+ import { onPath } from './on-path.js';
11
+ import { rustReader } from './readers/rust.js';
12
+ import { typeScriptReader } from './readers/typescript.js';
13
+ import { testChanges, withMovedTests } from './test-statements.js';
14
+ const run = promisify(execFile);
15
+ const git = async (repo, ...args) => (await run('git', ['-C', repo, ...args])).stdout.trim();
16
+ const readers = [rustReader, typeScriptReader];
17
+ const linkers = [fetchAxumLinker];
18
+ const missingToolOf = ({ tool }) => tool === undefined || onPath(tool) ? undefined : tool;
19
+ const readSide = async ({ repo, revision, directory, config, }) => {
20
+ await git(repo, 'worktree', 'add', '--detach', directory, revision);
21
+ const { opened } = readers.reduce((sofar, reader) => {
22
+ const checkout = reader.open({
23
+ directory,
24
+ repoName: basename(repo),
25
+ config,
26
+ taken: sofar.taken,
27
+ });
28
+ return {
29
+ taken: new Set([...sofar.taken, ...checkout.stacks.values()]),
30
+ opened: [...sofar.opened, { reader, checkout }],
31
+ };
32
+ }, { taken: new Set(), opened: [] });
33
+ const kept = (stack) => config.stacks[stack] !== false;
34
+ const skipped = opened.flatMap(({ reader, checkout }) => {
35
+ const tool = missingToolOf(reader);
36
+ const stacks = [...checkout.stacks.values()].filter(kept);
37
+ return tool === undefined || stacks.length === 0 ? [] : [{ reader: reader.name, tool, stacks }];
38
+ });
39
+ const graphs = (await Promise.all(opened
40
+ .filter(({ reader }) => !skipped.some(({ reader: name }) => name === reader.name))
41
+ .map(({ checkout }) => Promise.all([...checkout.stacks]
42
+ .filter(([, stack]) => kept(stack))
43
+ .map(([root]) => checkout.graph(root)))))).flat();
44
+ const stacks = opened.flatMap(({ reader, checkout }) => [...checkout.stacks].map(([root, stack]) => ({ stack, reader, root })));
45
+ const nodes = configuredNodes(config, graphs.flatMap(graph => graph.nodes));
46
+ const roots = stacks.filter(({ stack }) => kept(stack));
47
+ const stackOf = stackOfIn(nodes);
48
+ const linkedBy = (reader, only) => roots
49
+ .filter(drawn => drawn.reader.name === reader && (only?.includes(drawn.stack) ?? true))
50
+ .map(({ stack }) => ({ stack, nodes: nodes.filter(({ id }) => stackOf(id) === stack) }));
51
+ const links = await Promise.all(linkers.flatMap(linker => {
52
+ const settings = config.linkers[linker.name] ?? {};
53
+ if (settings === false)
54
+ return [];
55
+ return [
56
+ linker.link({
57
+ directory,
58
+ from: linkedBy(linker.from, settings.from),
59
+ to: linkedBy(linker.to, settings.to),
60
+ }),
61
+ ];
62
+ }));
63
+ const ids = new Set(nodes.map(({ id }) => id));
64
+ const edges = [...graphs.flatMap(graph => graph.edges), ...links.flatMap(({ edges }) => edges)];
65
+ return {
66
+ graph: { nodes, edges: edges.filter(edge => ids.has(edge.from) && ids.has(edge.to)) },
67
+ danglingCalls: links.flatMap(({ dangling }) => dangling),
68
+ roots,
69
+ stacks,
70
+ skipped,
71
+ };
72
+ };
73
+ const skippedAcross = (skips) => skips.reduce((merged, skip) => {
74
+ const known = merged.find(({ reader }) => reader === skip.reader);
75
+ return known === undefined
76
+ ? [...merged, skip]
77
+ : merged.map(each => each === known
78
+ ? { ...each, stacks: [...new Set([...each.stacks, ...skip.stacks])] }
79
+ : each);
80
+ }, []);
81
+ const textIn = async (directory, path) => existsSync(join(directory, path)) ? readFile(join(directory, path), 'utf8') : undefined;
82
+ const testFileAt = async ({ scratch, change: { from, to }, reader, }) => {
83
+ const before = await textIn(join(scratch, 'before'), from);
84
+ const after = await textIn(join(scratch, 'after'), to);
85
+ const renamedFrom = from === to ? undefined : from;
86
+ try {
87
+ return {
88
+ path: to,
89
+ renamedFrom,
90
+ statements: testChanges({ read: reader.readTests, before, after }),
91
+ };
92
+ }
93
+ catch (error) {
94
+ return { path: to, renamedFrom, unreadable: String(error) };
95
+ }
96
+ };
97
+ const isUnder = (path, root) => !relative(root, path).startsWith('..');
98
+ const testsIn = async ({ scratch, changes, roots, }) => {
99
+ const files = await Promise.all(changes.flatMap(change => {
100
+ const reader = roots.find(({ reader, root }) => reader.isTestFile(change.to) && isUnder(change.to, root))?.reader;
101
+ return reader === undefined ? [] : [testFileAt({ scratch, change, reader })];
102
+ }));
103
+ return files.filter(file => !('statements' in file) || file.statements.length > 0);
104
+ };
105
+ const configAt = async (repo, revision) => {
106
+ if ((await git(repo, 'ls-tree', '--name-only', revision, '--', CONFIG_FILE)) !== '')
107
+ return parseConfig(await git(repo, 'show', `${revision}:${CONFIG_FILE}`));
108
+ return parseConfig((await textIn(repo, CONFIG_FILE)) ?? '{}');
109
+ };
110
+ const resolve = (repo, revision) => git(repo, 'rev-parse', '--verify', '--quiet', `${revision}^{commit}`).catch(() => {
111
+ throw new Error(`Unknown revision: ${revision}`);
112
+ });
113
+ export const readRange = async ({ repo, from, to }) => {
114
+ const [fromCommit, toCommit] = await Promise.all([resolve(repo, from), resolve(repo, to)]);
115
+ const base = await git(repo, 'merge-base', fromCommit, toCommit);
116
+ const config = await configAt(repo, toCommit);
117
+ const scratch = await realpath(await mkdtemp(join(tmpdir(), 'visual-review-')));
118
+ try {
119
+ const before = await readSide({
120
+ repo,
121
+ revision: base,
122
+ directory: join(scratch, 'before'),
123
+ config,
124
+ });
125
+ const after = await readSide({
126
+ repo,
127
+ revision: toCommit,
128
+ directory: join(scratch, 'after'),
129
+ config,
130
+ });
131
+ checkConfig(config, [...before.stacks, ...after.stacks], linkers);
132
+ const changes = (await git(repo, 'diff', '--find-renames', '--name-status', base, toCommit))
133
+ .split('\n')
134
+ .filter(line => line !== '')
135
+ .map(line => line.split('\t').slice(1));
136
+ return {
137
+ before: before.graph,
138
+ after: after.graph,
139
+ danglingCalls: after.danglingCalls,
140
+ skipped: skippedAcross([...before.skipped, ...after.skipped]),
141
+ changedPaths: changes.flat(),
142
+ renames: changes.flatMap(([from = '', to]) => (to === undefined ? [] : [{ from, to }])),
143
+ tests: withMovedTests(await testsIn({
144
+ scratch,
145
+ changes: changes.map(([from = '', to = from]) => ({ from, to })),
146
+ roots: [...before.roots, ...after.roots],
147
+ })),
148
+ patches: await Promise.all(changes.map(async (paths) => ({
149
+ path: paths.at(-1) ?? '',
150
+ patch: await git(repo, 'diff', '--find-renames', base, toCommit, '--', ...paths),
151
+ }))),
152
+ };
153
+ }
154
+ finally {
155
+ await rm(scratch, { recursive: true });
156
+ await git(repo, 'worktree', 'prune');
157
+ }
158
+ };
@@ -0,0 +1,30 @@
1
+ import type { Config, ReaderName } from './config.ts';
2
+ import type { Graph } from './graph.ts';
3
+ import type { ReadTest } from './test-statements.ts';
4
+ export type Checkout = {
5
+ readonly directory: string;
6
+ readonly repoName: string;
7
+ readonly config: Config;
8
+ readonly taken: ReadonlySet<string>;
9
+ };
10
+ export type OpenedCheckout = {
11
+ readonly stacks: ReadonlyMap<string, string>;
12
+ readonly graph: (root: string) => Promise<Graph>;
13
+ };
14
+ /**
15
+ * Reads one stack of a checkout. `open` looks at a checkout once, given the reviewed repo's folder
16
+ * name for a stack with no name of its own, the review's config and the ids readers before it took: `stacks` maps each directory,
17
+ * relative to the checkout, where a stack is found (none when it isn't) to the id the stack is
18
+ * drawn under, and `graph` reads one of those roots into nodes and edges. The reader also owns its
19
+ * test files, saying which paths they are and how their tests read. `tool` names the command its
20
+ * `graph` runs, if any: when it isn't on `PATH`, none of the reader's stacks is read, and the
21
+ * review lists them as skipped.
22
+ */
23
+ export interface Reader {
24
+ readonly name: ReaderName;
25
+ readonly tool?: string;
26
+ readonly stackFields: ReadonlyArray<string>;
27
+ readonly open: (checkout: Checkout) => OpenedCheckout;
28
+ readonly isTestFile: (path: string) => boolean;
29
+ readonly readTests: (text: string) => ReadonlyArray<ReadTest>;
30
+ }
package/dist/reader.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { Reader } from '../reader.ts';
2
+ export declare const rustReader: Reader;
@@ -0,0 +1,66 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { addedStacks } from '../config.js';
6
+ import { crateGraph, crateRoots } from '../crate-graph.js';
7
+ import { crateModules } from '../crate-modules.js';
8
+ import { readRustTests } from '../rust-tests.js';
9
+ import { sourcesIn } from '../sources-in.js';
10
+ const run = promisify(execFile);
11
+ const reviewersToolchain = (outside) => run('rustup', ['show', 'active-toolchain'], { cwd: outside }).then(({ stdout }) => ({ ...process.env, RUSTUP_TOOLCHAIN: stdout.split(' ')[0] }), () => process.env);
12
+ const graphAt = async (checkout, root) => {
13
+ const directory = join(checkout, root);
14
+ const { stdout } = await run('cargo', [
15
+ 'metadata',
16
+ '--no-deps',
17
+ '--format-version',
18
+ '1',
19
+ '--manifest-path',
20
+ join(directory, 'Cargo.toml'),
21
+ ], { cwd: directory, env: await reviewersToolchain(dirname(checkout)) });
22
+ const crates = crateGraph(stdout);
23
+ const modules = await Promise.all(crateRoots(stdout).map(async ({ crate, directory: crateDirectory, roots }) => {
24
+ const files = await sourcesIn(directory, join(crateDirectory, '**/*.rs'));
25
+ const sources = Object.fromEntries(files.map(({ file, text }) => [file, text]));
26
+ return crateModules({ crate, roots, sources });
27
+ }));
28
+ return {
29
+ nodes: [...crates.nodes, ...modules.flatMap(graph => graph.nodes)],
30
+ edges: [...crates.edges, ...modules.flatMap(graph => graph.edges)],
31
+ };
32
+ };
33
+ const rebased = (graph, { stack, root }) => {
34
+ const id = (old) => `${stack}${old.slice('rust'.length)}`;
35
+ return {
36
+ nodes: graph.nodes.map(node => ({
37
+ ...node,
38
+ id: id(node.id),
39
+ label: node.parent === undefined ? stack : node.label,
40
+ ...(node.parent === undefined ? {} : { parent: id(node.parent) }),
41
+ path: node.kind === 'stack' ? '' : join(root, node.path),
42
+ })),
43
+ edges: graph.edges.map(edge => ({ ...edge, from: id(edge.from), to: id(edge.to) })),
44
+ };
45
+ };
46
+ const isWorkspace = (directory) => existsSync(join(directory, 'Cargo.toml'));
47
+ export const rustReader = {
48
+ name: 'rust',
49
+ tool: 'cargo',
50
+ stackFields: [],
51
+ open: ({ directory, config, taken }) => {
52
+ const detected = new Map(isWorkspace(directory) ? [['.', 'rust']] : []);
53
+ const stacks = new Map([
54
+ ...detected,
55
+ ...addedStacks({ config, reader: 'rust', detected, taken })
56
+ .filter(({ root }) => isWorkspace(join(directory, root)))
57
+ .map(({ root, stack }) => [root, stack]),
58
+ ]);
59
+ return {
60
+ stacks,
61
+ graph: async (root) => rebased(await graphAt(directory, root), { stack: stacks.get(root) ?? 'rust', root }),
62
+ };
63
+ },
64
+ isTestFile: path => path.endsWith('.rs'),
65
+ readTests: readRustTests,
66
+ };
@@ -0,0 +1,2 @@
1
+ import type { Reader } from '../reader.ts';
2
+ export declare const typeScriptReader: Reader;
@@ -0,0 +1,124 @@
1
+ import { existsSync, globSync, readFileSync, statSync } from 'node:fs';
2
+ import { basename, dirname, join, relative } from 'node:path';
3
+ import { cruise } from 'dependency-cruiser';
4
+ import { parse } from 'jsonc-parser';
5
+ import { z } from 'zod';
6
+ import { addedStacks, folderOf, stackSettings } from '../config.js';
7
+ import { readTypeScriptTests } from '../test-statements.js';
8
+ import { webGraph } from '../web-graph.js';
9
+ const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/;
10
+ const SOURCES = '**/*.{ts,tsx,js,jsx,mjs,cjs}';
11
+ const TsconfigSchema = z.object({
12
+ compilerOptions: z.object({ rootDir: z.string().optional() }).optional(),
13
+ include: z.array(z.string()).optional(),
14
+ });
15
+ const tsconfigIn = (directory) => {
16
+ const tsconfig = join(directory, 'tsconfig.json');
17
+ return existsSync(tsconfig)
18
+ ? (TsconfigSchema.safeParse(parse(readFileSync(tsconfig, 'utf8'))).data ?? {})
19
+ : {};
20
+ };
21
+ const isDirectory = (path) => existsSync(path) && statSync(path).isDirectory();
22
+ const includedFolders = (directory, include) => [
23
+ ...new Set(include
24
+ .map(entry => folderOf(entry.replace(/[*?{[].*$/, '')))
25
+ .filter(folder => folder !== '.' && isDirectory(join(directory, folder)))),
26
+ ];
27
+ const sourceRootOf = (directory) => {
28
+ const { compilerOptions, include = [] } = tsconfigIn(directory);
29
+ const [folder, ...others] = includedFolders(directory, include);
30
+ const rootDir = compilerOptions?.rootDir;
31
+ if (rootDir !== undefined)
32
+ return folderOf(rootDir);
33
+ return folder !== undefined && others.length === 0 ? folder : 'src';
34
+ };
35
+ const PackageSchema = z.object({ name: z.string().optional() });
36
+ const packageNameIn = (directory) => {
37
+ const manifest = join(directory, 'package.json');
38
+ return existsSync(manifest)
39
+ ? PackageSchema.safeParse(parse(readFileSync(manifest, 'utf8'))).data?.name
40
+ : undefined;
41
+ };
42
+ const stackAt = ({ checkout: { directory, repoName }, root }) => root === '.' ? (packageNameIn(directory) ?? repoName) : root;
43
+ const sourceRootIn = ({ checkout: { directory, config }, root, stack }) => {
44
+ const { sourceRoot } = stackSettings(config, stack);
45
+ return sourceRoot === undefined ? sourceRootOf(join(directory, root)) : folderOf(sourceRoot);
46
+ };
47
+ const hasSources = ({ checkout, root }) => globSync(SOURCES, {
48
+ cwd: join(checkout.directory, root, sourceRootIn({ checkout, root, stack: stackAt({ checkout, root }) })),
49
+ }).length > 0;
50
+ const holdsOthers = ({ checkout, root, roots }) => {
51
+ const folder = join(root, sourceRootOf(join(checkout, root)));
52
+ return roots.some(other => other !== root && !relative(folder, other).startsWith('..'));
53
+ };
54
+ const rootsIn = (checkout) => {
55
+ const roots = globSync('**/package.json', {
56
+ cwd: checkout.directory,
57
+ exclude: path => basename(path) === 'node_modules',
58
+ })
59
+ .map(dirname)
60
+ .filter(root => hasSources({ checkout, root }))
61
+ .toSorted();
62
+ return roots.filter(root => !holdsOthers({ checkout: checkout.directory, root, roots }));
63
+ };
64
+ const stacksIn = (checkout, roots) => roots.reduce((sofar, root) => {
65
+ const stack = stackAt({ checkout, root });
66
+ const free = (n) => sofar.used.has(`${stack}~${n}`) ? free(n + 1) : `${stack}~${n}`;
67
+ const id = sofar.used.has(stack) ? free(2) : stack;
68
+ return { used: new Set([...sofar.used, id]), stacks: new Map([...sofar.stacks, [root, id]]) };
69
+ }, { used: checkout.taken, stacks: new Map() }).stacks;
70
+ const packagesIn = (directory, stacks) => new Map([...stacks].flatMap(([root, stack]) => {
71
+ const name = packageNameIn(join(directory, root));
72
+ return name === undefined ? [] : [[name, stack]];
73
+ }));
74
+ const graphAt = async ({ directory, root, stack, sourceRoot, boxes, packages }) => {
75
+ const { output } = await cruise([sourceRoot], {
76
+ baseDir: join(directory, root),
77
+ outputType: 'json',
78
+ });
79
+ return webGraph({
80
+ stack,
81
+ directory: root,
82
+ sourceRoot,
83
+ boxes,
84
+ cruiseJson: String(output),
85
+ packages,
86
+ });
87
+ };
88
+ export const typeScriptReader = {
89
+ name: 'typescript',
90
+ stackFields: ['boxes', 'sourceRoot'],
91
+ open: checkout => {
92
+ const { directory, config } = checkout;
93
+ const detected = rootsIn(checkout);
94
+ const detectedStacks = stacksIn(checkout, detected);
95
+ const added = addedStacks({
96
+ config,
97
+ reader: 'typescript',
98
+ detected: detectedStacks,
99
+ taken: checkout.taken,
100
+ }).filter(({ root, stack }) => isDirectory(join(directory, root, sourceRootIn({ checkout, root, stack }))));
101
+ const stacks = new Map([
102
+ ...detectedStacks,
103
+ ...added.map(({ root, stack }) => [root, stack]),
104
+ ]);
105
+ const packages = packagesIn(directory, stacks);
106
+ return {
107
+ stacks,
108
+ graph: root => {
109
+ const stack = stacks.get(root) ?? root;
110
+ const { boxes = [] } = stackSettings(config, stack);
111
+ return graphAt({
112
+ directory,
113
+ root,
114
+ stack,
115
+ sourceRoot: sourceRootIn({ checkout, root, stack }),
116
+ boxes,
117
+ packages,
118
+ });
119
+ },
120
+ };
121
+ },
122
+ isTestFile: path => TEST_FILE.test(path),
123
+ readTests: readTypeScriptTests,
124
+ };
@@ -0,0 +1,10 @@
1
+ import { type Node } from 'web-tree-sitter';
2
+ export declare const parseRust: (text: string) => Node | undefined;
3
+ type Item = {
4
+ readonly node: Node;
5
+ readonly attributes: ReadonlyArray<Node>;
6
+ };
7
+ export declare const itemsIn: (list: Node) => ReadonlyArray<Item>;
8
+ export declare const attributeOf: (item: Node) => Node | undefined;
9
+ export declare const isCfgTest: (item: Node) => boolean;
10
+ export {};
@@ -0,0 +1,11 @@
1
+ import { createRequire } from 'node:module';
2
+ import { Language, Parser } from 'web-tree-sitter';
3
+ await Parser.init();
4
+ const parser = new Parser();
5
+ parser.setLanguage(await Language.load(createRequire(import.meta.url).resolve('tree-sitter-rust/tree-sitter-rust.wasm')));
6
+ export const parseRust = (text) => parser.parse(text)?.rootNode;
7
+ export const itemsIn = (list) => list.namedChildren.reduce(({ items, attributes }, child) => child.type === 'attribute_item'
8
+ ? { items, attributes: [...attributes, child] }
9
+ : { items: [...items, { node: child, attributes }], attributes: [] }, { items: [], attributes: [] }).items;
10
+ export const attributeOf = (item) => item.namedChildren[0];
11
+ export const isCfgTest = (item) => attributeOf(item)?.text === 'cfg(test)';
@@ -0,0 +1,2 @@
1
+ import type { ReadTest } from './test-statements.ts';
2
+ export declare const readRustTests: (text: string) => ReadonlyArray<ReadTest>;
@@ -0,0 +1,24 @@
1
+ import { attributeOf, isCfgTest, itemsIn, parseRust } from './rust-syntax.js';
2
+ const pathOf = (item) => attributeOf(item)?.namedChildren[0]?.text;
3
+ const isTestAttribute = (item) => pathOf(item)?.split('::').at(-1) === 'test';
4
+ const isIgnore = (item) => pathOf(item) === 'ignore';
5
+ const spaced = (name) => (name ?? '').replaceAll('_', ' ');
6
+ export const readRustTests = (text) => {
7
+ const statementsIn = (list) => itemsIn(list).flatMap(({ node, attributes }) => {
8
+ const name = spaced(node.childForFieldName('name')?.text);
9
+ if (attributes.some(isTestAttribute)) {
10
+ const source = [...attributes, node].map(part => part.text).join('\n');
11
+ const label = attributes.some(isIgnore) ? 'skip' : undefined;
12
+ return [{ kind: 'test', name, label, source }];
13
+ }
14
+ const body = node.childForFieldName('body');
15
+ if (body === null)
16
+ return [];
17
+ const children = statementsIn(body);
18
+ if (attributes.some(isCfgTest))
19
+ return children;
20
+ return children.length === 0 ? [] : [{ kind: 'heading', name, children }];
21
+ });
22
+ const root = parseRust(text);
23
+ return root === undefined ? [] : statementsIn(root);
24
+ };
@@ -0,0 +1,7 @@
1
+ import type { ReaderName } from './config.ts';
2
+ export type Skipped = {
3
+ readonly reader: ReaderName;
4
+ readonly tool: string;
5
+ readonly stacks: ReadonlyArray<string>;
6
+ };
7
+ export declare const notReadLine: ({ tool, stacks }: Skipped) => string;
@@ -0,0 +1 @@
1
+ export const notReadLine = ({ tool, stacks }) => `${stacks.join(', ')} not read: ${tool} not found on PATH`;
@@ -0,0 +1,5 @@
1
+ export type SourceFile = {
2
+ readonly file: string;
3
+ readonly text: string;
4
+ };
5
+ export declare const sourcesIn: (directory: string, pattern: string) => Promise<ReadonlyArray<SourceFile>>;
@@ -0,0 +1,7 @@
1
+ import { globSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ export const sourcesIn = (directory, pattern) => Promise.all(globSync(pattern, { cwd: directory }).map(async (file) => ({
5
+ file,
6
+ text: await readFile(join(directory, file), 'utf8'),
7
+ })));
@@ -0,0 +1,46 @@
1
+ export type TestChange = 'added' | 'removed' | 'edited' | 'unchanged';
2
+ export type TestLabel = 'skip' | 'only' | 'todo';
3
+ export type TestStatement = {
4
+ readonly kind: 'test';
5
+ readonly name: string;
6
+ readonly change: TestChange;
7
+ readonly label?: TestLabel | undefined;
8
+ readonly movedTo?: string;
9
+ readonly movedFrom?: string;
10
+ } | {
11
+ readonly kind: 'heading';
12
+ readonly name: string;
13
+ readonly label?: TestLabel | undefined;
14
+ readonly children: ReadonlyArray<TestStatement>;
15
+ };
16
+ export type TestFile = {
17
+ readonly path: string;
18
+ readonly renamedFrom?: string | undefined;
19
+ } & ({
20
+ readonly statements: ReadonlyArray<TestStatement>;
21
+ } | {
22
+ readonly unreadable: string;
23
+ });
24
+ export type ReadTest = {
25
+ readonly kind: 'test';
26
+ readonly name: string;
27
+ readonly label?: TestLabel | undefined;
28
+ readonly source: string;
29
+ } | {
30
+ readonly kind: 'heading';
31
+ readonly name: string;
32
+ readonly label?: TestLabel | undefined;
33
+ readonly children: ReadonlyArray<ReadTest>;
34
+ };
35
+ export declare const readTypeScriptTests: (text: string) => ReadonlyArray<ReadTest>;
36
+ type TestChangesOptions = {
37
+ readonly read: (text: string) => ReadonlyArray<ReadTest>;
38
+ readonly before?: string | undefined;
39
+ readonly after?: string | undefined;
40
+ };
41
+ export declare const testChanges: ({ read, before, after, }: TestChangesOptions) => ReadonlyArray<TestStatement>;
42
+ export type MarkedTest = Extract<TestStatement, {
43
+ kind: 'test';
44
+ }>;
45
+ export declare const withMovedTests: (files: ReadonlyArray<TestFile>) => ReadonlyArray<TestFile>;
46
+ export {};
@@ -0,0 +1,121 @@
1
+ import { parse } from '@babel/parser';
2
+ const TEST_CALLS = new Set(['test', 'it']);
3
+ const HEADING_CALLS = new Set(['describe', 'test.describe']);
4
+ const LABELS = new Map([
5
+ ['skip', 'skip'],
6
+ ['only', 'only'],
7
+ ['todo', 'todo'],
8
+ ]);
9
+ const TABLES = new Set(['each', 'for']);
10
+ const calleeParts = (callee) => {
11
+ if (callee.type === 'Identifier')
12
+ return [callee.name];
13
+ if (callee.type === 'CallExpression')
14
+ return calleeParts(callee.callee);
15
+ if (callee.type === 'TaggedTemplateExpression')
16
+ return calleeParts(callee.tag);
17
+ if (callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier')
18
+ return [];
19
+ return [...calleeParts(callee.object), callee.property.name];
20
+ };
21
+ export const readTypeScriptTests = (text) => {
22
+ const statementsIn = (body) => body.flatMap(statement => {
23
+ if (statement.type !== 'ExpressionStatement')
24
+ return [];
25
+ const call = statement.expression;
26
+ if (call.type !== 'CallExpression')
27
+ return [];
28
+ const parts = calleeParts(call.callee);
29
+ const callee = parts.filter(part => !LABELS.has(part) && !TABLES.has(part)).join('.');
30
+ const label = parts.map(part => LABELS.get(part)).find(Boolean);
31
+ const [name, callback] = call.arguments;
32
+ if (name === undefined)
33
+ return [];
34
+ const isTable = call.callee.type !== 'MemberExpression' && call.callee.type !== 'Identifier';
35
+ const shown = name.type === 'StringLiteral' && !isTable
36
+ ? name.value
37
+ : text.slice(name.start ?? undefined, name.end ?? undefined);
38
+ if (TEST_CALLS.has(callee)) {
39
+ const source = text.slice(statement.start ?? undefined, statement.end ?? undefined);
40
+ return [{ kind: 'test', name: shown, label, source }];
41
+ }
42
+ if (!HEADING_CALLS.has(callee))
43
+ return [];
44
+ if (callback?.type !== 'ArrowFunctionExpression' || callback.body.type !== 'BlockStatement') {
45
+ return [];
46
+ }
47
+ return [{ kind: 'heading', name: shown, label, children: statementsIn(callback.body.body) }];
48
+ });
49
+ return statementsIn(parse(text, { sourceType: 'module', plugins: ['typescript', 'jsx'] }).program.body);
50
+ };
51
+ const allMarked = (tests, change) => tests.map(test => test.kind === 'test'
52
+ ? { kind: 'test', name: test.name, change, label: test.label }
53
+ : {
54
+ kind: 'heading',
55
+ name: test.name,
56
+ label: test.label,
57
+ children: allMarked(test.children, change),
58
+ });
59
+ const sameTest = (one, other) => one.kind === other.kind && one.name === other.name;
60
+ const comparedPair = (old, test) => {
61
+ if (old?.kind === 'heading' && test.kind === 'heading') {
62
+ return [
63
+ {
64
+ kind: 'heading',
65
+ name: test.name,
66
+ label: test.label,
67
+ children: compared(old.children, test.children),
68
+ },
69
+ ];
70
+ }
71
+ if (old?.kind !== 'test' || test.kind !== 'test')
72
+ return allMarked([test], 'added');
73
+ return [
74
+ {
75
+ kind: 'test',
76
+ name: test.name,
77
+ change: old.source === test.source ? 'unchanged' : 'edited',
78
+ label: test.label,
79
+ },
80
+ ];
81
+ };
82
+ const compared = (before, after) => {
83
+ const kept = (test) => after.some(candidate => sameTest(candidate, test));
84
+ const removedAfter = (anchor) => allMarked(before.filter((test, index) => !kept(test) && before.slice(0, index).findLast(kept) === anchor), 'removed');
85
+ return [
86
+ ...removedAfter(undefined),
87
+ ...after.flatMap(test => {
88
+ const old = before.find(candidate => sameTest(candidate, test));
89
+ return [...comparedPair(old, test), ...(old === undefined ? [] : removedAfter(old))];
90
+ }),
91
+ ];
92
+ };
93
+ const anyChanged = (statements) => statements.some(statement => statement.kind === 'test' ? statement.change !== 'unchanged' : anyChanged(statement.children));
94
+ export const testChanges = ({ read, before, after, }) => {
95
+ const readTests = (text) => (text === undefined ? [] : read(text));
96
+ const statements = compared(readTests(before), readTests(after));
97
+ return anyChanged(statements) ? statements : [];
98
+ };
99
+ const testsUnder = (statements) => statements.flatMap(statement => statement.kind === 'test' ? [statement] : testsUnder(statement.children));
100
+ export const withMovedTests = (files) => {
101
+ const pathsWhere = (name, change) => files.flatMap(file => 'statements' in file
102
+ ? testsUnder(file.statements)
103
+ .filter(test => test.name === name && test.change === change)
104
+ .map(() => file.path)
105
+ : []);
106
+ const otherEnd = (test) => {
107
+ const [from, ...moreFrom] = pathsWhere(test.name, 'removed');
108
+ const [to, ...moreTo] = pathsWhere(test.name, 'added');
109
+ if (from === undefined || to === undefined || from === to)
110
+ return {};
111
+ if (moreFrom.length > 0 || moreTo.length > 0)
112
+ return {};
113
+ if (test.change === 'removed')
114
+ return { movedTo: to };
115
+ return test.change === 'added' ? { movedFrom: from } : {};
116
+ };
117
+ const marked = (statement) => statement.kind === 'test'
118
+ ? { ...statement, ...otherEnd(statement) }
119
+ : { ...statement, children: statement.children.map(marked) };
120
+ return files.map(file => 'statements' in file ? { ...file, statements: file.statements.map(marked) } : file);
121
+ };
@@ -0,0 +1,18 @@
1
+ import type { GraphDiff } from '../diff-graphs.ts';
2
+ import type { DanglingCall } from '../linker.ts';
3
+ import type { ReviewNotes } from '../notes.ts';
4
+ import type { PullRequest } from '../pull-request.ts';
5
+ import type { FilePatch } from '../read-range.ts';
6
+ import type { Skipped } from '../skipped.ts';
7
+ import type { TestFile } from '../test-statements.ts';
8
+ export type ReviewData = {
9
+ readonly range: string;
10
+ readonly pullRequest?: PullRequest;
11
+ readonly diff: GraphDiff;
12
+ readonly dangling?: ReadonlyArray<DanglingCall>;
13
+ readonly patches?: ReadonlyArray<FilePatch>;
14
+ readonly undrawn?: ReadonlyArray<FilePatch>;
15
+ readonly tests?: ReadonlyArray<TestFile>;
16
+ readonly notes?: ReviewNotes;
17
+ readonly skipped?: ReadonlyArray<Skipped>;
18
+ };
@@ -0,0 +1 @@
1
+ export {};