@pablocm90/visual-review 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/dist/api-paths.d.ts +18 -0
- package/dist/api-paths.js +22 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +95 -0
- package/dist/config.d.ts +85 -0
- package/dist/config.js +150 -0
- package/dist/config.schema.json +98 -0
- package/dist/crate-graph.d.ts +8 -0
- package/dist/crate-graph.js +51 -0
- package/dist/crate-modules.d.ts +8 -0
- package/dist/crate-modules.js +103 -0
- package/dist/diff-graphs.d.ts +31 -0
- package/dist/diff-graphs.js +72 -0
- package/dist/graph.d.ts +24 -0
- package/dist/graph.js +9 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1 -0
- package/dist/linker.d.ts +32 -0
- package/dist/linker.js +1 -0
- package/dist/linkers/fetch-axum.d.ts +2 -0
- package/dist/linkers/fetch-axum.js +38 -0
- package/dist/notes.d.ts +37 -0
- package/dist/notes.js +82 -0
- package/dist/notes.schema.json +68 -0
- package/dist/on-path.d.ts +1 -0
- package/dist/on-path.js +16 -0
- package/dist/prune-reviews.d.ts +6 -0
- package/dist/prune-reviews.js +12 -0
- package/dist/pull-request.d.ts +17 -0
- package/dist/pull-request.js +41 -0
- package/dist/read-range.d.ts +29 -0
- package/dist/read-range.js +158 -0
- package/dist/reader.d.ts +30 -0
- package/dist/reader.js +1 -0
- package/dist/readers/rust.d.ts +2 -0
- package/dist/readers/rust.js +66 -0
- package/dist/readers/typescript.d.ts +2 -0
- package/dist/readers/typescript.js +124 -0
- package/dist/rust-syntax.d.ts +10 -0
- package/dist/rust-syntax.js +11 -0
- package/dist/rust-tests.d.ts +2 -0
- package/dist/rust-tests.js +24 -0
- package/dist/skipped.d.ts +7 -0
- package/dist/skipped.js +1 -0
- package/dist/sources-in.d.ts +5 -0
- package/dist/sources-in.js +7 -0
- package/dist/test-statements.d.ts +46 -0
- package/dist/test-statements.js +121 -0
- package/dist/viewer/review-data.d.ts +18 -0
- package/dist/viewer/review-data.js +1 -0
- package/dist/viewer/visible-diff.d.ts +16 -0
- package/dist/viewer/visible-diff.js +74 -0
- package/dist/viewer.html +52 -0
- package/dist/web-graph.d.ts +19 -0
- package/dist/web-graph.js +59 -0
- package/dist/working-tree.d.ts +1 -0
- package/dist/working-tree.js +19 -0
- package/package.json +68 -4
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { basename, dirname, join } from 'node:path';
|
|
2
|
+
import { crateId } from './crate-graph.js';
|
|
3
|
+
import { edgeKey } from './graph.js';
|
|
4
|
+
import { isCfgTest, itemsIn, parseRust } from './rust-syntax.js';
|
|
5
|
+
const itemsOf = (text) => {
|
|
6
|
+
const root = parseRust(text);
|
|
7
|
+
if (root === undefined)
|
|
8
|
+
return [];
|
|
9
|
+
return itemsIn(root)
|
|
10
|
+
.filter(({ attributes }) => !attributes.some(isCfgTest))
|
|
11
|
+
.map(({ node }) => node);
|
|
12
|
+
};
|
|
13
|
+
const declaredModules = (items) => items.flatMap(node => node.type === 'mod_item' && node.childForFieldName('body') === null
|
|
14
|
+
? [node.childForFieldName('name')?.text ?? '']
|
|
15
|
+
: []);
|
|
16
|
+
const EXTERN_PRELUDE = '::';
|
|
17
|
+
const segmentsOf = (node) => {
|
|
18
|
+
if (node === null)
|
|
19
|
+
return [EXTERN_PRELUDE];
|
|
20
|
+
if (node.type !== 'scoped_identifier')
|
|
21
|
+
return [node.text];
|
|
22
|
+
return [
|
|
23
|
+
...segmentsOf(node.childForFieldName('path')),
|
|
24
|
+
node.childForFieldName('name')?.text ?? '',
|
|
25
|
+
];
|
|
26
|
+
};
|
|
27
|
+
const pathsInUse = (node) => {
|
|
28
|
+
if (node.type === 'use_list')
|
|
29
|
+
return node.namedChildren.flatMap(pathsInUse);
|
|
30
|
+
if (node.type === 'use_as_clause')
|
|
31
|
+
return [segmentsOf(node.childForFieldName('path'))];
|
|
32
|
+
if (node.type === 'use_wildcard')
|
|
33
|
+
return [segmentsOf(node.namedChildren[0] ?? null)];
|
|
34
|
+
if (node.type === 'scoped_use_list') {
|
|
35
|
+
const prefix = segmentsOf(node.childForFieldName('path'));
|
|
36
|
+
const list = node.childForFieldName('list');
|
|
37
|
+
return (list === null ? [] : pathsInUse(list)).map(path => [...prefix, ...path]);
|
|
38
|
+
}
|
|
39
|
+
return [segmentsOf(node)];
|
|
40
|
+
};
|
|
41
|
+
const usedPaths = (items) => items.flatMap(node => {
|
|
42
|
+
const argument = node.type === 'use_declaration' ? node.childForFieldName('argument') : null;
|
|
43
|
+
return argument === null ? [] : pathsInUse(argument);
|
|
44
|
+
});
|
|
45
|
+
export const crateModules = ({ crate, roots, sources }) => {
|
|
46
|
+
const readModule = ({ path, segments, label }) => ({
|
|
47
|
+
label,
|
|
48
|
+
path,
|
|
49
|
+
segments,
|
|
50
|
+
items: itemsOf(sources[path] ?? ''),
|
|
51
|
+
});
|
|
52
|
+
const withChildren = ({ module, directory }) => [
|
|
53
|
+
module,
|
|
54
|
+
...declaredModules(module.items).flatMap(name => {
|
|
55
|
+
const found = [join(directory, `${name}.rs`), join(directory, name, 'mod.rs')].find(candidate => sources[candidate] !== undefined);
|
|
56
|
+
if (found === undefined)
|
|
57
|
+
return [];
|
|
58
|
+
const segments = [...module.segments, name];
|
|
59
|
+
return withChildren({
|
|
60
|
+
module: readModule({ path: found, segments, label: segments.join('::') }),
|
|
61
|
+
directory: join(directory, name),
|
|
62
|
+
});
|
|
63
|
+
}),
|
|
64
|
+
];
|
|
65
|
+
const idOf = (module) => `rust/mod/${crate}/${module.label}`;
|
|
66
|
+
const trees = roots.map(path => withChildren({
|
|
67
|
+
module: readModule({ path, segments: [], label: basename(path) }),
|
|
68
|
+
directory: dirname(path),
|
|
69
|
+
}));
|
|
70
|
+
const edges = trees.flatMap(tree => {
|
|
71
|
+
const byKey = new Map(tree.map(module => [module.segments.join('::'), module]));
|
|
72
|
+
const moduleAt = (segments) => byKey.get(segments.join('::'));
|
|
73
|
+
const startOf = (from, first) => {
|
|
74
|
+
if (first === 'crate')
|
|
75
|
+
return [];
|
|
76
|
+
if (first === 'self')
|
|
77
|
+
return from;
|
|
78
|
+
if (first === 'super')
|
|
79
|
+
return from.slice(0, -1);
|
|
80
|
+
return [...from, first];
|
|
81
|
+
};
|
|
82
|
+
const targetOf = (from, [first = '', ...rest]) => {
|
|
83
|
+
const absolute = rest.reduce((at, segment) => (segment === 'super' ? at.slice(0, -1) : at.concat(segment)), startOf(from, first));
|
|
84
|
+
return Array.from({ length: absolute.length + 1 }, (_, index) => moduleAt(absolute.slice(0, absolute.length - index))).find(module => module !== undefined);
|
|
85
|
+
};
|
|
86
|
+
return tree.flatMap(module => usedPaths(module.items).flatMap((path) => {
|
|
87
|
+
const target = targetOf(module.segments, path);
|
|
88
|
+
return target === undefined || target === module
|
|
89
|
+
? []
|
|
90
|
+
: [{ from: idOf(module), to: idOf(target) }];
|
|
91
|
+
}));
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
nodes: trees.flat().map((module) => ({
|
|
95
|
+
id: idOf(module),
|
|
96
|
+
kind: 'file',
|
|
97
|
+
label: module.label,
|
|
98
|
+
parent: crateId(crate),
|
|
99
|
+
path: module.path,
|
|
100
|
+
})),
|
|
101
|
+
edges: [...new Map(edges.map(edge => [edgeKey(edge), edge])).values()],
|
|
102
|
+
};
|
|
103
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Graph, type GraphEdge, type GraphNode } from './graph.ts';
|
|
2
|
+
import type { FilePatch, Rename } from './read-range.ts';
|
|
3
|
+
type Diffed<T, KeptState> = T & {
|
|
4
|
+
readonly state: 'added' | 'removed' | KeptState;
|
|
5
|
+
};
|
|
6
|
+
export type GraphDiff = {
|
|
7
|
+
readonly nodes: ReadonlyArray<Diffed<GraphNode, 'modified' | 'unchanged'> & {
|
|
8
|
+
readonly renamedFrom?: string;
|
|
9
|
+
readonly formerLabel?: string;
|
|
10
|
+
}>;
|
|
11
|
+
readonly edges: ReadonlyArray<Diffed<GraphEdge, 'unchanged'>>;
|
|
12
|
+
};
|
|
13
|
+
type DiffGraphsOptions = {
|
|
14
|
+
readonly before: Graph;
|
|
15
|
+
readonly after: Graph;
|
|
16
|
+
readonly changedPaths: ReadonlyArray<string>;
|
|
17
|
+
readonly renames: ReadonlyArray<Rename>;
|
|
18
|
+
};
|
|
19
|
+
export declare const isAtOrUnder: (path: string, node: GraphNode) => boolean;
|
|
20
|
+
export declare const diffGraphs: ({ before, after, changedPaths, renames, }: DiffGraphsOptions) => GraphDiff;
|
|
21
|
+
export type DiffedNode = GraphDiff['nodes'][number];
|
|
22
|
+
export type DiffedEdge = GraphDiff['edges'][number];
|
|
23
|
+
type SplitPatchesOptions = {
|
|
24
|
+
readonly diff: GraphDiff;
|
|
25
|
+
readonly patches: ReadonlyArray<FilePatch>;
|
|
26
|
+
};
|
|
27
|
+
export declare const splitPatches: ({ diff, patches }: SplitPatchesOptions) => {
|
|
28
|
+
drawn: FilePatch[];
|
|
29
|
+
undrawn: FilePatch[];
|
|
30
|
+
};
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { edgeKey } from './graph.js';
|
|
2
|
+
const diffItems = ({ before, after, keyOf, stateOfKept, }) => {
|
|
3
|
+
const beforeKeys = new Set(before.map(keyOf));
|
|
4
|
+
const afterKeys = new Set(after.map(keyOf));
|
|
5
|
+
return [
|
|
6
|
+
...after.map((item) => ({
|
|
7
|
+
...item,
|
|
8
|
+
state: beforeKeys.has(keyOf(item)) ? stateOfKept(item) : 'added',
|
|
9
|
+
})),
|
|
10
|
+
...before
|
|
11
|
+
.filter(item => !afterKeys.has(keyOf(item)))
|
|
12
|
+
.map((item) => ({ ...item, state: 'removed' })),
|
|
13
|
+
];
|
|
14
|
+
};
|
|
15
|
+
export const isAtOrUnder = (path, node) => path === node.path || path.startsWith(`${node.path}/`);
|
|
16
|
+
const pairNodes = ({ before, after, renames, }) => {
|
|
17
|
+
const beforeIds = new Set(before.nodes.map(node => node.id));
|
|
18
|
+
const afterIds = new Set(after.nodes.map(node => node.id));
|
|
19
|
+
const added = after.nodes.filter(node => !beforeIds.has(node.id));
|
|
20
|
+
const filesAcross = (from, to) => {
|
|
21
|
+
if (from.kind !== to.kind || from.path === '')
|
|
22
|
+
return 0;
|
|
23
|
+
if (from.path === to.path)
|
|
24
|
+
return Number.POSITIVE_INFINITY;
|
|
25
|
+
return renames.filter(rename => isAtOrUnder(rename.from, from) && isAtOrUnder(rename.to, to))
|
|
26
|
+
.length;
|
|
27
|
+
};
|
|
28
|
+
return new Map(before.nodes
|
|
29
|
+
.filter(node => !afterIds.has(node.id))
|
|
30
|
+
.flatMap(from => {
|
|
31
|
+
const [best] = added
|
|
32
|
+
.map(to => ({ to, files: filesAcross(from, to) }))
|
|
33
|
+
.filter(({ files }) => files > 0)
|
|
34
|
+
.sort((a, b) => b.files - a.files);
|
|
35
|
+
return best === undefined ? [] : [[from.id, { from, to: best.to }]];
|
|
36
|
+
}));
|
|
37
|
+
};
|
|
38
|
+
export const diffGraphs = ({ before, after, changedPaths, renames, }) => {
|
|
39
|
+
const isTouched = (node) => changedPaths.some(path => isAtOrUnder(path, node));
|
|
40
|
+
const pairs = pairNodes({ before, after, renames });
|
|
41
|
+
const pairedFrom = new Map([...pairs].map(([, { from, to }]) => [to.id, from]));
|
|
42
|
+
const renamedFrom = new Map(renames.map(({ from, to }) => [to, from]));
|
|
43
|
+
const idOf = (id) => pairs.get(id)?.to.id ?? id;
|
|
44
|
+
return {
|
|
45
|
+
nodes: diffItems({
|
|
46
|
+
before: before.nodes.map(node => ({ ...node, id: idOf(node.id) })),
|
|
47
|
+
after: after.nodes,
|
|
48
|
+
keyOf: node => node.id,
|
|
49
|
+
stateOfKept: node => (isTouched(node) ? 'modified' : 'unchanged'),
|
|
50
|
+
}).map(node => {
|
|
51
|
+
const former = pairedFrom.get(node.id);
|
|
52
|
+
const from = former === undefined ? renamedFrom.get(node.path) : former.path;
|
|
53
|
+
return {
|
|
54
|
+
...node,
|
|
55
|
+
...(from === undefined || from === node.path ? {} : { renamedFrom: from }),
|
|
56
|
+
...(former === undefined || former.label === node.label
|
|
57
|
+
? {}
|
|
58
|
+
: { formerLabel: former.label }),
|
|
59
|
+
};
|
|
60
|
+
}),
|
|
61
|
+
edges: diffItems({
|
|
62
|
+
before: before.edges.map(edge => ({ ...edge, from: idOf(edge.from), to: idOf(edge.to) })),
|
|
63
|
+
after: after.edges,
|
|
64
|
+
keyOf: edgeKey,
|
|
65
|
+
stateOfKept: () => 'unchanged',
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
export const splitPatches = ({ diff, patches }) => {
|
|
70
|
+
const isDrawn = ({ path }) => diff.nodes.some(node => isAtOrUnder(path, node));
|
|
71
|
+
return { drawn: patches.filter(isDrawn), undrawn: patches.filter(patch => !isDrawn(patch)) };
|
|
72
|
+
};
|
package/dist/graph.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A box in the review graph. `id` is the node's contract: notes are keyed by it, so a reader
|
|
3
|
+
* keeps it stable across versions. Ids are `<stack>/<word>/<name>` under the reader's stack id,
|
|
4
|
+
* e.g. `rust/crate/http-adapter`, `rust/mod/application/sign_up`, `web/module/utils`,
|
|
5
|
+
* `web/file/App.tsx`; the stack's own node is just `<stack>`. An edge is keyed `<from> -> <to>`.
|
|
6
|
+
*/
|
|
7
|
+
export type GraphNode = {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly kind: 'stack' | 'package' | 'module' | 'file';
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly parent?: string;
|
|
12
|
+
readonly path: string;
|
|
13
|
+
};
|
|
14
|
+
export type GraphEdge = {
|
|
15
|
+
readonly from: string;
|
|
16
|
+
readonly to: string;
|
|
17
|
+
readonly via?: ReadonlyArray<string>;
|
|
18
|
+
};
|
|
19
|
+
export type Graph = {
|
|
20
|
+
readonly nodes: ReadonlyArray<GraphNode>;
|
|
21
|
+
readonly edges: ReadonlyArray<GraphEdge>;
|
|
22
|
+
};
|
|
23
|
+
export declare const edgeKey: (edge: GraphEdge) => string;
|
|
24
|
+
export declare const stackOfIn: (nodes: ReadonlyArray<GraphNode>) => ((id: string) => string);
|
package/dist/graph.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const edgeKey = (edge) => JSON.stringify([edge.from, edge.to]);
|
|
2
|
+
export const stackOfIn = (nodes) => {
|
|
3
|
+
const parents = new Map(nodes.map(node => [node.id, node.parent]));
|
|
4
|
+
const stackOf = (id) => {
|
|
5
|
+
const parent = parents.get(id);
|
|
6
|
+
return parent === undefined ? id : stackOf(parent);
|
|
7
|
+
};
|
|
8
|
+
return stackOf;
|
|
9
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type { Graph, GraphEdge, GraphNode } from './graph.ts';
|
|
2
|
+
export type { DanglingCall, LinkedStack, Linker, LinkOptions, Links } from './linker.ts';
|
|
3
|
+
export type { Checkout, OpenedCheckout, Reader } from './reader.ts';
|
|
4
|
+
export type { ReadTest, TestLabel } from './test-statements.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/linker.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { LinkerName, ReaderName } from './config.ts';
|
|
2
|
+
import type { GraphEdge, GraphNode } from './graph.ts';
|
|
3
|
+
export type DanglingCall = {
|
|
4
|
+
readonly from: string;
|
|
5
|
+
readonly path: string;
|
|
6
|
+
};
|
|
7
|
+
export type LinkedStack = {
|
|
8
|
+
readonly stack: string;
|
|
9
|
+
readonly nodes: ReadonlyArray<GraphNode>;
|
|
10
|
+
};
|
|
11
|
+
export type Links = {
|
|
12
|
+
readonly edges: ReadonlyArray<GraphEdge>;
|
|
13
|
+
readonly dangling: ReadonlyArray<DanglingCall>;
|
|
14
|
+
};
|
|
15
|
+
export type LinkOptions = {
|
|
16
|
+
readonly directory: string;
|
|
17
|
+
readonly from: ReadonlyArray<LinkedStack>;
|
|
18
|
+
readonly to: ReadonlyArray<LinkedStack>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Draws edges across stacks. `link` is given the checkout's directory and every stack the config
|
|
22
|
+
* keeps of the `from` reader and of the `to` reader, or those the config's `linkers` entry names,
|
|
23
|
+
* each with its nodes, whose paths are relative to the checkout; a skipped reader's stacks come
|
|
24
|
+
* with none. It returns edges between those nodes' ids, and the calls from a `from` node that
|
|
25
|
+
* reach nothing in the `to` stacks.
|
|
26
|
+
*/
|
|
27
|
+
export interface Linker {
|
|
28
|
+
readonly name: LinkerName;
|
|
29
|
+
readonly from: ReaderName;
|
|
30
|
+
readonly to: ReaderName;
|
|
31
|
+
readonly link: (options: LinkOptions) => Promise<Links>;
|
|
32
|
+
}
|
package/dist/linker.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { apiRoutes, callGraph, webCalls } from '../api-paths.js';
|
|
4
|
+
const TABLE_HEADER = /^\[([^\]]*)\]/;
|
|
5
|
+
const DEPENDENCY_TABLE = /^(target\..+\.)?dependencies$/;
|
|
6
|
+
const AXUM_TABLE = /^(target\..+\.)?dependencies\.axum$/;
|
|
7
|
+
const AXUM_KEY = /^\s*axum\s*[=.]/;
|
|
8
|
+
const declaresAxum = (manifest) => manifest.split('\n').reduce(({ table, found }, line) => {
|
|
9
|
+
const header = TABLE_HEADER.exec(line)?.[1];
|
|
10
|
+
if (header !== undefined)
|
|
11
|
+
return { table: header, found: found || AXUM_TABLE.test(header) };
|
|
12
|
+
return { table, found: found || (DEPENDENCY_TABLE.test(table) && AXUM_KEY.test(line)) };
|
|
13
|
+
}, { table: '', found: false }).found;
|
|
14
|
+
const dependsOnAxum = async (directory, crate) => declaresAxum(await readFile(join(directory, crate.path, 'Cargo.toml'), 'utf8'));
|
|
15
|
+
export const fetchAxumLinker = {
|
|
16
|
+
name: 'fetch-axum',
|
|
17
|
+
from: 'typescript',
|
|
18
|
+
to: 'rust',
|
|
19
|
+
link: async ({ directory, from, to }) => {
|
|
20
|
+
const files = from.flatMap(({ nodes }) => nodes.filter(({ kind }) => kind === 'file'));
|
|
21
|
+
const packages = to.flatMap(({ nodes }) => nodes.filter(({ kind }) => kind === 'package'));
|
|
22
|
+
const axum = await Promise.all(packages.map(crate => dependsOnAxum(directory, crate)));
|
|
23
|
+
const crates = packages.filter((_, index) => axum[index]);
|
|
24
|
+
if (crates.length === 0)
|
|
25
|
+
return { edges: [], dangling: [] };
|
|
26
|
+
const calls = webCalls(await Promise.all(files.map(async ({ id, path }) => ({
|
|
27
|
+
from: id,
|
|
28
|
+
text: await readFile(join(directory, path), 'utf8'),
|
|
29
|
+
}))));
|
|
30
|
+
const crateIds = new Set(crates.map(({ id }) => id));
|
|
31
|
+
const modules = to.flatMap(({ nodes }) => nodes.flatMap(({ parent, path }) => parent !== undefined && crateIds.has(parent) ? [{ crate: parent, path }] : []));
|
|
32
|
+
const routes = (await Promise.all(modules.map(async ({ crate, path }) => apiRoutes(await readFile(join(directory, path), 'utf8')).map(route => ({
|
|
33
|
+
to: crate,
|
|
34
|
+
path: route,
|
|
35
|
+
}))))).flat();
|
|
36
|
+
return callGraph({ calls, routes });
|
|
37
|
+
},
|
|
38
|
+
};
|
package/dist/notes.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { GraphDiff } from './diff-graphs.ts';
|
|
3
|
+
declare const ReviewNotesSchema: z.ZodReadonly<z.ZodObject<{
|
|
4
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
5
|
+
version: z.ZodLiteral<1>;
|
|
6
|
+
summary: z.ZodReadonly<z.ZodArray<z.ZodReadonly<z.ZodObject<{
|
|
7
|
+
text: z.ZodString;
|
|
8
|
+
target: z.ZodOptional<z.ZodString>;
|
|
9
|
+
}, z.core.$strict>>>>;
|
|
10
|
+
tests: z.ZodString;
|
|
11
|
+
notes: z.ZodReadonly<z.ZodRecord<z.ZodString, z.ZodReadonly<z.ZodObject<{
|
|
12
|
+
text: z.ZodString;
|
|
13
|
+
flag: z.ZodOptional<z.ZodString>;
|
|
14
|
+
}, z.core.$strict>>>>;
|
|
15
|
+
}, z.core.$strict>>;
|
|
16
|
+
export type ReviewNotes = z.infer<typeof ReviewNotesSchema>;
|
|
17
|
+
export type Note = ReviewNotes['notes'][string];
|
|
18
|
+
export declare const notesJsonSchema: () => z.core.ZodStandardJSONSchemaPayload<z.ZodReadonly<z.ZodObject<{
|
|
19
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
20
|
+
version: z.ZodLiteral<1>;
|
|
21
|
+
summary: z.ZodReadonly<z.ZodArray<z.ZodReadonly<z.ZodObject<{
|
|
22
|
+
text: z.ZodString;
|
|
23
|
+
target: z.ZodOptional<z.ZodString>;
|
|
24
|
+
}, z.core.$strict>>>>;
|
|
25
|
+
tests: z.ZodString;
|
|
26
|
+
notes: z.ZodReadonly<z.ZodRecord<z.ZodString, z.ZodReadonly<z.ZodObject<{
|
|
27
|
+
text: z.ZodString;
|
|
28
|
+
flag: z.ZodOptional<z.ZodString>;
|
|
29
|
+
}, z.core.$strict>>>>;
|
|
30
|
+
}, z.core.$strict>>>;
|
|
31
|
+
export declare const readNotes: (file: string) => Promise<ReviewNotes>;
|
|
32
|
+
type NotesProblemsOptions = {
|
|
33
|
+
readonly diff: GraphDiff;
|
|
34
|
+
readonly notes: ReviewNotes;
|
|
35
|
+
};
|
|
36
|
+
export declare const notesProblems: ({ diff, notes }: NotesProblemsOptions) => ReadonlyArray<string>;
|
|
37
|
+
export {};
|
package/dist/notes.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { edgeId, visibleDiff, visibleNodeOf, visibleNotes } from './viewer/visible-diff.js';
|
|
4
|
+
const notesVersion = 1;
|
|
5
|
+
const ReviewNotesSchema = z
|
|
6
|
+
.strictObject({
|
|
7
|
+
$schema: z.string().optional(),
|
|
8
|
+
version: z.literal(notesVersion).describe('The notes format this file is written in'),
|
|
9
|
+
summary: z
|
|
10
|
+
.array(z
|
|
11
|
+
.strictObject({
|
|
12
|
+
text: z.string(),
|
|
13
|
+
target: z.string().optional().describe('The id of the node or edge it is about'),
|
|
14
|
+
})
|
|
15
|
+
.readonly())
|
|
16
|
+
.readonly()
|
|
17
|
+
.describe("A few items on the change's shape as a whole"),
|
|
18
|
+
tests: z.string().describe('What the tests pin, and what the change leaves unpinned'),
|
|
19
|
+
notes: z
|
|
20
|
+
.record(z.string(), z
|
|
21
|
+
.strictObject({
|
|
22
|
+
text: z.string(),
|
|
23
|
+
flag: z.string().optional().describe('A short label for a structural concern'),
|
|
24
|
+
})
|
|
25
|
+
.readonly())
|
|
26
|
+
.readonly()
|
|
27
|
+
.describe('Notes by node id, or by `<from> -> <to>` for an edge'),
|
|
28
|
+
})
|
|
29
|
+
.readonly();
|
|
30
|
+
export const notesJsonSchema = () => z.toJSONSchema(ReviewNotesSchema, {
|
|
31
|
+
io: 'input',
|
|
32
|
+
override: ({ jsonSchema }) => {
|
|
33
|
+
delete jsonSchema.readOnly;
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const VersionedSchema = z.object({ version: z.unknown() });
|
|
37
|
+
const invalidNotes = (file, reason) => new Error(`Invalid notes file ${file}: ${reason}`);
|
|
38
|
+
const versionProblem = (version) => `${version === undefined ? 'no "version"' : `version ${JSON.stringify(version)} is unknown`}; this visual-review reads version ${notesVersion}`;
|
|
39
|
+
const contentsOf = async (file) => {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
throw invalidNotes(file, error);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
export const readNotes = async (file) => {
|
|
48
|
+
const contents = await contentsOf(file);
|
|
49
|
+
const version = VersionedSchema.safeParse(contents).data?.version;
|
|
50
|
+
if (version !== notesVersion) {
|
|
51
|
+
throw invalidNotes(file, versionProblem(version));
|
|
52
|
+
}
|
|
53
|
+
const notes = ReviewNotesSchema.safeParse(contents);
|
|
54
|
+
if (!notes.success) {
|
|
55
|
+
throw invalidNotes(file, notes.error);
|
|
56
|
+
}
|
|
57
|
+
return notes.data;
|
|
58
|
+
};
|
|
59
|
+
export const notesProblems = ({ diff, notes }) => {
|
|
60
|
+
const collapsedOf = visibleNodeOf({ diff, expanded: new Set() });
|
|
61
|
+
const endsOf = (id) => [...new Set([id, collapsedOf(id)])];
|
|
62
|
+
const known = new Set([
|
|
63
|
+
...diff.nodes.filter(node => node.kind !== 'stack').map(node => node.id),
|
|
64
|
+
...diff.edges.flatMap(edge => endsOf(edge.from).flatMap(from => endsOf(edge.to)
|
|
65
|
+
.filter(to => to !== from)
|
|
66
|
+
.map(to => edgeId({ from, to })))),
|
|
67
|
+
]);
|
|
68
|
+
const collapsed = visibleDiff({ diff, expanded: new Set() });
|
|
69
|
+
const shown = visibleNotes({ diff, expanded: new Set(), notes: notes.notes });
|
|
70
|
+
const touched = [
|
|
71
|
+
...collapsed.nodes
|
|
72
|
+
.filter(node => node.kind !== 'stack' && node.state !== 'unchanged')
|
|
73
|
+
.map(node => node.id),
|
|
74
|
+
...collapsed.edges.filter(edge => edge.state !== 'unchanged').map(edgeId),
|
|
75
|
+
];
|
|
76
|
+
return [
|
|
77
|
+
...[...Object.keys(notes.notes), ...notes.summary.flatMap(item => item.target ?? [])]
|
|
78
|
+
.filter(id => !known.has(id))
|
|
79
|
+
.map(id => `unknown id ${id}`),
|
|
80
|
+
...touched.filter(id => shown[id] === undefined).map(id => `no note on ${id}`),
|
|
81
|
+
];
|
|
82
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"$schema": {
|
|
6
|
+
"type": "string"
|
|
7
|
+
},
|
|
8
|
+
"version": {
|
|
9
|
+
"type": "number",
|
|
10
|
+
"const": 1,
|
|
11
|
+
"description": "The notes format this file is written in"
|
|
12
|
+
},
|
|
13
|
+
"summary": {
|
|
14
|
+
"description": "A few items on the change's shape as a whole",
|
|
15
|
+
"type": "array",
|
|
16
|
+
"items": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"text": {
|
|
20
|
+
"type": "string"
|
|
21
|
+
},
|
|
22
|
+
"target": {
|
|
23
|
+
"description": "The id of the node or edge it is about",
|
|
24
|
+
"type": "string"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"required": [
|
|
28
|
+
"text"
|
|
29
|
+
],
|
|
30
|
+
"additionalProperties": false
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"tests": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "What the tests pin, and what the change leaves unpinned"
|
|
36
|
+
},
|
|
37
|
+
"notes": {
|
|
38
|
+
"description": "Notes by node id, or by `<from> -> <to>` for an edge",
|
|
39
|
+
"type": "object",
|
|
40
|
+
"propertyNames": {
|
|
41
|
+
"type": "string"
|
|
42
|
+
},
|
|
43
|
+
"additionalProperties": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"text": {
|
|
47
|
+
"type": "string"
|
|
48
|
+
},
|
|
49
|
+
"flag": {
|
|
50
|
+
"description": "A short label for a structural concern",
|
|
51
|
+
"type": "string"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"required": [
|
|
55
|
+
"text"
|
|
56
|
+
],
|
|
57
|
+
"additionalProperties": false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"required": [
|
|
62
|
+
"version",
|
|
63
|
+
"summary",
|
|
64
|
+
"tests",
|
|
65
|
+
"notes"
|
|
66
|
+
],
|
|
67
|
+
"additionalProperties": false
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const onPath: (command: string) => boolean;
|
package/dist/on-path.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { accessSync, constants, statSync } from 'node:fs';
|
|
2
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
3
|
+
const runs = (file) => {
|
|
4
|
+
try {
|
|
5
|
+
accessSync(file, constants.X_OK);
|
|
6
|
+
return statSync(file).isFile();
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
export const onPath = (command) => {
|
|
13
|
+
const path = process.env['PATH'];
|
|
14
|
+
return (path === undefined ||
|
|
15
|
+
path.split(delimiter).some(directory => isAbsolute(directory) && runs(join(directory, command))));
|
|
16
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readdir, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export const pruneReviews = async ({ directory, keep }) => {
|
|
4
|
+
const pages = await Promise.all((await readdir(directory))
|
|
5
|
+
.filter(file => file.endsWith('.html'))
|
|
6
|
+
.map(async (file) => ({
|
|
7
|
+
name: file.slice(0, -'.html'.length),
|
|
8
|
+
writtenAt: (await stat(join(directory, file))).mtimeMs,
|
|
9
|
+
})));
|
|
10
|
+
const older = pages.toSorted((one, other) => other.writtenAt - one.writtenAt).slice(keep);
|
|
11
|
+
await Promise.all(older.flatMap(({ name }) => ['html', 'json', 'notes.json'].map(extension => rm(join(directory, `${name}.${extension}`), { force: true }))));
|
|
12
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type PullRequest = {
|
|
2
|
+
readonly number: number;
|
|
3
|
+
readonly title: string;
|
|
4
|
+
readonly url: string;
|
|
5
|
+
};
|
|
6
|
+
type PullRequestOptions = {
|
|
7
|
+
readonly repo: string;
|
|
8
|
+
readonly number: number;
|
|
9
|
+
};
|
|
10
|
+
export declare const readPullRequest: ({ repo, number }: PullRequestOptions) => Promise<{
|
|
11
|
+
from: string;
|
|
12
|
+
to: string;
|
|
13
|
+
number: number;
|
|
14
|
+
title: string;
|
|
15
|
+
url: string;
|
|
16
|
+
}>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
const run = promisify(execFile);
|
|
5
|
+
const PullRequestViewSchema = z.object({
|
|
6
|
+
baseRefName: z.string(),
|
|
7
|
+
mergeCommit: z.object({ oid: z.string() }).nullable(),
|
|
8
|
+
title: z.string(),
|
|
9
|
+
url: z.string(),
|
|
10
|
+
});
|
|
11
|
+
const namesRepository = (remoteUrl, repository) => {
|
|
12
|
+
const path = remoteUrl.replace(/\.git$/, '');
|
|
13
|
+
return path.endsWith(`/${repository}`) || path.endsWith(`:${repository}`);
|
|
14
|
+
};
|
|
15
|
+
export const readPullRequest = async ({ repo, number }) => {
|
|
16
|
+
const git = async (...args) => (await run('git', ['-C', repo, ...args])).stdout.trim();
|
|
17
|
+
const view = await run('gh', ['pr', 'view', String(number), '--json', 'baseRefName,mergeCommit,title,url'], { cwd: repo }).catch((error) => {
|
|
18
|
+
throw new Error(`Could not read PR #${number}: ${error.stderr.trim()}`);
|
|
19
|
+
});
|
|
20
|
+
const { baseRefName, mergeCommit, title, url } = PullRequestViewSchema.parse(JSON.parse(view.stdout));
|
|
21
|
+
const repository = new URL(url).pathname.split('/').slice(1, 3).join('/');
|
|
22
|
+
const remoteUrls = await git('config', '--get-regexp', '^remote\\..*\\.url$').catch(() => '');
|
|
23
|
+
const remote = remoteUrls.split('\n').flatMap(line => {
|
|
24
|
+
const [key = '', remoteUrl = ''] = line.split(' ');
|
|
25
|
+
return namesRepository(remoteUrl, repository)
|
|
26
|
+
? [key.slice('remote.'.length, -'.url'.length)]
|
|
27
|
+
: [];
|
|
28
|
+
})[0];
|
|
29
|
+
if (remote === undefined)
|
|
30
|
+
throw new Error(`No git remote points at ${repository}`);
|
|
31
|
+
const fetched = async (ref) => {
|
|
32
|
+
await git('fetch', '--quiet', remote, ref).catch((error) => {
|
|
33
|
+
throw new Error(`Could not fetch PR #${number} from ${remote}: ${error.stderr.trim()}`);
|
|
34
|
+
});
|
|
35
|
+
return git('rev-parse', 'FETCH_HEAD');
|
|
36
|
+
};
|
|
37
|
+
const to = await fetched(`refs/pull/${number}/head`);
|
|
38
|
+
const baseTip = await fetched(`refs/heads/${baseRefName}`);
|
|
39
|
+
const base = mergeCommit === null ? baseTip : `${mergeCommit.oid}^1`;
|
|
40
|
+
return { from: await git('merge-base', base, to), to, number, title, url };
|
|
41
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Graph } from './graph.ts';
|
|
2
|
+
import type { DanglingCall } from './linker.ts';
|
|
3
|
+
import type { Skipped } from './skipped.ts';
|
|
4
|
+
import { type TestFile } from './test-statements.ts';
|
|
5
|
+
export type FilePatch = {
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly patch: string;
|
|
8
|
+
};
|
|
9
|
+
export type Rename = {
|
|
10
|
+
readonly from: string;
|
|
11
|
+
readonly to: string;
|
|
12
|
+
};
|
|
13
|
+
export type RangeReading = {
|
|
14
|
+
readonly before: Graph;
|
|
15
|
+
readonly after: Graph;
|
|
16
|
+
readonly changedPaths: ReadonlyArray<string>;
|
|
17
|
+
readonly renames: ReadonlyArray<Rename>;
|
|
18
|
+
readonly patches: ReadonlyArray<FilePatch>;
|
|
19
|
+
readonly tests: ReadonlyArray<TestFile>;
|
|
20
|
+
readonly danglingCalls: ReadonlyArray<DanglingCall>;
|
|
21
|
+
readonly skipped: ReadonlyArray<Skipped>;
|
|
22
|
+
};
|
|
23
|
+
type ReadRangeOptions = {
|
|
24
|
+
readonly repo: string;
|
|
25
|
+
readonly from: string;
|
|
26
|
+
readonly to: string;
|
|
27
|
+
};
|
|
28
|
+
export declare const readRange: ({ repo, from, to }: ReadRangeOptions) => Promise<RangeReading>;
|
|
29
|
+
export {};
|