@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pablo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,81 @@
|
|
|
1
|
-
#
|
|
1
|
+
# visual-review
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Draws what a change did to the structure of a repo: the Rust crates and their dependencies, the web modules and files and their imports, and the web calls to API routes, before and after, with added, removed and modified parts coloured. A renamed crate, module or file is one node that says what it was called ("was domain"), and a test that moved to another file says where it went. Changed files outside those (tools, plans, CI, config) are listed below the graph with their patches.
|
|
4
|
+
|
|
5
|
+
It reads Rust (Cargo workspaces) and TypeScript/JavaScript, and links `fetch` calls to axum routes. More languages are planned.
|
|
6
|
+
|
|
7
|
+
## Before the first run
|
|
8
|
+
|
|
9
|
+
- Node `^22.20.0 || >=24.8.0`.
|
|
10
|
+
- macOS or Linux. 0.1 doesn't run on Windows.
|
|
11
|
+
- `git`.
|
|
12
|
+
- `cargo` on your `PATH` for Rust crates, which come from `cargo metadata`. Without it, the Rust stacks are skipped and the page says so.
|
|
13
|
+
- For PRs, the GitHub CLI, logged in with `gh auth login`, and a git remote that points at the PR's repository. Any name works, so a fork's `origin` beside an `upstream` is fine.
|
|
14
|
+
|
|
15
|
+
Nothing needs installing in the reviewed repo.
|
|
16
|
+
|
|
17
|
+
## Run it
|
|
18
|
+
|
|
19
|
+
In Claude Code, add the plugin once:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
/plugin marketplace add pablocm90/visual-review
|
|
23
|
+
/plugin install visual-review@pablocm90
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Then, from anywhere in the repo (as `/visual-review:visual-review` when another skill or command already takes the name):
|
|
27
|
+
|
|
28
|
+
| Command | Reviews |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `/visual-review 42` | PR #42, from where it branched off its base to its head |
|
|
31
|
+
| `/visual-review main..my-branch` | a range of commits |
|
|
32
|
+
| `/visual-review` | your uncommitted work against `HEAD` |
|
|
33
|
+
|
|
34
|
+
Claude builds the page, writes structural notes and flags onto it, and opens it in your browser. Selecting a crate, module, file or edge opens its note and patches in a panel beside the graph; drag the divider between them, or focus it and use the arrow keys, to give either side more room.
|
|
35
|
+
|
|
36
|
+
Without Claude, run the CLI with the same argument. It builds the page with no notes and prints its path:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npx @pablocm90/visual-review 42
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## When detection misreads a repo
|
|
43
|
+
|
|
44
|
+
Stacks are detected: a Cargo workspace at the repo root, and every `package.json` whose source root holds TS/JS files. A `visual-review.config.json` at the repo root overrides that. It is read from the reviewed head revision, or from your checkout when the head has none, and applies to both sides.
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"$schema": "https://unpkg.com/@pablocm90/visual-review@0.1/dist/config.schema.json",
|
|
49
|
+
"stacks": {
|
|
50
|
+
"web": { "boxes": ["modules/*"], "label": "Web app" },
|
|
51
|
+
"e2e": false,
|
|
52
|
+
"api": { "reader": "rust", "root": "backend" },
|
|
53
|
+
"gen": { "reader": "typescript", "root": "tools/gen", "sourceRoot": "lib" }
|
|
54
|
+
},
|
|
55
|
+
"linkers": {
|
|
56
|
+
"fetch-axum": { "from": ["web"], "to": ["api"] }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- Stacks are keyed by the id the page draws: a package's directory, a root package's name, or `rust`.
|
|
62
|
+
- `false` drops a stack, and `label` relabels it.
|
|
63
|
+
- `reader` with `root` adds a stack that detection missed (`rust` or `typescript`).
|
|
64
|
+
- TS stacks also take `sourceRoot`, and `boxes`: folder globs under the source root, each matched folder one box. The deepest match wins, and other files keep one box per first folder.
|
|
65
|
+
- `linkers.fetch-axum` draws the `fetch` calls of TS stacks to the routes of Rust crates that depend on axum, and lists calls no route matches. `from` and `to` limit it to those stacks, and `false` turns it off.
|
|
66
|
+
|
|
67
|
+
A key that names no detected stack, an unknown field, or a field the stack's reader doesn't take fails the review, naming the file and the field.
|
|
68
|
+
|
|
69
|
+
## Notes
|
|
70
|
+
|
|
71
|
+
A notes file is JSON in notes version 1, whose schema is [`notes.schema.json`](https://unpkg.com/@pablocm90/visual-review@0.1/dist/notes.schema.json) (give that URL as `$schema`). Beside `version`, `summary` holds a few `{ text, target? }` items on the change as a whole and `tests` a sentence on what the tests pin; `notes` maps a node id, or `<from> -> <to>` for an edge, to `{ text, flag? }`, where `flag` labels a structural concern.
|
|
72
|
+
|
|
73
|
+
`--notes <file>` with the same argument fills the notes into the page already written for it. A missing or unknown `version`, an unknown key, an id the graph doesn't have, or a changed part without a note fails it, naming the file.
|
|
74
|
+
|
|
75
|
+
## Where pages go
|
|
76
|
+
|
|
77
|
+
Pages go to `.reviews/` at the repo root, which git ignores. Each page is named by its range, for example `<sha>..<sha>.html` for a PR, with its data (`.json`) and Claude's notes (`.notes.json`) beside it. Only the 20 most recently written pages are kept. Pages aren't hosted: each person runs the review they want to see.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DanglingCall, Links } from './linker.ts';
|
|
2
|
+
export type WebCall = DanglingCall;
|
|
3
|
+
type CallingFile = {
|
|
4
|
+
readonly from: string;
|
|
5
|
+
readonly text: string;
|
|
6
|
+
};
|
|
7
|
+
export declare const webCalls: (files: ReadonlyArray<CallingFile>) => ReadonlyArray<WebCall>;
|
|
8
|
+
type Route = {
|
|
9
|
+
readonly to: string;
|
|
10
|
+
readonly path: string;
|
|
11
|
+
};
|
|
12
|
+
type CallGraphOptions = {
|
|
13
|
+
readonly calls: ReadonlyArray<WebCall>;
|
|
14
|
+
readonly routes: ReadonlyArray<Route>;
|
|
15
|
+
};
|
|
16
|
+
export declare const callGraph: ({ calls, routes }: CallGraphOptions) => Links;
|
|
17
|
+
export declare const apiRoutes: (text: string) => ReadonlyArray<string>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const FETCH_LITERAL = /fetch\(\s*(['"`])(.*?)\1/g;
|
|
2
|
+
const LEADING_ORIGIN = /^\$\{[^}]*\}/;
|
|
3
|
+
const ROUTE_LITERAL = /\.route\(\s*"([^"]*)"/g;
|
|
4
|
+
export const webCalls = (files) => files.flatMap(({ from, text }) => [...text.matchAll(FETCH_LITERAL)]
|
|
5
|
+
.map(([, , literal = '']) => literal.replace(LEADING_ORIGIN, ''))
|
|
6
|
+
.filter(path => !path.includes('${'))
|
|
7
|
+
.map(path => ({ from, path })));
|
|
8
|
+
export const callGraph = ({ calls, routes }) => {
|
|
9
|
+
const routed = calls.flatMap(({ from, path }) => routes.filter(route => route.path === path).map(({ to }) => ({ from, to, path })));
|
|
10
|
+
const pairs = new Map(routed.map(({ from, to }) => [JSON.stringify([from, to]), { from, to }]));
|
|
11
|
+
return {
|
|
12
|
+
edges: [...pairs.values()].map(({ from, to }) => ({
|
|
13
|
+
from,
|
|
14
|
+
to,
|
|
15
|
+
via: [
|
|
16
|
+
...new Set(routed.filter(other => other.from === from && other.to === to).map(({ path }) => path)),
|
|
17
|
+
],
|
|
18
|
+
})),
|
|
19
|
+
dangling: calls.filter(({ path }) => !routes.some(route => route.path === path)),
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export const apiRoutes = (text) => [...text.matchAll(ROUTE_LITERAL)].map(([, path = '']) => path);
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { parseArgs, promisify } from 'node:util';
|
|
6
|
+
import { diffGraphs, splitPatches } from './diff-graphs.js';
|
|
7
|
+
import { notesProblems, readNotes } from './notes.js';
|
|
8
|
+
import { onPath } from './on-path.js';
|
|
9
|
+
import { pruneReviews } from './prune-reviews.js';
|
|
10
|
+
import { readPullRequest } from './pull-request.js';
|
|
11
|
+
import { readRange } from './read-range.js';
|
|
12
|
+
import { notReadLine } from './skipped.js';
|
|
13
|
+
import { snapshotWorkingTree } from './working-tree.js';
|
|
14
|
+
const slot = (id, content) => `<script type="application/json" id="${id}">${content}</script>`;
|
|
15
|
+
const fillSlot = (html, id, value) => html.replace(new RegExp(slot(id, '[\\s\\S]*?')), () => slot(id, JSON.stringify(value).replaceAll('<', '\\u003c')));
|
|
16
|
+
const isDirectoryOrMissing = (path) => lstat(path).then(stats => stats.isDirectory(), () => true);
|
|
17
|
+
const parseRange = (range) => {
|
|
18
|
+
const match = /^(.+?)\.\.(.+)$/.exec(range);
|
|
19
|
+
if (!match?.[1] || !match[2])
|
|
20
|
+
throw new Error('Usage: visual-review [<from>..<to> | <pr number>]');
|
|
21
|
+
return { from: match[1], to: match[2] };
|
|
22
|
+
};
|
|
23
|
+
const requireOnPath = (command, installFrom) => {
|
|
24
|
+
if (!onPath(command))
|
|
25
|
+
throw new Error(`${command} not found on PATH: install it from ${installFrom}`);
|
|
26
|
+
};
|
|
27
|
+
const rangeOf = async (repo, argument) => {
|
|
28
|
+
if (argument === undefined) {
|
|
29
|
+
return { range: 'HEAD..worktree', from: 'HEAD', to: await snapshotWorkingTree(repo) };
|
|
30
|
+
}
|
|
31
|
+
if (/^\d+$/.test(argument)) {
|
|
32
|
+
requireOnPath('gh', 'https://cli.github.com');
|
|
33
|
+
const { from, to, ...pullRequest } = await readPullRequest({ repo, number: Number(argument) });
|
|
34
|
+
return { range: `${from}..${to}`, from, to, pullRequest };
|
|
35
|
+
}
|
|
36
|
+
return { range: argument, ...parseRange(argument) };
|
|
37
|
+
};
|
|
38
|
+
const viewerWith = async (review) => fillSlot(await readFile(join(import.meta.dirname, '..', 'dist', 'viewer.html'), 'utf8'), 'review-data', review);
|
|
39
|
+
const fillNotes = async ({ range, page, data, notesFile }) => {
|
|
40
|
+
const notes = await readNotes(notesFile);
|
|
41
|
+
const review = JSON.parse(await readFile(data, 'utf8').catch(() => {
|
|
42
|
+
throw new Error(`No page for ${range} yet: run visual-review without --notes first`);
|
|
43
|
+
}));
|
|
44
|
+
const problems = notesProblems({ diff: review.diff, notes });
|
|
45
|
+
if (problems.length > 0) {
|
|
46
|
+
throw new Error(`Notes in ${notesFile} don't match the graph:\n${problems.join('\n')}`);
|
|
47
|
+
}
|
|
48
|
+
await writeFile(page, fillSlot(await readFile(page, 'utf8'), 'review-notes', notes));
|
|
49
|
+
};
|
|
50
|
+
const writeReview = async (args) => {
|
|
51
|
+
const { positionals, values } = parseArgs({
|
|
52
|
+
args: [...args],
|
|
53
|
+
allowPositionals: true,
|
|
54
|
+
options: { notes: { type: 'string' } },
|
|
55
|
+
});
|
|
56
|
+
requireOnPath('git', 'https://git-scm.com/downloads');
|
|
57
|
+
const repo = (await promisify(execFile)('git', ['rev-parse', '--show-toplevel'])).stdout.trim();
|
|
58
|
+
const { range, from, to, ...pullRequest } = await rangeOf(repo, positionals[0]);
|
|
59
|
+
const directory = join(repo, '.reviews');
|
|
60
|
+
if (!(await isDirectoryOrMissing(directory)))
|
|
61
|
+
throw new Error(`${directory} is not a directory, so no page is written into it`);
|
|
62
|
+
const name = range.replaceAll('/', '_');
|
|
63
|
+
const page = join(directory, `${name}.html`);
|
|
64
|
+
const data = join(directory, `${name}.json`);
|
|
65
|
+
if (values.notes !== undefined) {
|
|
66
|
+
await fillNotes({ range, page, data, notesFile: values.notes });
|
|
67
|
+
return page;
|
|
68
|
+
}
|
|
69
|
+
const reading = await readRange({ repo, from, to });
|
|
70
|
+
reading.skipped.forEach(skip => {
|
|
71
|
+
console.error(notReadLine(skip));
|
|
72
|
+
});
|
|
73
|
+
const diff = diffGraphs(reading);
|
|
74
|
+
const { drawn, undrawn } = splitPatches({ diff, patches: reading.patches });
|
|
75
|
+
const review = {
|
|
76
|
+
range,
|
|
77
|
+
...pullRequest,
|
|
78
|
+
diff,
|
|
79
|
+
dangling: reading.danglingCalls,
|
|
80
|
+
patches: drawn,
|
|
81
|
+
undrawn,
|
|
82
|
+
tests: reading.tests,
|
|
83
|
+
...(reading.skipped.length === 0 ? {} : { skipped: reading.skipped }),
|
|
84
|
+
};
|
|
85
|
+
await mkdir(directory, { recursive: true });
|
|
86
|
+
await writeFile(join(directory, '.gitignore'), '*\n');
|
|
87
|
+
await writeFile(data, JSON.stringify(review));
|
|
88
|
+
await writeFile(page, await viewerWith(review));
|
|
89
|
+
await pruneReviews({ directory, keep: 20 });
|
|
90
|
+
return page;
|
|
91
|
+
};
|
|
92
|
+
writeReview(process.argv.slice(2)).then(page => console.log(page), (error) => {
|
|
93
|
+
console.error(error.message);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
});
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { GraphNode } from './graph.ts';
|
|
3
|
+
export declare const CONFIG_FILE = "visual-review.config.json";
|
|
4
|
+
export declare const READERS: readonly ['rust', 'typescript'];
|
|
5
|
+
export type ReaderName = (typeof READERS)[number];
|
|
6
|
+
export declare const LINKERS: readonly ['fetch-axum'];
|
|
7
|
+
export type LinkerName = (typeof LINKERS)[number];
|
|
8
|
+
export declare const folderOf: (path: string) => string;
|
|
9
|
+
declare const StackSchema: z.ZodObject<{
|
|
10
|
+
reader: z.ZodOptional<z.ZodEnum<{
|
|
11
|
+
rust: "rust";
|
|
12
|
+
typescript: "typescript";
|
|
13
|
+
}>>;
|
|
14
|
+
root: z.ZodOptional<z.ZodString>;
|
|
15
|
+
label: z.ZodOptional<z.ZodString>;
|
|
16
|
+
sourceRoot: z.ZodOptional<z.ZodString>;
|
|
17
|
+
boxes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
18
|
+
}, z.core.$strict>;
|
|
19
|
+
declare const ConfigSchema: z.ZodObject<{
|
|
20
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
21
|
+
stacks: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
|
|
22
|
+
reader: z.ZodOptional<z.ZodEnum<{
|
|
23
|
+
rust: "rust";
|
|
24
|
+
typescript: "typescript";
|
|
25
|
+
}>>;
|
|
26
|
+
root: z.ZodOptional<z.ZodString>;
|
|
27
|
+
label: z.ZodOptional<z.ZodString>;
|
|
28
|
+
sourceRoot: z.ZodOptional<z.ZodString>;
|
|
29
|
+
boxes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
30
|
+
}, z.core.$strict>]>>>;
|
|
31
|
+
linkers: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
|
|
32
|
+
"fetch-axum": "fetch-axum";
|
|
33
|
+
}> & z.core.$partial, z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
|
|
34
|
+
from: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
35
|
+
to: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
36
|
+
}, z.core.$strict>]>>>;
|
|
37
|
+
}, z.core.$strict>;
|
|
38
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
39
|
+
export declare const configJsonSchema: () => z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
|
|
40
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
41
|
+
stacks: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
|
|
42
|
+
reader: z.ZodOptional<z.ZodEnum<{
|
|
43
|
+
rust: "rust";
|
|
44
|
+
typescript: "typescript";
|
|
45
|
+
}>>;
|
|
46
|
+
root: z.ZodOptional<z.ZodString>;
|
|
47
|
+
label: z.ZodOptional<z.ZodString>;
|
|
48
|
+
sourceRoot: z.ZodOptional<z.ZodString>;
|
|
49
|
+
boxes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
50
|
+
}, z.core.$strict>]>>>;
|
|
51
|
+
linkers: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
|
|
52
|
+
"fetch-axum": "fetch-axum";
|
|
53
|
+
}> & z.core.$partial, z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
|
|
54
|
+
from: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
55
|
+
to: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
56
|
+
}, z.core.$strict>]>>>;
|
|
57
|
+
}, z.core.$strict>>;
|
|
58
|
+
export declare const stackSettings: (config: Config, stack: string) => z.infer<typeof StackSchema>;
|
|
59
|
+
type AddedStack = {
|
|
60
|
+
readonly stack: string;
|
|
61
|
+
readonly root: string;
|
|
62
|
+
};
|
|
63
|
+
type AddedStacksOptions = {
|
|
64
|
+
readonly config: Config;
|
|
65
|
+
readonly reader: ReaderName;
|
|
66
|
+
readonly detected: ReadonlyMap<string, string>;
|
|
67
|
+
readonly taken: ReadonlySet<string>;
|
|
68
|
+
};
|
|
69
|
+
export declare const addedStacks: ({ config, reader, detected, taken, }: AddedStacksOptions) => ReadonlyArray<AddedStack>;
|
|
70
|
+
export declare const configuredNodes: (config: Config, nodes: ReadonlyArray<GraphNode>) => ReadonlyArray<GraphNode>;
|
|
71
|
+
type DrawnStack = {
|
|
72
|
+
readonly stack: string;
|
|
73
|
+
readonly reader: {
|
|
74
|
+
readonly name: ReaderName;
|
|
75
|
+
readonly stackFields: ReadonlyArray<string>;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
type LinkerReaders = {
|
|
79
|
+
readonly name: LinkerName;
|
|
80
|
+
readonly from: ReaderName;
|
|
81
|
+
readonly to: ReaderName;
|
|
82
|
+
};
|
|
83
|
+
export declare const checkConfig: (config: Config, stacks: ReadonlyArray<DrawnStack>, linkers: ReadonlyArray<LinkerReaders>) => void;
|
|
84
|
+
export declare const parseConfig: (text: string) => Config;
|
|
85
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { normalize } from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export const CONFIG_FILE = 'visual-review.config.json';
|
|
4
|
+
export const READERS = ['rust', 'typescript'];
|
|
5
|
+
export const LINKERS = ['fetch-axum'];
|
|
6
|
+
export const folderOf = (path) => normalize(path).replace(/\/$/, '');
|
|
7
|
+
const folderInside = (parent) => z.string().refine(path => !`${folderOf(path)}/`.startsWith('../'), {
|
|
8
|
+
message: `must be a folder inside ${parent}`,
|
|
9
|
+
});
|
|
10
|
+
const StackSchema = z
|
|
11
|
+
.strictObject({
|
|
12
|
+
reader: z
|
|
13
|
+
.enum(READERS)
|
|
14
|
+
.optional()
|
|
15
|
+
.describe('With root, adds a stack detection missed, read by this reader'),
|
|
16
|
+
root: folderInside('the repo')
|
|
17
|
+
.optional()
|
|
18
|
+
.describe('With reader, the folder of the added stack, relative to the repo'),
|
|
19
|
+
label: z.string().optional().describe("The stack's label on the page; its id stays"),
|
|
20
|
+
sourceRoot: folderInside("the stack's root")
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("TS stacks: the folder under the stack's root that holds its sources"),
|
|
23
|
+
boxes: z
|
|
24
|
+
.array(z.string())
|
|
25
|
+
.optional()
|
|
26
|
+
.describe('TS stacks: folder globs under the source root, each matched folder a box; other files box by first folder'),
|
|
27
|
+
})
|
|
28
|
+
.refine(({ reader, root }) => (reader === undefined) === (root === undefined), {
|
|
29
|
+
message: 'give both reader and root, or neither',
|
|
30
|
+
});
|
|
31
|
+
const LinkerSchema = z.strictObject({
|
|
32
|
+
from: z
|
|
33
|
+
.array(z.string())
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("The stacks it reads from, by drawn id; every stack of its from reader's by default"),
|
|
36
|
+
to: z
|
|
37
|
+
.array(z.string())
|
|
38
|
+
.optional()
|
|
39
|
+
.describe("The stacks it links to, by drawn id; every stack of its to reader's by default"),
|
|
40
|
+
});
|
|
41
|
+
const ConfigSchema = z.strictObject({
|
|
42
|
+
$schema: z.string().optional(),
|
|
43
|
+
stacks: z
|
|
44
|
+
.record(z.string(), z.union([z.literal(false), StackSchema], {
|
|
45
|
+
error: 'Invalid input: expected false or an object',
|
|
46
|
+
}))
|
|
47
|
+
.default({})
|
|
48
|
+
.describe('Stacks by the id the page draws; false drops one'),
|
|
49
|
+
linkers: z
|
|
50
|
+
.partialRecord(z.enum(LINKERS), z.union([z.literal(false), LinkerSchema], {
|
|
51
|
+
error: 'Invalid input: expected false or an object',
|
|
52
|
+
}))
|
|
53
|
+
.default({})
|
|
54
|
+
.describe('Linkers by name; false turns one off'),
|
|
55
|
+
});
|
|
56
|
+
export const configJsonSchema = () => z.toJSONSchema(ConfigSchema, { io: 'input' });
|
|
57
|
+
export const stackSettings = (config, stack) => config.stacks[stack] || {};
|
|
58
|
+
export const addedStacks = ({ config, reader, detected, taken, }) => {
|
|
59
|
+
const added = Object.entries(config.stacks).flatMap(([stack, settings]) => settings && settings.reader === reader && settings.root !== undefined
|
|
60
|
+
? [{ stack, root: settings.root }]
|
|
61
|
+
: []);
|
|
62
|
+
const problems = added.flatMap(({ stack, root }) => {
|
|
63
|
+
const other = detected.get(folderOf(root));
|
|
64
|
+
const detectedAt = [...detected].find(([, id]) => id === stack)?.[0];
|
|
65
|
+
return [
|
|
66
|
+
...(other === undefined
|
|
67
|
+
? []
|
|
68
|
+
: [
|
|
69
|
+
`${CONFIG_FILE}: stacks.${stack}.root: ${root} is detected as stack ${other}; set stacks.${other} instead`,
|
|
70
|
+
]),
|
|
71
|
+
...(detectedAt === undefined
|
|
72
|
+
? []
|
|
73
|
+
: [
|
|
74
|
+
`${CONFIG_FILE}: stacks.${stack} is detected at ${detectedAt}; add the stack under another key, or drop its reader and root`,
|
|
75
|
+
]),
|
|
76
|
+
...(taken.has(stack)
|
|
77
|
+
? [
|
|
78
|
+
`${CONFIG_FILE}: stacks.${stack} is drawn by another reader; add the stack under another key, or drop its reader and root`,
|
|
79
|
+
]
|
|
80
|
+
: []),
|
|
81
|
+
];
|
|
82
|
+
});
|
|
83
|
+
if (problems.length > 0)
|
|
84
|
+
throw new Error(problems.join('\n'));
|
|
85
|
+
return added;
|
|
86
|
+
};
|
|
87
|
+
export const configuredNodes = (config, nodes) => nodes.map(node => ({ ...node, label: stackSettings(config, node.id).label ?? node.label }));
|
|
88
|
+
const OWN_FIELDS = ['reader', 'root', 'label'];
|
|
89
|
+
const undetectedIn = (config, stacks) => Object.keys(config.stacks)
|
|
90
|
+
.filter(stack => !stacks.some(drawn => drawn.stack === stack))
|
|
91
|
+
.map(stack => {
|
|
92
|
+
const { reader, root } = stackSettings(config, stack);
|
|
93
|
+
return reader === undefined
|
|
94
|
+
? `${CONFIG_FILE}: stacks.${stack} names no detected stack; give it reader and root to add one`
|
|
95
|
+
: `${CONFIG_FILE}: stacks.${stack}.root: ${root} holds no ${reader} stack on either side`;
|
|
96
|
+
});
|
|
97
|
+
const untakenIn = (config, stacks) => [
|
|
98
|
+
...new Set(stacks.flatMap(({ stack, reader }) => Object.keys(stackSettings(config, stack))
|
|
99
|
+
.filter(field => !OWN_FIELDS.includes(field) && !reader.stackFields.includes(field))
|
|
100
|
+
.map(field => `${CONFIG_FILE}: stacks.${stack}.${field} is not taken by the ${reader.name} reader`))),
|
|
101
|
+
];
|
|
102
|
+
const unlinkableIn = (config, stacks, linkers) => linkers.flatMap(linker => {
|
|
103
|
+
const settings = config.linkers[linker.name] || {};
|
|
104
|
+
return ['from', 'to'].flatMap(end => (settings[end] ?? []).flatMap(stack => {
|
|
105
|
+
if (config.stacks[stack] === false)
|
|
106
|
+
return [
|
|
107
|
+
`${CONFIG_FILE}: linkers.${linker.name}.${end}: ${stack} is dropped by stacks.${stack}`,
|
|
108
|
+
];
|
|
109
|
+
return stacks.some(drawn => drawn.stack === stack && drawn.reader.name === linker[end])
|
|
110
|
+
? []
|
|
111
|
+
: [
|
|
112
|
+
`${CONFIG_FILE}: linkers.${linker.name}.${end}: ${stack} names no ${linker[end]} stack on either side`,
|
|
113
|
+
];
|
|
114
|
+
}));
|
|
115
|
+
});
|
|
116
|
+
export const checkConfig = (config, stacks, linkers) => {
|
|
117
|
+
const problems = [
|
|
118
|
+
...undetectedIn(config, stacks),
|
|
119
|
+
...untakenIn(config, stacks),
|
|
120
|
+
...unlinkableIn(config, stacks, linkers),
|
|
121
|
+
];
|
|
122
|
+
if (problems.length > 0)
|
|
123
|
+
throw new Error(problems.join('\n'));
|
|
124
|
+
};
|
|
125
|
+
const jsonIn = (text) => {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(text.replace(/^\uFEFF/, ''));
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throw new Error(`${CONFIG_FILE}: ${String(error)}`);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const withinUnions = (issue) => {
|
|
134
|
+
if (issue.code !== 'invalid_union')
|
|
135
|
+
return [issue];
|
|
136
|
+
const within = issue.errors
|
|
137
|
+
.flat()
|
|
138
|
+
.filter(({ path }) => path.length > 0)
|
|
139
|
+
.map(inner => ({ ...inner, path: [...issue.path, ...inner.path] }));
|
|
140
|
+
return within.length > 0 ? within : [issue];
|
|
141
|
+
};
|
|
142
|
+
export const parseConfig = (text) => {
|
|
143
|
+
const result = ConfigSchema.safeParse(jsonIn(text));
|
|
144
|
+
if (result.success)
|
|
145
|
+
return result.data;
|
|
146
|
+
throw new Error(result.error.issues
|
|
147
|
+
.flatMap(withinUnions)
|
|
148
|
+
.map(({ path, message }) => [CONFIG_FILE, ...(path.length === 0 ? [] : [path.join('.')]), message].join(': '))
|
|
149
|
+
.join('\n'));
|
|
150
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"$schema": {
|
|
6
|
+
"type": "string"
|
|
7
|
+
},
|
|
8
|
+
"stacks": {
|
|
9
|
+
"default": {},
|
|
10
|
+
"description": "Stacks by the id the page draws; false drops one",
|
|
11
|
+
"type": "object",
|
|
12
|
+
"propertyNames": {
|
|
13
|
+
"type": "string"
|
|
14
|
+
},
|
|
15
|
+
"additionalProperties": {
|
|
16
|
+
"anyOf": [
|
|
17
|
+
{
|
|
18
|
+
"type": "boolean",
|
|
19
|
+
"const": false
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"reader": {
|
|
25
|
+
"description": "With root, adds a stack detection missed, read by this reader",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"enum": [
|
|
28
|
+
"rust",
|
|
29
|
+
"typescript"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"root": {
|
|
33
|
+
"description": "With reader, the folder of the added stack, relative to the repo",
|
|
34
|
+
"type": "string"
|
|
35
|
+
},
|
|
36
|
+
"label": {
|
|
37
|
+
"description": "The stack's label on the page; its id stays",
|
|
38
|
+
"type": "string"
|
|
39
|
+
},
|
|
40
|
+
"sourceRoot": {
|
|
41
|
+
"description": "TS stacks: the folder under the stack's root that holds its sources",
|
|
42
|
+
"type": "string"
|
|
43
|
+
},
|
|
44
|
+
"boxes": {
|
|
45
|
+
"description": "TS stacks: folder globs under the source root, each matched folder a box; other files box by first folder",
|
|
46
|
+
"type": "array",
|
|
47
|
+
"items": {
|
|
48
|
+
"type": "string"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"additionalProperties": false
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"linkers": {
|
|
58
|
+
"default": {},
|
|
59
|
+
"description": "Linkers by name; false turns one off",
|
|
60
|
+
"type": "object",
|
|
61
|
+
"propertyNames": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"enum": [
|
|
64
|
+
"fetch-axum"
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
"additionalProperties": {
|
|
68
|
+
"anyOf": [
|
|
69
|
+
{
|
|
70
|
+
"type": "boolean",
|
|
71
|
+
"const": false
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"type": "object",
|
|
75
|
+
"properties": {
|
|
76
|
+
"from": {
|
|
77
|
+
"description": "The stacks it reads from, by drawn id; every stack of its from reader's by default",
|
|
78
|
+
"type": "array",
|
|
79
|
+
"items": {
|
|
80
|
+
"type": "string"
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"to": {
|
|
84
|
+
"description": "The stacks it links to, by drawn id; every stack of its to reader's by default",
|
|
85
|
+
"type": "array",
|
|
86
|
+
"items": {
|
|
87
|
+
"type": "string"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"additionalProperties": false
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"additionalProperties": false
|
|
98
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Graph } from './graph.ts';
|
|
2
|
+
export declare const crateId: (name: string) => string;
|
|
3
|
+
export declare const crateGraph: (metadataJson: string) => Graph;
|
|
4
|
+
export declare const crateRoots: (metadataJson: string) => {
|
|
5
|
+
crate: string;
|
|
6
|
+
directory: string;
|
|
7
|
+
roots: string[];
|
|
8
|
+
}[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { dirname, relative } from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const CargoMetadataSchema = z.object({
|
|
4
|
+
workspace_root: z.string(),
|
|
5
|
+
packages: z.array(z.object({
|
|
6
|
+
name: z.string(),
|
|
7
|
+
manifest_path: z.string(),
|
|
8
|
+
dependencies: z.array(z.object({ path: z.string().optional() })),
|
|
9
|
+
targets: z.array(z.object({ kind: z.array(z.string()), src_path: z.string() })),
|
|
10
|
+
})),
|
|
11
|
+
});
|
|
12
|
+
const rustStack = { id: 'rust', kind: 'stack', label: 'rust', path: '' };
|
|
13
|
+
export const crateId = (name) => `${rustStack.id}/crate/${name}`;
|
|
14
|
+
export const crateGraph = (metadataJson) => {
|
|
15
|
+
const metadata = CargoMetadataSchema.parse(JSON.parse(metadataJson));
|
|
16
|
+
const crates = metadata.packages.map(pkg => ({
|
|
17
|
+
...pkg,
|
|
18
|
+
directory: dirname(pkg.manifest_path),
|
|
19
|
+
}));
|
|
20
|
+
const idByDirectory = new Map(crates.map(pkg => [pkg.directory, crateId(pkg.name)]));
|
|
21
|
+
return {
|
|
22
|
+
nodes: [
|
|
23
|
+
rustStack,
|
|
24
|
+
...crates.map((pkg) => ({
|
|
25
|
+
id: crateId(pkg.name),
|
|
26
|
+
kind: 'package',
|
|
27
|
+
label: pkg.name,
|
|
28
|
+
parent: rustStack.id,
|
|
29
|
+
path: relative(metadata.workspace_root, pkg.directory),
|
|
30
|
+
})),
|
|
31
|
+
],
|
|
32
|
+
edges: crates.flatMap(pkg => {
|
|
33
|
+
const targets = new Set(pkg.dependencies.flatMap(dependency => {
|
|
34
|
+
const to = idByDirectory.get(dependency.path);
|
|
35
|
+
return to === undefined ? [] : [to];
|
|
36
|
+
}));
|
|
37
|
+
return [...targets].map(to => ({ from: crateId(pkg.name), to }));
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
const NOT_A_ROOT = new Set(['test', 'bench', 'example', 'custom-build']);
|
|
42
|
+
export const crateRoots = (metadataJson) => {
|
|
43
|
+
const metadata = CargoMetadataSchema.parse(JSON.parse(metadataJson));
|
|
44
|
+
return metadata.packages.map(pkg => ({
|
|
45
|
+
crate: pkg.name,
|
|
46
|
+
directory: relative(metadata.workspace_root, dirname(pkg.manifest_path)),
|
|
47
|
+
roots: pkg.targets
|
|
48
|
+
.filter(target => !target.kind.some(kind => NOT_A_ROOT.has(kind)))
|
|
49
|
+
.map(target => relative(metadata.workspace_root, target.src_path)),
|
|
50
|
+
}));
|
|
51
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Graph } from './graph.ts';
|
|
2
|
+
type CrateModulesOptions = {
|
|
3
|
+
readonly crate: string;
|
|
4
|
+
readonly roots: ReadonlyArray<string>;
|
|
5
|
+
readonly sources: Readonly<Record<string, string>>;
|
|
6
|
+
};
|
|
7
|
+
export declare const crateModules: ({ crate, roots, sources }: CrateModulesOptions) => Graph;
|
|
8
|
+
export {};
|