@venn-lang/cli 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 +284 -0
- package/dist/bin/venn.mjs +16 -0
- package/dist/cli.mjs +56378 -0
- package/dist/index.d.mts +98 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +916 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +67 -0
- package/src/bin/venn.ts +14 -0
- package/src/cli.ts +299 -0
- package/src/commands/build.ts +97 -0
- package/src/commands/check.ts +143 -0
- package/src/commands/deps.ts +228 -0
- package/src/commands/fmt.ts +55 -0
- package/src/commands/index.ts +2 -0
- package/src/commands/inside-workspace.ts +20 -0
- package/src/commands/list.ts +53 -0
- package/src/commands/new.ts +66 -0
- package/src/commands/run.ts +149 -0
- package/src/commands/script.ts +127 -0
- package/src/commands/upgrade.ts +86 -0
- package/src/commands/verify-plugin.ts +35 -0
- package/src/index.ts +7 -0
- package/src/manifest/index.ts +2 -0
- package/src/manifest/load-env.ts +70 -0
- package/src/manifest/load-manifest.ts +42 -0
- package/src/project/command-targets.ts +53 -0
- package/src/project/index.ts +9 -0
- package/src/project/resolve-targets.ts +70 -0
- package/src/project/select-packages.ts +37 -0
- package/src/project/targets-or-exit.ts +28 -0
- package/src/reporters/colors.ts +17 -0
- package/src/reporters/dot-sink.ts +18 -0
- package/src/reporters/error-line.ts +14 -0
- package/src/reporters/index.ts +9 -0
- package/src/reporters/junit-sink.ts +39 -0
- package/src/reporters/ndjson-stdout.ts +13 -0
- package/src/reporters/pick-reporter.ts +28 -0
- package/src/reporters/pretty/diff-lines.ts +53 -0
- package/src/reporters/pretty/index.ts +1 -0
- package/src/reporters/pretty/pretty-reporter.ts +122 -0
- package/src/reporters/pretty/pretty.types.ts +29 -0
- package/src/reporters/pretty/render.ts +88 -0
- package/src/reporters/problem-reporter.ts +13 -0
- package/src/reporters/problem-sink.ts +20 -0
- package/src/reporters/reporter.types.ts +21 -0
- package/src/run/collect-files.ts +43 -0
- package/src/run/ending.types.ts +17 -0
- package/src/run/exit-code.ts +15 -0
- package/src/run/node-io.ts +27 -0
- package/src/run/npm-loader.ts +41 -0
- package/src/run/package-types.ts +66 -0
- package/src/run/run-file.ts +106 -0
- package/src/run/should-leave.ts +11 -0
- package/src/run/step-titles.ts +61 -0
- package/src/shutdown/create-leave.ts +42 -0
- package/src/shutdown/create-shutdown.ts +34 -0
- package/src/shutdown/index.ts +7 -0
- package/src/shutdown/install-exit-hook.ts +21 -0
- package/src/shutdown/install-fault-hooks.ts +30 -0
- package/src/shutdown/install-hooks.ts +36 -0
- package/src/shutdown/install-signal-hooks.ts +26 -0
- package/src/shutdown/shutdown.types.ts +20 -0
- package/src/title/index.ts +2 -0
- package/src/title/program-title.ts +15 -0
- package/src/title/set-program-title.ts +15 -0
- package/src/upgrade/index.ts +6 -0
- package/src/upgrade/install-site.ts +70 -0
- package/src/upgrade/latest-version.ts +50 -0
- package/src/upgrade/upgrade-command.ts +32 -0
- package/src/upgrade/upgrade-plan.ts +36 -0
- package/src/upgrade/upgrade.types.ts +17 -0
- package/src/upgrade/version.ts +21 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createNodeFs } from "@venn-lang/contracts/node";
|
|
2
|
+
import { findProject, normalise, type Package, type Project } from "@venn-lang/project";
|
|
3
|
+
|
|
4
|
+
export interface Selection {
|
|
5
|
+
project: Project;
|
|
6
|
+
/** The packages this command acts on. */
|
|
7
|
+
packages: readonly Package[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SelectArgs {
|
|
11
|
+
/** Where the command was run, or the path it was given. */
|
|
12
|
+
from: string;
|
|
13
|
+
/** `-p api`: one member by name. Absent means the workspace's default set. */
|
|
14
|
+
packageName?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Which packages a command with no file argument acts on.
|
|
19
|
+
*
|
|
20
|
+
* A workspace answers with its `default-members`, or with all of them when it
|
|
21
|
+
* named none. That is the rule Cargo follows, and the reason `venn test` at the
|
|
22
|
+
* root of a monorepo means "the suite", not "nothing here".
|
|
23
|
+
*/
|
|
24
|
+
export async function selectPackages(args: SelectArgs): Promise<Selection | undefined> {
|
|
25
|
+
const { project } = await findProject({ fs: createNodeFs(), from: normalise(args.from) });
|
|
26
|
+
if (!project) return undefined;
|
|
27
|
+
if (!args.packageName) return { project, packages: project.defaultPackages };
|
|
28
|
+
const wanted = project.packages.filter((one) => one.manifest.name === args.packageName);
|
|
29
|
+
return { project, packages: wanted };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What to say when `-p` named something the workspace does not hold. */
|
|
33
|
+
export function unknownPackage(project: Project, name: string): string {
|
|
34
|
+
const known = project.packages.map((one) => one.manifest.name).filter(Boolean);
|
|
35
|
+
const list = known.length > 0 ? known.join(", ") : "none";
|
|
36
|
+
return `VN2103 · no package named ${name} in this workspace.\n it holds: ${list}\n`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type CommandKind, resolveTargets } from "./resolve-targets.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The paths a command should act on, or nothing, having already written the
|
|
5
|
+
* reason to stderr and set the exit code.
|
|
6
|
+
*
|
|
7
|
+
* Saying why is the point: a bare `venn test` in a folder with no project must
|
|
8
|
+
* not be indistinguishable from one that found nothing to do.
|
|
9
|
+
*/
|
|
10
|
+
export async function targetsOrExit(args: {
|
|
11
|
+
kind: CommandKind;
|
|
12
|
+
target?: string;
|
|
13
|
+
packageName?: string;
|
|
14
|
+
binName?: string;
|
|
15
|
+
}): Promise<readonly string[] | undefined> {
|
|
16
|
+
const found = await resolveTargets({ ...args, cwd: process.cwd() });
|
|
17
|
+
if (found.problem) {
|
|
18
|
+
process.stderr.write(found.problem);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return found.paths;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The worst outcome of running one command over several paths. */
|
|
26
|
+
export function worst(codes: readonly number[]): number {
|
|
27
|
+
return codes.reduce((so, far) => Math.max(so, far), 0);
|
|
28
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Colour only when a human is watching: never when piped, and never against
|
|
2
|
+
// NO_COLOR (https://no-color.org) or a dumb terminal.
|
|
3
|
+
const ESC = String.fromCharCode(27);
|
|
4
|
+
const ENABLED =
|
|
5
|
+
Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
6
|
+
|
|
7
|
+
function style(open: number, close: number): (text: string) => string {
|
|
8
|
+
return (text) => (ENABLED ? `${ESC}[${open}m${text}${ESC}[${close}m` : text);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const bold: (text: string) => string = style(1, 22);
|
|
12
|
+
export const dim: (text: string) => string = style(2, 22);
|
|
13
|
+
export const red: (text: string) => string = style(31, 39);
|
|
14
|
+
export const green: (text: string) => string = style(32, 39);
|
|
15
|
+
export const yellow: (text: string) => string = style(33, 39);
|
|
16
|
+
export const cyan: (text: string) => string = style(36, 39);
|
|
17
|
+
export const inverse: (text: string) => string = style(7, 27);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Envelope } from "@venn-lang/core";
|
|
2
|
+
import type { EventSink } from "@venn-lang/runtime";
|
|
3
|
+
|
|
4
|
+
/** Terminal reporter: one char per assertion, a summary line at the end. */
|
|
5
|
+
export function createDotSink(): EventSink {
|
|
6
|
+
return { emit: (envelope) => write(envelope) };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function write(envelope: Envelope): void {
|
|
10
|
+
if (envelope.kind === "expect.passed") process.stdout.write(".");
|
|
11
|
+
else if (envelope.kind === "expect.failed") process.stdout.write("F");
|
|
12
|
+
else if (envelope.kind === "run.finished") writeSummary(envelope);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeSummary(envelope: Envelope): void {
|
|
16
|
+
const data = envelope.data as { passed: number; failed: number; durationMs: number };
|
|
17
|
+
process.stdout.write(`\n${data.passed} passed, ${data.failed} failed (${data.durationMs}ms)\n`);
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { VennError } from "@venn-lang/contracts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One line for one failure, however it reached us.
|
|
5
|
+
*
|
|
6
|
+
* A `VennError` already knows how it wants to read, and leads with its code so
|
|
7
|
+
* the failure is googlable; anything else is a stray from below the language and
|
|
8
|
+
* gets its message, never `[object Object]`.
|
|
9
|
+
*/
|
|
10
|
+
export function errorLine(error: unknown): string {
|
|
11
|
+
if (error instanceof VennError) return `${error.code} ${error.message}`;
|
|
12
|
+
const message = (error as { message?: unknown } | undefined)?.message;
|
|
13
|
+
return typeof message === "string" && message !== "" ? message : String(error);
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { createDotSink } from "./dot-sink.js";
|
|
2
|
+
export { errorLine } from "./error-line.js";
|
|
3
|
+
export { createJunitSink } from "./junit-sink.js";
|
|
4
|
+
export { createStdoutSink } from "./ndjson-stdout.js";
|
|
5
|
+
export { pickReporter } from "./pick-reporter.js";
|
|
6
|
+
export { createPrettyReporter } from "./pretty/index.js";
|
|
7
|
+
export { reportProblems } from "./problem-reporter.js";
|
|
8
|
+
export { createProblemSink } from "./problem-sink.js";
|
|
9
|
+
export type { Reporter, RunTotals } from "./reporter.types.js";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { EventSink } from "@venn-lang/runtime";
|
|
2
|
+
|
|
3
|
+
interface FlowResult {
|
|
4
|
+
title: string;
|
|
5
|
+
status: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const XML_ESCAPES: Record<string, string> = {
|
|
9
|
+
"<": "<",
|
|
10
|
+
">": ">",
|
|
11
|
+
"&": "&",
|
|
12
|
+
'"': """,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Accumulate flow results and emit a JUnit XML document on run.finished. */
|
|
16
|
+
export function createJunitSink(args: { write: (xml: string) => void }): EventSink {
|
|
17
|
+
const flows: FlowResult[] = [];
|
|
18
|
+
return {
|
|
19
|
+
emit: (envelope) => {
|
|
20
|
+
if (envelope.kind === "flow.finished") flows.push(envelope.data as FlowResult);
|
|
21
|
+
else if (envelope.kind === "run.finished") args.write(toJunit(flows));
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toJunit(flows: readonly FlowResult[]): string {
|
|
27
|
+
const failures = flows.filter((flow) => flow.status === "failed").length;
|
|
28
|
+
const cases = flows.map(toCase).join("\n");
|
|
29
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite tests="${flows.length}" failures="${failures}">\n${cases}\n</testsuite>\n`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toCase(flow: FlowResult): string {
|
|
33
|
+
const body = flow.status === "failed" ? "<failure/>" : "";
|
|
34
|
+
return ` <testcase name="${escapeXml(flow.title)}">${body}</testcase>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function escapeXml(text: string): string {
|
|
38
|
+
return text.replace(/[<>&"]/g, (char) => XML_ESCAPES[char] ?? char);
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createNdjsonSink, type EventSink } from "@venn-lang/runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An event sink that writes each envelope to stdout as one line of NDJSON: the
|
|
5
|
+
* stream a script or a CI job parses.
|
|
6
|
+
*/
|
|
7
|
+
export function createStdoutSink(): EventSink {
|
|
8
|
+
return createNdjsonSink({
|
|
9
|
+
write: (line) => {
|
|
10
|
+
process.stdout.write(line);
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { EventSink } from "@venn-lang/runtime";
|
|
2
|
+
import { createDotSink } from "./dot-sink.js";
|
|
3
|
+
import { createJunitSink } from "./junit-sink.js";
|
|
4
|
+
import { createStdoutSink } from "./ndjson-stdout.js";
|
|
5
|
+
import { createPrettyReporter } from "./pretty/index.js";
|
|
6
|
+
import type { Reporter } from "./reporter.types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Choose a reporter by name. With no `--reporter`, a terminal gets the readable
|
|
10
|
+
* tree and anything piped gets NDJSON, so scripts and CI keep the
|
|
11
|
+
* machine-readable stream they can parse.
|
|
12
|
+
*/
|
|
13
|
+
export function pickReporter(name: string | undefined): Reporter {
|
|
14
|
+
if (name === "pretty") return createPrettyReporter();
|
|
15
|
+
if (name === "ndjson") return passive(createStdoutSink());
|
|
16
|
+
if (name === "dot") return passive(createDotSink());
|
|
17
|
+
if (name === "junit") return passive(junit());
|
|
18
|
+
return process.stdout.isTTY ? createPrettyReporter() : passive(createStdoutSink());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Machine formats say everything they need through the event stream itself.
|
|
22
|
+
function passive(sink: EventSink): Reporter {
|
|
23
|
+
return { sink, beginFile: () => {}, finish: () => {} };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function junit(): EventSink {
|
|
27
|
+
return createJunitSink({ write: (xml) => process.stdout.write(xml) });
|
|
28
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type Diff, type DiffEntry, formatValue } from "@venn-lang/core";
|
|
2
|
+
import { bold, dim, green, red } from "../colors.js";
|
|
3
|
+
|
|
4
|
+
const INDENT = " ";
|
|
5
|
+
const COLUMN = "expected".length;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The body of a failure: the two sides as the kernel compared them, field by
|
|
9
|
+
* field (§16). The title says what went wrong in one line; this says where.
|
|
10
|
+
*/
|
|
11
|
+
export function diffLines(diff: Diff): string[] {
|
|
12
|
+
if (diff.kind === "fields") return fieldLines(diff.label, diff.entries);
|
|
13
|
+
if (diff.kind === "json") {
|
|
14
|
+
return [head(diff.path), ...pairLines(formatValue(diff.expected), formatValue(diff.actual))];
|
|
15
|
+
}
|
|
16
|
+
return pairLines(diff.expected, diff.actual);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fieldLines(label: string, entries: readonly DiffEntry[]): string[] {
|
|
20
|
+
const width = Math.max(0, ...entries.map((entry) => entry.path.length));
|
|
21
|
+
const lines = [head(label)];
|
|
22
|
+
for (const [index, entry] of entries.entries()) {
|
|
23
|
+
lines.push(...entryLines({ entry, width, last: index === entries.length - 1 }));
|
|
24
|
+
}
|
|
25
|
+
return lines;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One field: a single line when both sides agree, two when they do not. */
|
|
29
|
+
function entryLines(args: { entry: DiffEntry; width: number; last: boolean }): string[] {
|
|
30
|
+
const { entry, last, width } = args;
|
|
31
|
+
const branch = `${INDENT}${dim(last ? "└" : "├")} ${entry.path.padEnd(width)}`;
|
|
32
|
+
if (entry.same) return [`${branch} ${column("same")} ${dim(entry.expected)}`];
|
|
33
|
+
const gutter = `${INDENT}${dim(last ? " " : "│")} ${" ".repeat(width)}`;
|
|
34
|
+
return [
|
|
35
|
+
`${branch} ${column("expected")} ${green(entry.expected)}`,
|
|
36
|
+
`${gutter} ${column("actual")} ${red(entry.actual)}`,
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pairLines(expected: string, actual: string): string[] {
|
|
41
|
+
return [
|
|
42
|
+
`${INDENT}${column("expected")} ${green(expected)}`,
|
|
43
|
+
`${INDENT}${column("actual")} ${red(actual)}`,
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function head(label: string): string {
|
|
48
|
+
return `${INDENT}${bold(label)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function column(label: string): string {
|
|
52
|
+
return dim(label.padEnd(COLUMN));
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createPrettyReporter } from "./pretty-reporter.js";
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Envelope, Problem } from "@venn-lang/core";
|
|
2
|
+
import type { Reporter, RunTotals } from "../reporter.types.js";
|
|
3
|
+
import type { Failure, PrettyState } from "./pretty.types.js";
|
|
4
|
+
import {
|
|
5
|
+
failuresBlock,
|
|
6
|
+
flowLine,
|
|
7
|
+
header,
|
|
8
|
+
locationOf,
|
|
9
|
+
logLine,
|
|
10
|
+
reasonLine,
|
|
11
|
+
stepLine,
|
|
12
|
+
summary,
|
|
13
|
+
} from "./render.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A live tree: each file opens a banner, each flow a branch, and each step
|
|
17
|
+
* prints its verdict as soon as it settles. Everything that failed across every
|
|
18
|
+
* file is repeated at the end with its code and source location.
|
|
19
|
+
*/
|
|
20
|
+
export function createPrettyReporter(): Reporter {
|
|
21
|
+
const state: PrettyState = {
|
|
22
|
+
flow: "",
|
|
23
|
+
step: "",
|
|
24
|
+
inStep: false,
|
|
25
|
+
stepStartedAt: 0,
|
|
26
|
+
current: [],
|
|
27
|
+
logs: [],
|
|
28
|
+
failures: [],
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
sink: { emit: (envelope) => handle(envelope, state) },
|
|
32
|
+
beginFile: (file) => {
|
|
33
|
+
state.pendingFile = file;
|
|
34
|
+
},
|
|
35
|
+
finish: (totals) => finishRun(state, totals),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function handle(envelope: Envelope, state: PrettyState): void {
|
|
40
|
+
const data = envelope.data as Record<string, unknown>;
|
|
41
|
+
if (envelope.kind === "flow.started") beginFlow(state, data);
|
|
42
|
+
else if (envelope.kind === "step.started") beginStep(state, envelope, data);
|
|
43
|
+
else if (envelope.kind === "expect.failed") failed(state, data);
|
|
44
|
+
else if (envelope.kind === "step.finished") endStep(state, envelope, data);
|
|
45
|
+
else if (envelope.kind === "flow.finished") state.flow = "";
|
|
46
|
+
else if (envelope.kind === "log") logged(state, data);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A failure inside a step waits for that step's verdict, to print under it. One
|
|
51
|
+
* that arrives between steps (a `setup` or a `teardown` that blew up) has no
|
|
52
|
+
* verdict coming, and holding it would let the next `step.started` wipe it, so
|
|
53
|
+
* it goes straight to the summary.
|
|
54
|
+
*/
|
|
55
|
+
function failed(state: PrettyState, data: Record<string, unknown>): void {
|
|
56
|
+
const failure = fromProblem(state, data);
|
|
57
|
+
if (state.inStep) state.current.push(failure);
|
|
58
|
+
else state.failures.push(failure);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function beginFlow(state: PrettyState, data: Record<string, unknown>): void {
|
|
62
|
+
if (state.pendingFile) {
|
|
63
|
+
write(header(state.pendingFile));
|
|
64
|
+
state.pendingFile = undefined;
|
|
65
|
+
}
|
|
66
|
+
state.flow = String(data.title ?? "");
|
|
67
|
+
write(flowLine(state.flow));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function beginStep(state: PrettyState, envelope: Envelope, data: Record<string, unknown>): void {
|
|
71
|
+
state.step = String(data.title ?? "");
|
|
72
|
+
state.inStep = true;
|
|
73
|
+
state.stepStartedAt = Date.parse(envelope.ts);
|
|
74
|
+
state.current = [];
|
|
75
|
+
state.logs = [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function endStep(state: PrettyState, envelope: Envelope, data: Record<string, unknown>): void {
|
|
79
|
+
const ms = Math.max(0, Date.parse(envelope.ts) - state.stepStartedAt);
|
|
80
|
+
write(stepLine({ title: String(data.title ?? ""), passed: data.status === "passed", ms }));
|
|
81
|
+
for (const line of state.logs) write(logLine(line));
|
|
82
|
+
for (const failure of state.current) write(reasonLine(failure));
|
|
83
|
+
state.failures.push(...state.current);
|
|
84
|
+
state.current = [];
|
|
85
|
+
state.logs = [];
|
|
86
|
+
state.inStep = false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `log "…"` prints under its step, like console output in vitest. An error
|
|
91
|
+
* thrown mid-flow also arrives as a log, and that one is kept for the summary.
|
|
92
|
+
*/
|
|
93
|
+
function logged(state: PrettyState, data: Record<string, unknown>): void {
|
|
94
|
+
const message = String(data.message ?? "");
|
|
95
|
+
if (data.level === "error") {
|
|
96
|
+
state.failures.push({ flow: state.flow, step: state.step, code: "VN7001", title: message });
|
|
97
|
+
} else {
|
|
98
|
+
state.logs.push(message);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function finishRun(state: PrettyState, totals: RunTotals): void {
|
|
103
|
+
write(failuresBlock(state.failures));
|
|
104
|
+
write(summary(totals));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function fromProblem(state: PrettyState, data: Record<string, unknown>): Failure {
|
|
108
|
+
const problem = data.problem as Problem;
|
|
109
|
+
return {
|
|
110
|
+
flow: state.flow,
|
|
111
|
+
// A step that already ended is not to blame for what came after it.
|
|
112
|
+
step: state.inStep ? state.step : "",
|
|
113
|
+
code: problem.code,
|
|
114
|
+
title: problem.title,
|
|
115
|
+
location: locationOf(problem),
|
|
116
|
+
diff: problem.diff,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function write(text: string): void {
|
|
121
|
+
if (text) process.stdout.write(`${text}\n`);
|
|
122
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Diff } from "@venn-lang/core";
|
|
2
|
+
|
|
3
|
+
/** One thing that went wrong, kept for the summary printed after the tree. */
|
|
4
|
+
export interface Failure {
|
|
5
|
+
flow: string;
|
|
6
|
+
step: string;
|
|
7
|
+
code: string;
|
|
8
|
+
title: string;
|
|
9
|
+
location?: string;
|
|
10
|
+
/** The two sides compared, shown as the failure's body. */
|
|
11
|
+
diff?: Diff;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** What the reporter tracks while events stream in. */
|
|
15
|
+
export interface PrettyState {
|
|
16
|
+
/** The file whose banner has not been printed yet. A file with no matching
|
|
17
|
+
* flow never prints one, so filters stay quiet. */
|
|
18
|
+
pendingFile?: string;
|
|
19
|
+
flow: string;
|
|
20
|
+
step: string;
|
|
21
|
+
/** Whether a step is open. A failure outside one belongs to no step. */
|
|
22
|
+
inStep: boolean;
|
|
23
|
+
stepStartedAt: number;
|
|
24
|
+
/** Failures seen in the step currently running. */
|
|
25
|
+
current: Failure[];
|
|
26
|
+
/** `log` messages emitted during the step currently running. */
|
|
27
|
+
logs: string[];
|
|
28
|
+
failures: Failure[];
|
|
29
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import type { Problem } from "@venn-lang/core";
|
|
3
|
+
import { bold, cyan, dim, green, inverse, red } from "../colors.js";
|
|
4
|
+
import { diffLines } from "./diff-lines.js";
|
|
5
|
+
import type { Failure } from "./pretty.types.js";
|
|
6
|
+
|
|
7
|
+
const PASS = "✓";
|
|
8
|
+
const FAIL = "✗";
|
|
9
|
+
|
|
10
|
+
/** The banner naming the file under test. Flows add their own leading blank line. */
|
|
11
|
+
export function header(file: string): string {
|
|
12
|
+
return `\n${inverse(cyan(" RUN "))} ${dim(shorten(file))}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A flow opens a branch of the tree. */
|
|
16
|
+
export function flowLine(title: string): string {
|
|
17
|
+
return `\n ${dim("❯")} ${bold(title)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A step closes with its verdict and how long it took. */
|
|
21
|
+
export function stepLine(args: { title: string; passed: boolean; ms: number }): string {
|
|
22
|
+
const mark = args.passed ? green(PASS) : red(FAIL);
|
|
23
|
+
return ` ${mark} ${args.title} ${dim(`${args.ms}ms`)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Why a step failed, shown inline right under it. */
|
|
27
|
+
export function reasonLine(failure: Failure): string {
|
|
28
|
+
return ` ${red("→")} ${failure.title}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A `log` line the flow emitted, shown under its step like console output. */
|
|
32
|
+
export function logLine(message: string): string {
|
|
33
|
+
const [first = "", ...rest] = message.split("\n");
|
|
34
|
+
const head = ` ${dim("›")} ${first}`;
|
|
35
|
+
return [head, ...rest.map((line) => ` ${line}`)].join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function failuresBlock(failures: readonly Failure[]): string {
|
|
39
|
+
if (failures.length === 0) return "";
|
|
40
|
+
const blocks = failures.map((failure, index) => block(failure, index + 1));
|
|
41
|
+
return `\n${inverse(red(" FAILURES "))}\n\n${blocks.join("\n\n")}\n`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function summary(args: {
|
|
45
|
+
passed: number;
|
|
46
|
+
failed: number;
|
|
47
|
+
files?: number;
|
|
48
|
+
ms: number;
|
|
49
|
+
}): string {
|
|
50
|
+
const counts = [
|
|
51
|
+
args.failed > 0 ? red(`${args.failed} failed`) : undefined,
|
|
52
|
+
green(`${args.passed} passed`),
|
|
53
|
+
];
|
|
54
|
+
const line = counts.filter(Boolean).join(dim(" | "));
|
|
55
|
+
const total = dim(`(${args.passed + args.failed})`);
|
|
56
|
+
const files = args.files && args.files > 1 ? `\n ${dim("Files")} ${args.files}` : "";
|
|
57
|
+
return `${files}\n ${dim("Tests")} ${line} ${total}\n ${dim(" Time")} ${dim(`${args.ms}ms`)}\n`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `examples/testing/01-first-flow.vn:12:5`, relative to where the CLI was invoked. */
|
|
61
|
+
export function locationOf(problem: Problem): string | undefined {
|
|
62
|
+
const span = problem.span;
|
|
63
|
+
if (!span?.uri) return undefined;
|
|
64
|
+
return `${shorten(span.uri)}:${span.line}:${span.column}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function block(failure: Failure, index: number): string {
|
|
68
|
+
const lines = [
|
|
69
|
+
` ${red(`${index})`)} ${bold(where(failure))}`,
|
|
70
|
+
` ${red(failure.code)} ${failure.title}`,
|
|
71
|
+
];
|
|
72
|
+
if (failure.location) lines.push(` ${dim(`at ${failure.location}`)}`);
|
|
73
|
+
if (failure.diff) lines.push("", ...diffLines(failure.diff));
|
|
74
|
+
return lines.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Which flow and step it happened in. A hook that failed belongs to neither, so
|
|
79
|
+
* it says so instead of borrowing the last step's name.
|
|
80
|
+
*/
|
|
81
|
+
function where(failure: Failure): string {
|
|
82
|
+
return [failure.flow, failure.step].filter(Boolean).join(` ${dim("›")} `) || "lifecycle";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function shorten(file: string): string {
|
|
86
|
+
const path = relative(process.cwd(), file);
|
|
87
|
+
return path && !path.startsWith("..") ? path.replace(/\\/g, "/") : file;
|
|
88
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Problem } from "@venn-lang/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Print compile-time problems to stderr, one code and title per problem with
|
|
5
|
+
* its source location beneath. The terminal surface of the §16 model.
|
|
6
|
+
*/
|
|
7
|
+
export function reportProblems(problems: readonly Problem[]): void {
|
|
8
|
+
for (const problem of problems) {
|
|
9
|
+
process.stderr.write(`${problem.code} · ${problem.title}\n`);
|
|
10
|
+
const { uri, line, column } = problem.span;
|
|
11
|
+
process.stderr.write(` at ${uri}:${line}:${column}\n`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Problem } from "@venn-lang/core";
|
|
2
|
+
import type { EventSink } from "@venn-lang/runtime";
|
|
3
|
+
import { reportProblems } from "./problem-reporter.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The sink for a run with no reporter watching it: `venn run` leaves stdout to
|
|
7
|
+
* the program, so nothing draws its flows.
|
|
8
|
+
*
|
|
9
|
+
* Failures are the exception. A `setup` that blew up is counted rather than
|
|
10
|
+
* thrown, so this is the only place it can still be said out loud, on stderr,
|
|
11
|
+
* where the program's own output is not.
|
|
12
|
+
*/
|
|
13
|
+
export function createProblemSink(): EventSink {
|
|
14
|
+
return {
|
|
15
|
+
emit: (envelope) => {
|
|
16
|
+
if (envelope.kind !== "expect.failed") return;
|
|
17
|
+
reportProblems([(envelope.data as { problem: Problem }).problem]);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { EventSink } from "@venn-lang/runtime";
|
|
2
|
+
|
|
3
|
+
/** Totals across every file a single `venn run` covered. */
|
|
4
|
+
export interface RunTotals {
|
|
5
|
+
passed: number;
|
|
6
|
+
failed: number;
|
|
7
|
+
files: number;
|
|
8
|
+
ms: number;
|
|
9
|
+
/** Set when a file called `exit`: the code the whole command leaves with. */
|
|
10
|
+
exitCode?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A reporter is an event sink plus the two moments the CLI owns: which file is
|
|
15
|
+
* starting, and that the whole run is over. Machine formats ignore both.
|
|
16
|
+
*/
|
|
17
|
+
export interface Reporter {
|
|
18
|
+
sink: EventSink;
|
|
19
|
+
beginFile(file: string): void;
|
|
20
|
+
finish(totals: RunTotals): void;
|
|
21
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const EXTENSION = ".vn";
|
|
5
|
+
const SKIP = new Set(["node_modules", "dist", ".git"]);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `.vn` files a path names: the file itself, or every one under a
|
|
9
|
+
* directory, walked recursively and sorted so runs are reproducible.
|
|
10
|
+
*/
|
|
11
|
+
export async function collectSourceFiles(path: string): Promise<string[]> {
|
|
12
|
+
const info = await stat(path).catch(() => undefined);
|
|
13
|
+
if (!info) return [];
|
|
14
|
+
if (info.isFile()) return path.endsWith(EXTENSION) ? [path] : [];
|
|
15
|
+
return (await walk(path)).sort();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function walk(directory: string): Promise<string[]> {
|
|
19
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
20
|
+
const found: string[] = [];
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (entry.isDirectory() && !SKIP.has(entry.name))
|
|
23
|
+
found.push(...(await walk(join(directory, entry.name))));
|
|
24
|
+
else if (entry.isFile() && entry.name.endsWith(EXTENSION))
|
|
25
|
+
found.push(join(directory, entry.name));
|
|
26
|
+
}
|
|
27
|
+
return found;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every `.vn` under any of these paths, each counted once.
|
|
32
|
+
*
|
|
33
|
+
* Deduplicated because a workspace can name the same directory twice, as a
|
|
34
|
+
* member and as the root that contains it, and checking a file twice reports
|
|
35
|
+
* every problem in it twice.
|
|
36
|
+
*/
|
|
37
|
+
export async function everySourceUnder(paths: readonly string[]): Promise<string[]> {
|
|
38
|
+
const found = new Set<string>();
|
|
39
|
+
for (const path of paths) {
|
|
40
|
+
for (const file of await collectSourceFiles(resolve(path))) found.add(file);
|
|
41
|
+
}
|
|
42
|
+
return [...found];
|
|
43
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a program ended, and what the process should do about it.
|
|
3
|
+
*
|
|
4
|
+
* Two separate questions. `exit 0` and "ran off the end cleanly" both arrive as
|
|
5
|
+
* the number 0, but one is a request to leave and the other is a program with
|
|
6
|
+
* nothing more to say, which for a server means it is still working.
|
|
7
|
+
*/
|
|
8
|
+
export interface Ending {
|
|
9
|
+
/** The number to leave with. */
|
|
10
|
+
code: number;
|
|
11
|
+
/**
|
|
12
|
+
* Whether the process should go now. False means hand the decision to the
|
|
13
|
+
* event loop: a program still holding a socket keeps serving, and one holding
|
|
14
|
+
* nothing exits on its own, running its cleanup on the way.
|
|
15
|
+
*/
|
|
16
|
+
leave: boolean;
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RunResult } from "@venn-lang/runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The number a run hands to the process.
|
|
5
|
+
*
|
|
6
|
+
* `exit 3` is the program saying how it went, and nothing here knows better,
|
|
7
|
+
* `exit 0` after a failure included. Without one, a run that reported failures
|
|
8
|
+
* must not leave with 0: a `setup` that blew up is counted rather than thrown,
|
|
9
|
+
* and a 0 would tell whatever is waiting on this process that the program did
|
|
10
|
+
* its job.
|
|
11
|
+
*/
|
|
12
|
+
export function exitCodeOf(result: RunResult | undefined): number {
|
|
13
|
+
if (result?.exitCode !== undefined) return result.exitCode;
|
|
14
|
+
return result && result.failed > 0 ? 1 : 0;
|
|
15
|
+
}
|