@comity-dev/validate 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/bin/comity-validate.d.ts +1 -1
- package/dist/bin/comity-validate.js +9 -4
- package/dist/discover.d.ts +28 -0
- package/dist/discover.js +127 -1
- package/dist/engines/declaration-dependencies.d.ts +3 -0
- package/dist/engines/declaration-dependencies.js +437 -0
- package/dist/exit-codes.d.ts +1 -1
- package/dist/exit-codes.js +1 -1
- package/dist/format.d.ts +2 -0
- package/dist/format.js +4 -0
- package/dist/run.d.ts +2 -0
- package/dist/run.js +19 -1
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
-
Thin orchestrator over Comity shared validation tooling. Discovers a repository, invokes the specialized engines (schema, dependency-cruiser, ESLint, Semgrep, adapter-peers), and returns a normalized result.
|
|
5
|
+
Thin orchestrator over Comity shared validation tooling. Discovers a repository, invokes the specialized engines (schema, dependency-cruiser, ESLint, Semgrep, adapter-peers, declarations), and returns a normalized result.
|
|
6
6
|
|
|
7
|
-
This package is intentionally small. It does **not** implement
|
|
7
|
+
This package is intentionally small. It does **not** implement source-code rules; the in-process declaration engine
|
|
8
|
+
implements the rules in ADR-035. Its only responsibilities are:
|
|
8
9
|
|
|
9
10
|
1. Discover repository configuration (`comity.config.json`).
|
|
10
11
|
2. Discover packages.
|
|
@@ -23,6 +24,7 @@ The engines wrap shared Development-owned tooling:
|
|
|
23
24
|
| `eslint` | ESLint + `@comity-dev/eslint-plugin` | Source-code lint policy. |
|
|
24
25
|
| `semgrep` | `semgrep` + `@comity-dev/semgrep-rules` | Structural source patterns. |
|
|
25
26
|
| `adapter-peers` | in-process | Adapter peer-dependency contract. |
|
|
27
|
+
| `declarations` | in-process + TypeScript | Published declaration dependency contract (ADR-035). |
|
|
26
28
|
|
|
27
29
|
Engines report one of `PASS` / `FAIL` / `NOT-RUN` / `TOOL-UNAVAILABLE` / `CONFIGURATION-ERROR` / `EXECUTION-ERROR` / `NOT-APPLICABLE`. `PASS` is only reported when the engine actually executed.
|
|
28
30
|
|
|
@@ -47,7 +49,7 @@ import {
|
|
|
47
49
|
```bash
|
|
48
50
|
comity-validate --repo /path/to/repo
|
|
49
51
|
comity-validate --only=schema # one engine only
|
|
50
|
-
comity-validate --only=
|
|
52
|
+
comity-validate --only=declarations
|
|
51
53
|
```
|
|
52
54
|
|
|
53
55
|
`--only <category>` runs a single engine. Policies owned by another engine are
|
|
@@ -59,7 +61,7 @@ Exit codes:
|
|
|
59
61
|
|
|
60
62
|
- `0` — validation passed
|
|
61
63
|
- `1` — validation failed (violations found)
|
|
62
|
-
- `2` — configuration / discovery error
|
|
64
|
+
- `2` — configuration / discovery error (may co-occur with findings)
|
|
63
65
|
- `3` — required tool not installed
|
|
64
66
|
|
|
65
67
|
## Environment
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* --verbose, -v Print underlying tool output
|
|
11
11
|
* --repo <path> Repository root (default: cwd)
|
|
12
12
|
* --only <category> Only run a single category
|
|
13
|
-
* (schema | metadata | config | dependencies | eslint | semgrep | adapter-peers)
|
|
13
|
+
* (schema | metadata | config | dependencies | eslint | semgrep | adapter-peers | declarations | declaration-dependencies)
|
|
14
14
|
* Default: run all categories.
|
|
15
15
|
*
|
|
16
16
|
* Exit codes:
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* --verbose, -v Print underlying tool output
|
|
11
11
|
* --repo <path> Repository root (default: cwd)
|
|
12
12
|
* --only <category> Only run a single category
|
|
13
|
-
* (schema | metadata | config | dependencies | eslint | semgrep | adapter-peers)
|
|
13
|
+
* (schema | metadata | config | dependencies | eslint | semgrep | adapter-peers | declarations | declaration-dependencies)
|
|
14
14
|
* Default: run all categories.
|
|
15
15
|
*
|
|
16
16
|
* Exit codes:
|
|
@@ -62,7 +62,7 @@ Options:
|
|
|
62
62
|
--verbose, -v Print underlying tool output
|
|
63
63
|
--repo <path> Repository root (default: cwd)
|
|
64
64
|
--only <category> Only run a single category
|
|
65
|
-
(schema | metadata | config | dependencies | eslint | semgrep | adapter-peers)
|
|
65
|
+
(schema | metadata | config | dependencies | eslint | semgrep | adapter-peers | declarations | declaration-dependencies)
|
|
66
66
|
|
|
67
67
|
Exit codes:
|
|
68
68
|
0 validation passed
|
|
@@ -83,15 +83,20 @@ async function main() {
|
|
|
83
83
|
verbose: args.verbose,
|
|
84
84
|
only: args.only,
|
|
85
85
|
});
|
|
86
|
+
const exitCode = exitCodeForResult(result);
|
|
86
87
|
console.log(formatSummary(result));
|
|
87
|
-
if (
|
|
88
|
+
if (result.violations.length > 0) {
|
|
88
89
|
console.log("");
|
|
89
90
|
console.log(formatViolations(result.violations));
|
|
90
91
|
}
|
|
92
|
+
if (exitCode === ExitCode.CONFIG_ERROR && result.violations.length > 0) {
|
|
93
|
+
console.log("");
|
|
94
|
+
console.log("Configuration error(s) took precedence over validation findings (exit code 2).");
|
|
95
|
+
}
|
|
91
96
|
// A required tool that could not run must never surface as PASS:
|
|
92
97
|
// report it with the documented TOOL_MISSING exit code instead of
|
|
93
98
|
// fabricating a passing result.
|
|
94
|
-
process.exit(
|
|
99
|
+
process.exit(exitCode);
|
|
95
100
|
}
|
|
96
101
|
catch (err) {
|
|
97
102
|
const message = err instanceof Error ? err.message : String(err);
|
package/dist/discover.d.ts
CHANGED
|
@@ -39,3 +39,31 @@ export interface DiscoverOptions {
|
|
|
39
39
|
* package roots, and produce a normalized Repository handle.
|
|
40
40
|
*/
|
|
41
41
|
export declare function discoverRepository({ root, }: DiscoverOptions): Promise<Repository>;
|
|
42
|
+
export interface ExcludedPackage {
|
|
43
|
+
/** The absolute path of the excluded package directory. */
|
|
44
|
+
path: string;
|
|
45
|
+
/** The package's `name` field, when it has a readable string name. */
|
|
46
|
+
name: string | null;
|
|
47
|
+
/** Why this package is not publishable. */
|
|
48
|
+
reason: string;
|
|
49
|
+
}
|
|
50
|
+
export interface PublishableDiscovery {
|
|
51
|
+
/** The configured package roots, relative to the repository root. */
|
|
52
|
+
roots: string[];
|
|
53
|
+
/** Whether the default root was used because no explicit roots were configured. */
|
|
54
|
+
usedDefaultRoots: boolean;
|
|
55
|
+
/** Every discovered package with a readable manifest and a string name, including private packages. */
|
|
56
|
+
allPackages: PackageRecord[];
|
|
57
|
+
/** Packages that participate in stage 1 declaration validation. */
|
|
58
|
+
publishable: PackageRecord[];
|
|
59
|
+
/** Packages excluded from validation, with the reason recorded. */
|
|
60
|
+
excluded: ExcludedPackage[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Discover every package under the configured roots, with no namespace filter.
|
|
64
|
+
*
|
|
65
|
+
* This is the discovery used by the stage 1 and stage 2 engines. It deliberately
|
|
66
|
+
* does not change `discoverRepository`, whose existing engines still use the
|
|
67
|
+
* legacy name-based classification.
|
|
68
|
+
*/
|
|
69
|
+
export declare function discoverPublishablePackages(root: string): PublishableDiscovery;
|
package/dist/discover.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* roots to produce a normalized Repository handle that the orchestrator
|
|
6
6
|
* passes to specialized validators.
|
|
7
7
|
*/
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
9
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
9
|
-
import { join, resolve } from "node:path";
|
|
10
|
+
import { join, matchesGlob, relative, resolve } from "node:path";
|
|
10
11
|
/**
|
|
11
12
|
* Discover a repository: load comity.config.json, walk the configured
|
|
12
13
|
* package roots, and produce a normalized Repository handle.
|
|
@@ -72,3 +73,128 @@ export async function discoverRepository({ root, }) {
|
|
|
72
73
|
config: config ?? {},
|
|
73
74
|
};
|
|
74
75
|
}
|
|
76
|
+
function isRecord(value) {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
function readConfig(root) {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(readFileSync(join(root, "comity.config.json"), "utf8"));
|
|
82
|
+
return isRecord(parsed) ? parsed : null;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function configuredRoots(config) {
|
|
89
|
+
const packages = isRecord(config) && isRecord(config["packages"])
|
|
90
|
+
? config["packages"]
|
|
91
|
+
: null;
|
|
92
|
+
const configured = packages?.["roots"];
|
|
93
|
+
const roots = Array.isArray(configured) &&
|
|
94
|
+
configured.length > 0 &&
|
|
95
|
+
configured.every((root) => typeof root === "string")
|
|
96
|
+
? configured
|
|
97
|
+
: ["packages"];
|
|
98
|
+
const defaulted = !Array.isArray(configured) ||
|
|
99
|
+
configured.length === 0 ||
|
|
100
|
+
!configured.every((root) => typeof root === "string");
|
|
101
|
+
const excludeValue = packages?.["exclude"];
|
|
102
|
+
const exclude = Array.isArray(excludeValue)
|
|
103
|
+
? excludeValue.filter((pattern) => typeof pattern === "string")
|
|
104
|
+
: [];
|
|
105
|
+
return { roots, usedDefaultRoots: defaulted, exclude };
|
|
106
|
+
}
|
|
107
|
+
function isExcluded(relativePath, patterns) {
|
|
108
|
+
return patterns.some((pattern) => matchesGlob(relativePath, pattern));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Discover every package under the configured roots, with no namespace filter.
|
|
112
|
+
*
|
|
113
|
+
* This is the discovery used by the stage 1 and stage 2 engines. It deliberately
|
|
114
|
+
* does not change `discoverRepository`, whose existing engines still use the
|
|
115
|
+
* legacy name-based classification.
|
|
116
|
+
*/
|
|
117
|
+
export function discoverPublishablePackages(root) {
|
|
118
|
+
const absRoot = resolve(root);
|
|
119
|
+
const config = readConfig(absRoot);
|
|
120
|
+
const { roots, usedDefaultRoots, exclude } = configuredRoots(config);
|
|
121
|
+
const allPackages = [];
|
|
122
|
+
const publishable = [];
|
|
123
|
+
const excluded = [];
|
|
124
|
+
for (const relRoot of roots) {
|
|
125
|
+
const dir = join(absRoot, relRoot);
|
|
126
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory())
|
|
127
|
+
continue;
|
|
128
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
129
|
+
.filter((entry) => entry.isDirectory())
|
|
130
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
const pkgDir = join(dir, entry.name);
|
|
133
|
+
const manifestPath = join(pkgDir, "package.json");
|
|
134
|
+
if (!existsSync(manifestPath) || !statSync(manifestPath).isFile())
|
|
135
|
+
continue;
|
|
136
|
+
let manifest;
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
139
|
+
if (!isRecord(parsed)) {
|
|
140
|
+
excluded.push({
|
|
141
|
+
path: pkgDir,
|
|
142
|
+
name: null,
|
|
143
|
+
reason: "package.json is not a JSON object",
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
manifest = parsed;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
excluded.push({
|
|
151
|
+
path: pkgDir,
|
|
152
|
+
name: null,
|
|
153
|
+
reason: "package.json is not readable JSON",
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const name = manifest["name"];
|
|
158
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
159
|
+
excluded.push({
|
|
160
|
+
path: pkgDir,
|
|
161
|
+
name: null,
|
|
162
|
+
reason: "package.json has no string name",
|
|
163
|
+
});
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const relativePath = relative(absRoot, pkgDir).split("\\").join("/");
|
|
167
|
+
if (isExcluded(relativePath, exclude)) {
|
|
168
|
+
excluded.push({
|
|
169
|
+
path: pkgDir,
|
|
170
|
+
name,
|
|
171
|
+
reason: "matched packages.exclude",
|
|
172
|
+
});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const comity = isRecord(manifest["comity"]) ? manifest["comity"] : null;
|
|
176
|
+
const layer = comity?.["layer"];
|
|
177
|
+
const record = {
|
|
178
|
+
name,
|
|
179
|
+
path: pkgDir,
|
|
180
|
+
layer: typeof layer === "string" ? layer : null,
|
|
181
|
+
manifest,
|
|
182
|
+
};
|
|
183
|
+
allPackages.push(record);
|
|
184
|
+
if (manifest["private"] === true) {
|
|
185
|
+
excluded.push({
|
|
186
|
+
path: pkgDir,
|
|
187
|
+
name,
|
|
188
|
+
reason: "private package",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
publishable.push(record);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
allPackages.sort((a, b) => a.path.localeCompare(b.path));
|
|
197
|
+
publishable.sort((a, b) => a.path.localeCompare(b.path));
|
|
198
|
+
excluded.sort((a, b) => a.path.localeCompare(b.path));
|
|
199
|
+
return { roots, usedDefaultRoots, allPackages, publishable, excluded };
|
|
200
|
+
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { builtinModules } from "node:module";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
import { discoverPublishablePackages } from "../discover.js";
|
|
6
|
+
const SOURCE = "ADR-035";
|
|
7
|
+
const BUILTINS = new Set(builtinModules);
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
function stringSet(value) {
|
|
12
|
+
if (!isRecord(value))
|
|
13
|
+
return new Set();
|
|
14
|
+
return new Set(Object.keys(value));
|
|
15
|
+
}
|
|
16
|
+
function dependencySets(manifest) {
|
|
17
|
+
const peers = stringSet(manifest["peerDependencies"]);
|
|
18
|
+
const meta = isRecord(manifest["peerDependenciesMeta"])
|
|
19
|
+
? manifest["peerDependenciesMeta"]
|
|
20
|
+
: {};
|
|
21
|
+
return {
|
|
22
|
+
dependencies: stringSet(manifest["dependencies"]),
|
|
23
|
+
peers,
|
|
24
|
+
optionalPeers: new Set([...peers].filter((name) => {
|
|
25
|
+
const entry = meta[name];
|
|
26
|
+
return isRecord(entry) && entry["optional"] === true;
|
|
27
|
+
})),
|
|
28
|
+
dev: stringSet(manifest["devDependencies"]),
|
|
29
|
+
optional: stringSet(manifest["optionalDependencies"]),
|
|
30
|
+
exports: isRecord(manifest["exports"]) ? Object.keys(manifest["exports"]) : [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function isDeclarationFile(path) {
|
|
34
|
+
return /\.d\.(?:ts|mts|cts)$/.test(path);
|
|
35
|
+
}
|
|
36
|
+
function collectDeclarationTargets(pkg) {
|
|
37
|
+
const targets = new Map();
|
|
38
|
+
const add = (entry, value) => {
|
|
39
|
+
if (value.includes("*") || !isDeclarationFile(value))
|
|
40
|
+
return;
|
|
41
|
+
const absolute = resolve(pkg.path, value);
|
|
42
|
+
const files = targets.get(entry) ?? new Set();
|
|
43
|
+
files.add(absolute);
|
|
44
|
+
targets.set(entry, files);
|
|
45
|
+
};
|
|
46
|
+
const walk = (entry, value) => {
|
|
47
|
+
if (typeof value === "string") {
|
|
48
|
+
add(entry, value);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(value)) {
|
|
52
|
+
for (const item of value)
|
|
53
|
+
walk(entry, item);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (isRecord(value)) {
|
|
57
|
+
for (const item of Object.values(value))
|
|
58
|
+
walk(entry, item);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const exportsField = pkg.manifest["exports"];
|
|
62
|
+
if (typeof exportsField === "string") {
|
|
63
|
+
walk(".", exportsField);
|
|
64
|
+
}
|
|
65
|
+
else if (isRecord(exportsField)) {
|
|
66
|
+
for (const [entry, value] of Object.entries(exportsField))
|
|
67
|
+
walk(entry, value);
|
|
68
|
+
}
|
|
69
|
+
const types = pkg.manifest["types"];
|
|
70
|
+
if (typeof types === "string")
|
|
71
|
+
add(".", types);
|
|
72
|
+
const typesVersions = pkg.manifest["typesVersions"];
|
|
73
|
+
if (isRecord(typesVersions)) {
|
|
74
|
+
for (const mapping of Object.values(typesVersions)) {
|
|
75
|
+
if (!isRecord(mapping))
|
|
76
|
+
continue;
|
|
77
|
+
for (const [subpath, value] of Object.entries(mapping)) {
|
|
78
|
+
const entry = subpath.startsWith(".")
|
|
79
|
+
? subpath
|
|
80
|
+
: subpath === "*"
|
|
81
|
+
? "*"
|
|
82
|
+
: `./${subpath}`;
|
|
83
|
+
walk(entry, value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return targets;
|
|
88
|
+
}
|
|
89
|
+
function parseDeclaration(file) {
|
|
90
|
+
const text = readFileSync(file, "utf8");
|
|
91
|
+
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
92
|
+
const relativeSpecifiers = new Set();
|
|
93
|
+
const externalSpecifiers = new Set();
|
|
94
|
+
const isModule = ts.isExternalModule(sourceFile);
|
|
95
|
+
const add = (specifier) => {
|
|
96
|
+
if (specifier.startsWith("."))
|
|
97
|
+
relativeSpecifiers.add(specifier);
|
|
98
|
+
else
|
|
99
|
+
externalSpecifiers.add(specifier);
|
|
100
|
+
};
|
|
101
|
+
const visit = (node) => {
|
|
102
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
103
|
+
if (node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
104
|
+
add(node.moduleSpecifier.text);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (ts.isImportEqualsDeclaration(node)) {
|
|
108
|
+
const reference = node.moduleReference;
|
|
109
|
+
if (ts.isExternalModuleReference(reference) &&
|
|
110
|
+
reference.expression &&
|
|
111
|
+
ts.isStringLiteral(reference.expression)) {
|
|
112
|
+
add(reference.expression.text);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else if (ts.isImportTypeNode(node)) {
|
|
116
|
+
const argument = node.argument;
|
|
117
|
+
if (ts.isLiteralTypeNode(argument) && ts.isStringLiteral(argument.literal)) {
|
|
118
|
+
add(argument.literal.text);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else if (isModule &&
|
|
122
|
+
ts.isModuleDeclaration(node) &&
|
|
123
|
+
ts.isStringLiteral(node.name)) {
|
|
124
|
+
add(node.name.text);
|
|
125
|
+
}
|
|
126
|
+
ts.forEachChild(node, visit);
|
|
127
|
+
};
|
|
128
|
+
visit(sourceFile);
|
|
129
|
+
for (const reference of sourceFile.referencedFiles)
|
|
130
|
+
add(reference.fileName);
|
|
131
|
+
for (const reference of sourceFile.typeReferenceDirectives) {
|
|
132
|
+
externalSpecifiers.add(reference.fileName);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
relative: [...relativeSpecifiers],
|
|
136
|
+
external: [...externalSpecifiers],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function resolveRelative(specifier, fromFile) {
|
|
140
|
+
const base = resolve(dirname(fromFile), specifier);
|
|
141
|
+
const candidates = [];
|
|
142
|
+
if (base.endsWith(".js"))
|
|
143
|
+
candidates.push(`${base.slice(0, -3)}.d.ts`);
|
|
144
|
+
else if (base.endsWith(".mjs"))
|
|
145
|
+
candidates.push(`${base.slice(0, -4)}.d.mts`);
|
|
146
|
+
else if (base.endsWith(".cjs"))
|
|
147
|
+
candidates.push(`${base.slice(0, -4)}.d.cts`);
|
|
148
|
+
else if (isDeclarationFile(base))
|
|
149
|
+
candidates.push(base);
|
|
150
|
+
candidates.push(`${base}.d.ts`, join(base, "index.d.ts"));
|
|
151
|
+
return candidates.find((candidate) => {
|
|
152
|
+
return existsSync(candidate) && statSync(candidate).isFile();
|
|
153
|
+
}) ?? null;
|
|
154
|
+
}
|
|
155
|
+
function packageNameOf(specifier) {
|
|
156
|
+
const parts = specifier.split("/");
|
|
157
|
+
return specifier.startsWith("@")
|
|
158
|
+
? parts.slice(0, 2).join("/")
|
|
159
|
+
: (parts[0] ?? specifier);
|
|
160
|
+
}
|
|
161
|
+
function subpathOf(specifier, owner) {
|
|
162
|
+
if (specifier === owner)
|
|
163
|
+
return "";
|
|
164
|
+
return specifier.startsWith(`${owner}/`)
|
|
165
|
+
? `.${specifier.slice(owner.length)}`
|
|
166
|
+
: "";
|
|
167
|
+
}
|
|
168
|
+
function typesPackageName(owner) {
|
|
169
|
+
if (!owner.startsWith("@"))
|
|
170
|
+
return `@types/${owner}`;
|
|
171
|
+
const [scope, name] = owner.split("/");
|
|
172
|
+
return `@types/${scope?.slice(1) ?? ""}__${name ?? ""}`;
|
|
173
|
+
}
|
|
174
|
+
function matchesSubpath(exportKeys, subpath) {
|
|
175
|
+
return exportKeys.some((key) => {
|
|
176
|
+
if (key === subpath)
|
|
177
|
+
return true;
|
|
178
|
+
if (!key.includes("*"))
|
|
179
|
+
return false;
|
|
180
|
+
const pattern = key
|
|
181
|
+
.split("*")
|
|
182
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
183
|
+
.join(".*");
|
|
184
|
+
return new RegExp(`^${pattern}$`).test(subpath);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function isBuiltin(specifier) {
|
|
188
|
+
return specifier.startsWith("node:") || BUILTINS.has(specifier);
|
|
189
|
+
}
|
|
190
|
+
function finding(context, rule, specifier, file, message, remediation) {
|
|
191
|
+
return {
|
|
192
|
+
tool: "declarations",
|
|
193
|
+
rule,
|
|
194
|
+
package: context.packageRecord.name,
|
|
195
|
+
file,
|
|
196
|
+
keyword: specifier,
|
|
197
|
+
severity: "error",
|
|
198
|
+
source: SOURCE,
|
|
199
|
+
message,
|
|
200
|
+
remediation,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function classifyExternal(specifier, file, context) {
|
|
204
|
+
const owner = packageNameOf(specifier);
|
|
205
|
+
if (isBuiltin(specifier))
|
|
206
|
+
return null;
|
|
207
|
+
const { packageRecord, sets } = context;
|
|
208
|
+
const subpath = subpathOf(specifier, owner);
|
|
209
|
+
if (owner === packageRecord.name) {
|
|
210
|
+
if (subpath && !matchesSubpath(sets.exports, subpath)) {
|
|
211
|
+
return finding(context, "ARCH-DECL-DEP-004", specifier, file, `Package "${packageRecord.name}" exposes "${specifier}" in published declarations (${file}, via entry "${context.entry}") but references a subpath that is not exported by the package`, "Export the subpath or fix the reference.");
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const typesOwner = typesPackageName(owner);
|
|
216
|
+
const declared = sets.dependencies.has(owner) ||
|
|
217
|
+
sets.peers.has(owner) ||
|
|
218
|
+
sets.dependencies.has(typesOwner) ||
|
|
219
|
+
sets.peers.has(typesOwner);
|
|
220
|
+
if (declared) {
|
|
221
|
+
const optionalPeer = sets.optionalPeers.has(owner) && !sets.dependencies.has(owner);
|
|
222
|
+
if (optionalPeer && context.rootReached) {
|
|
223
|
+
return finding(context, "ARCH-DECL-DEP-003", specifier, file, `Package "${packageRecord.name}" exposes optional peer "${owner}" in published declarations (${file}, via entry "${context.entry}") reachable from the root entry "."; consumers without it get unresolved types`, "Make the peer required, or move the reference behind an opt-in subpath.");
|
|
224
|
+
}
|
|
225
|
+
const sibling = context.packages.get(owner);
|
|
226
|
+
if (subpath && sibling) {
|
|
227
|
+
const siblingSets = dependencySets(sibling.manifest);
|
|
228
|
+
if (!matchesSubpath(siblingSets.exports, subpath)) {
|
|
229
|
+
return finding(context, "ARCH-DECL-DEP-005", specifier, file, `Package "${packageRecord.name}" exposes "${specifier}" in published declarations (${file}, via entry "${context.entry}") but "${subpath}" is not an exported subpath of "${owner}"`, "Import an exported subpath.");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const devOwner = sets.dev.has(owner)
|
|
235
|
+
? owner
|
|
236
|
+
: sets.dev.has(typesOwner)
|
|
237
|
+
? typesOwner
|
|
238
|
+
: null;
|
|
239
|
+
if (devOwner) {
|
|
240
|
+
return finding(context, "ARCH-DECL-DEP-002", specifier, file, `Package "${packageRecord.name}" exposes "${specifier}" in published declarations (${file}, via entry "${context.entry}") but "${devOwner}" is only a devDependency; consumers get TS2307, or "any" under skipLibCheck`, `Move or add "${devOwner}" to peerDependencies or dependencies.`);
|
|
241
|
+
}
|
|
242
|
+
if (sets.optional.has(owner) || sets.optional.has(typesOwner)) {
|
|
243
|
+
const optionalOwner = sets.optional.has(owner) ? owner : typesOwner;
|
|
244
|
+
return finding(context, "ARCH-DECL-DEP-001", specifier, file, `Package "${packageRecord.name}" exposes "${specifier}" in published declarations (${file}, via entry "${context.entry}") but declares "${optionalOwner}" only in optionalDependencies; optional dependencies are not guaranteed to be installed for consumers`, `Move or add "${optionalOwner}" to peerDependencies or dependencies.`);
|
|
245
|
+
}
|
|
246
|
+
return finding(context, "ARCH-DECL-DEP-001", specifier, file, `Package "${packageRecord.name}" exposes "${specifier}" in published declarations (${file}, via entry "${context.entry}") but declares "${owner}" in neither dependencies nor peerDependencies`, `Add "${owner}" as a peerDependency (nominal or shared types) or a dependency (private structural types).`);
|
|
247
|
+
}
|
|
248
|
+
function countDeclarationFiles(directory) {
|
|
249
|
+
let count = 0;
|
|
250
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
251
|
+
if (entry.name === "node_modules" || entry.name === ".turbo")
|
|
252
|
+
continue;
|
|
253
|
+
const path = join(directory, entry.name);
|
|
254
|
+
if (entry.isDirectory())
|
|
255
|
+
count += countDeclarationFiles(path);
|
|
256
|
+
else if (entry.isFile() && isDeclarationFile(entry.name))
|
|
257
|
+
count += 1;
|
|
258
|
+
}
|
|
259
|
+
return count;
|
|
260
|
+
}
|
|
261
|
+
function sortFindings(findings) {
|
|
262
|
+
findings.sort((a, b) => {
|
|
263
|
+
return ((a.package ?? "").localeCompare(b.package ?? "") ||
|
|
264
|
+
(a.file ?? "").localeCompare(b.file ?? "") ||
|
|
265
|
+
(a.keyword ?? "").localeCompare(b.keyword ?? "") ||
|
|
266
|
+
a.rule.localeCompare(b.rule));
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
export function runDeclarationDependencies(repo) {
|
|
270
|
+
const start = Date.now();
|
|
271
|
+
const discovery = discoverPublishablePackages(repo.root);
|
|
272
|
+
if (discovery.publishable.length === 0) {
|
|
273
|
+
if (discovery.allPackages.length === 0) {
|
|
274
|
+
const defaultNote = discovery.usedDefaultRoots
|
|
275
|
+
? " The default package root was used."
|
|
276
|
+
: "";
|
|
277
|
+
const excludedNote = discovery.excluded.length > 0
|
|
278
|
+
? ` Excluded package directories: ${discovery.excluded.map((pkg) => pkg.path).join(", ")}.`
|
|
279
|
+
: "";
|
|
280
|
+
return {
|
|
281
|
+
executed: false,
|
|
282
|
+
exitCode: null,
|
|
283
|
+
passed: false,
|
|
284
|
+
findings: [],
|
|
285
|
+
duration: Date.now() - start,
|
|
286
|
+
status: "CONFIGURATION-ERROR",
|
|
287
|
+
tool: "declarations",
|
|
288
|
+
reason: `No package directory with a readable package.json was found under root(s): ${discovery.roots.join(", ")}.${defaultNote}${excludedNote}`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const privatePackages = discovery.excluded
|
|
292
|
+
.filter((pkg) => pkg.reason === "private package")
|
|
293
|
+
.map((pkg) => pkg.name ?? pkg.path);
|
|
294
|
+
return {
|
|
295
|
+
executed: false,
|
|
296
|
+
exitCode: null,
|
|
297
|
+
passed: false,
|
|
298
|
+
findings: [],
|
|
299
|
+
duration: Date.now() - start,
|
|
300
|
+
status: "NOT-APPLICABLE",
|
|
301
|
+
tool: "declarations",
|
|
302
|
+
reason: `All discovered packages are private: ${privatePackages.join(", ")}.`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const allPackages = new Map(discovery.allPackages.map((pkg) => [pkg.name, pkg]));
|
|
306
|
+
const findings = [];
|
|
307
|
+
let totalDeclarationFiles = 0;
|
|
308
|
+
let reachableDeclarationFiles = 0;
|
|
309
|
+
let externalReferenceCount = 0;
|
|
310
|
+
const configurationErrors = [];
|
|
311
|
+
for (const pkg of discovery.publishable) {
|
|
312
|
+
totalDeclarationFiles += countDeclarationFiles(pkg.path);
|
|
313
|
+
const targets = collectDeclarationTargets(pkg);
|
|
314
|
+
if (targets.size === 0)
|
|
315
|
+
continue;
|
|
316
|
+
const entries = [...targets.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
317
|
+
const missingTarget = [...targets.values()]
|
|
318
|
+
.flatMap((files) => [...files])
|
|
319
|
+
.sort()
|
|
320
|
+
.find((file) => !existsSync(file) || !statSync(file).isFile());
|
|
321
|
+
if (missingTarget) {
|
|
322
|
+
configurationErrors.push(`Package "${pkg.name}" declares a missing declaration target: ${relative(pkg.path, missingTarget).split("\\").join("/")}`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const parsed = new Map();
|
|
326
|
+
const entryKeysByFile = new Map();
|
|
327
|
+
const unresolvedRelative = new Map();
|
|
328
|
+
const queue = [];
|
|
329
|
+
for (const [entry, files] of entries) {
|
|
330
|
+
for (const file of [...files].sort()) {
|
|
331
|
+
queue.push({ file, entry });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
335
|
+
const item = queue[index];
|
|
336
|
+
if (!item)
|
|
337
|
+
continue;
|
|
338
|
+
const keys = entryKeysByFile.get(item.file) ?? new Set();
|
|
339
|
+
if (keys.has(item.entry))
|
|
340
|
+
continue;
|
|
341
|
+
keys.add(item.entry);
|
|
342
|
+
entryKeysByFile.set(item.file, keys);
|
|
343
|
+
let declaration = parsed.get(item.file);
|
|
344
|
+
if (!declaration) {
|
|
345
|
+
declaration = parseDeclaration(item.file);
|
|
346
|
+
parsed.set(item.file, declaration);
|
|
347
|
+
}
|
|
348
|
+
for (const specifier of declaration.relative) {
|
|
349
|
+
const resolved = resolveRelative(specifier, item.file);
|
|
350
|
+
if (resolved) {
|
|
351
|
+
queue.push({ file: resolved, entry: item.entry });
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
const entriesForFile = unresolvedRelative.get(`${item.file}\0${specifier}`) ??
|
|
355
|
+
new Set();
|
|
356
|
+
entriesForFile.add(item.entry);
|
|
357
|
+
unresolvedRelative.set(`${item.file}\0${specifier}`, entriesForFile);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
reachableDeclarationFiles += entryKeysByFile.size;
|
|
362
|
+
for (const [key, entryKeys] of unresolvedRelative) {
|
|
363
|
+
const [file, specifier] = key.split("\0");
|
|
364
|
+
if (!file || !specifier)
|
|
365
|
+
continue;
|
|
366
|
+
const entry = [...entryKeys].sort()[0] ?? "?";
|
|
367
|
+
findings.push({
|
|
368
|
+
tool: "declarations",
|
|
369
|
+
rule: "ARCH-DECL-DEP-000",
|
|
370
|
+
package: pkg.name,
|
|
371
|
+
file: relative(pkg.path, file).split("\\").join("/"),
|
|
372
|
+
keyword: specifier,
|
|
373
|
+
severity: "error",
|
|
374
|
+
source: SOURCE,
|
|
375
|
+
message: `Package "${pkg.name}" has an unresolvable relative reference "${specifier}" in declaration "${relative(pkg.path, file).split("\\").join("/")}" (reached via entry "${entry}"); the built declaration graph is broken`,
|
|
376
|
+
remediation: "Rebuild the package, or fix the relative import so it resolves to an emitted declaration.",
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
for (const [file, entryKeys] of entryKeysByFile) {
|
|
380
|
+
const declaration = parsed.get(file);
|
|
381
|
+
if (!declaration)
|
|
382
|
+
continue;
|
|
383
|
+
const entry = [...entryKeys].sort()[0] ?? "?";
|
|
384
|
+
const rootReached = entryKeys.has(".");
|
|
385
|
+
const sets = dependencySets(pkg.manifest);
|
|
386
|
+
const context = {
|
|
387
|
+
packageRecord: pkg,
|
|
388
|
+
sets,
|
|
389
|
+
packages: allPackages,
|
|
390
|
+
rootReached,
|
|
391
|
+
entry,
|
|
392
|
+
};
|
|
393
|
+
for (const specifier of [...declaration.external].sort()) {
|
|
394
|
+
externalReferenceCount += 1;
|
|
395
|
+
const result = classifyExternal(specifier, relative(pkg.path, file).split("\\").join("/"), context);
|
|
396
|
+
if (result)
|
|
397
|
+
findings.push(result);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (configurationErrors.length > 0) {
|
|
402
|
+
return {
|
|
403
|
+
executed: false,
|
|
404
|
+
exitCode: null,
|
|
405
|
+
passed: false,
|
|
406
|
+
findings: [],
|
|
407
|
+
duration: Date.now() - start,
|
|
408
|
+
status: "CONFIGURATION-ERROR",
|
|
409
|
+
tool: "declarations",
|
|
410
|
+
reason: configurationErrors.join("; "),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
sortFindings(findings);
|
|
414
|
+
const excludedNotes = discovery.excluded.map((pkg) => {
|
|
415
|
+
return `${pkg.name ?? pkg.path} (${pkg.reason})`;
|
|
416
|
+
});
|
|
417
|
+
const notes = [
|
|
418
|
+
`${discovery.publishable.length} publishable package(s)`,
|
|
419
|
+
`${totalDeclarationFiles} declaration file(s) (${reachableDeclarationFiles} entry-reachable)`,
|
|
420
|
+
`${externalReferenceCount} external reference(s)`,
|
|
421
|
+
];
|
|
422
|
+
if (discovery.usedDefaultRoots)
|
|
423
|
+
notes.push("default package root was used");
|
|
424
|
+
if (excludedNotes.length > 0) {
|
|
425
|
+
notes.push(`excluded: ${excludedNotes.join(", ")}`);
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
executed: true,
|
|
429
|
+
exitCode: 0,
|
|
430
|
+
passed: findings.length === 0,
|
|
431
|
+
findings,
|
|
432
|
+
duration: Date.now() - start,
|
|
433
|
+
status: findings.length === 0 ? "PASS" : "FAIL",
|
|
434
|
+
tool: "declarations",
|
|
435
|
+
reason: notes.join("; "),
|
|
436
|
+
};
|
|
437
|
+
}
|
package/dist/exit-codes.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 0 = validation passed
|
|
5
5
|
* 1 = validation failed (violations found)
|
|
6
|
-
* 2 = configuration / discovery error (cannot run)
|
|
6
|
+
* 2 = configuration / discovery error (cannot run; may co-occur with violations)
|
|
7
7
|
* 3 = tool not installed (e.g. dependency-cruiser missing)
|
|
8
8
|
*/
|
|
9
9
|
export declare const ExitCode: Readonly<{
|
package/dist/exit-codes.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 0 = validation passed
|
|
5
5
|
* 1 = validation failed (violations found)
|
|
6
|
-
* 2 = configuration / discovery error (cannot run)
|
|
6
|
+
* 2 = configuration / discovery error (cannot run; may co-occur with violations)
|
|
7
7
|
* 3 = tool not installed (e.g. dependency-cruiser missing)
|
|
8
8
|
*/
|
|
9
9
|
export const ExitCode = Object.freeze({
|
package/dist/format.d.ts
CHANGED
package/dist/format.js
CHANGED
|
@@ -6,6 +6,7 @@ const CATEGORY_LABELS = {
|
|
|
6
6
|
eslint: "Source rules (ESLint)",
|
|
7
7
|
semgrep: "Structural patterns (Semgrep)",
|
|
8
8
|
adapterPeers: "Adapter peers",
|
|
9
|
+
declarations: "Declarations",
|
|
9
10
|
};
|
|
10
11
|
function statusBadge(cat) {
|
|
11
12
|
switch (cat.status) {
|
|
@@ -67,6 +68,9 @@ export function formatSummary(result) {
|
|
|
67
68
|
cat.status === "NOT-APPLICABLE") {
|
|
68
69
|
lines.push(` ${label}: ${cat.reason ?? cat.status}`);
|
|
69
70
|
}
|
|
71
|
+
else if (cat.status === "PASS" && cat.reason) {
|
|
72
|
+
lines.push(` ${label}: ${cat.reason}`);
|
|
73
|
+
}
|
|
70
74
|
}
|
|
71
75
|
lines.push("");
|
|
72
76
|
lines.push(`Result: ${overallResult(result)}`);
|
package/dist/run.d.ts
CHANGED
package/dist/run.js
CHANGED
|
@@ -2,6 +2,7 @@ import { dirname, resolve } from "node:path";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { discoverRepository } from "./discover.js";
|
|
4
4
|
import { runAdapterPeers } from "./engines/adapter-peers.js";
|
|
5
|
+
import { runDeclarationDependencies } from "./engines/declaration-dependencies.js";
|
|
5
6
|
import { runDepcruise } from "./engines/depcruise.js";
|
|
6
7
|
import { runEslint } from "./engines/eslint.js";
|
|
7
8
|
import { runSchema } from "./engines/schema.js";
|
|
@@ -115,6 +116,15 @@ export async function runValidation(options) {
|
|
|
115
116
|
else {
|
|
116
117
|
adapterResult = emptyEngineResult("adapter-peers engine skipped");
|
|
117
118
|
}
|
|
119
|
+
let declarationResult;
|
|
120
|
+
if (!only ||
|
|
121
|
+
only === "declarations" ||
|
|
122
|
+
only === "declaration-dependencies") {
|
|
123
|
+
declarationResult = runDeclarationDependencies(repo);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
declarationResult = emptyEngineResult("declarations engine skipped");
|
|
127
|
+
}
|
|
118
128
|
const violations = [];
|
|
119
129
|
for (const r of [
|
|
120
130
|
schemaResult,
|
|
@@ -122,12 +132,19 @@ export async function runValidation(options) {
|
|
|
122
132
|
eslintResult,
|
|
123
133
|
semgrepResult,
|
|
124
134
|
adapterResult,
|
|
135
|
+
declarationResult,
|
|
125
136
|
]) {
|
|
126
137
|
if (Array.isArray(r.findings)) {
|
|
127
138
|
violations.push(...r.findings);
|
|
128
139
|
}
|
|
129
140
|
}
|
|
130
|
-
const allExecuted = [
|
|
141
|
+
const allExecuted = [
|
|
142
|
+
depResult,
|
|
143
|
+
eslintResult,
|
|
144
|
+
semgrepResult,
|
|
145
|
+
adapterResult,
|
|
146
|
+
declarationResult,
|
|
147
|
+
];
|
|
131
148
|
const allPassed = allExecuted.every(categoryIsAcceptable) &&
|
|
132
149
|
categoryIsAcceptable(metaResult) &&
|
|
133
150
|
categoryIsAcceptable(configResult);
|
|
@@ -142,6 +159,7 @@ export async function runValidation(options) {
|
|
|
142
159
|
eslint: categoryOf(eslintResult),
|
|
143
160
|
semgrep: categoryOf(semgrepResult),
|
|
144
161
|
adapterPeers: categoryOf(adapterResult),
|
|
162
|
+
declarations: categoryOf(declarationResult),
|
|
145
163
|
},
|
|
146
164
|
};
|
|
147
165
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comity-dev/validate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Thin orchestrator over Comity shared validation tooling. Development tooling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -31,20 +31,20 @@
|
|
|
31
31
|
"./dist"
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@typescript-eslint/parser": "^8.69.0",
|
|
35
|
-
"dependency-cruiser": "^16.10.4",
|
|
36
|
-
"eslint": "^9.39.5",
|
|
37
34
|
"@comity-dev/schemas": "0.2.0",
|
|
38
35
|
"@comity-dev/dependency-rules": "0.2.0",
|
|
39
36
|
"@comity-dev/eslint-plugin": "0.3.0",
|
|
40
|
-
"@comity-dev/semgrep-rules": "0.2.
|
|
37
|
+
"@comity-dev/semgrep-rules": "0.2.3",
|
|
38
|
+
"@typescript-eslint/parser": "^8.69.0",
|
|
39
|
+
"dependency-cruiser": "^16.10.4",
|
|
40
|
+
"eslint": "^9.39.5",
|
|
41
|
+
"typescript": "^5.9.3"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
|
-
"@types/node": "^24.13.3"
|
|
44
|
-
"typescript": "^5.9.3"
|
|
44
|
+
"@types/node": "^24.13.3"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
|
-
"build": "tsc -p tsconfig.json",
|
|
47
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
48
48
|
"test": "vitest run"
|
|
49
49
|
}
|
|
50
50
|
}
|