ox-dep-lint 0.1.1 → 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/CHANGELOG.md +18 -0
- package/README.md +9 -0
- package/dist/{check-GClvJhK3.mjs → check-F4cLmfYv.mjs} +81 -26
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +2 -2
- package/dist/{config-CpuQhyZE.d.mts → config-DO_LRXM8.d.mts} +9 -0
- package/dist/{engine-7N-4EtzS.mjs → engine-Dc5tRuHT.mjs} +423 -148
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +2 -2
- package/dist/plugin.mjs +8 -10
- package/package.json +9 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7af0fef: An internal refactor with no change in behaviour for configurations written as plain data (objects, arrays and strings, without getters, Proxies or custom iterators): every finding and every message is the same as in 0.2.0, in the same order, and so is every configuration error. Output differs only where 0.2.0 crashed: a hole in a sparse `fileClasses` or `baseline` list (`[ , ]` in an `.mjs` file) is now reported as `fileClasses must be a list of objects` or `baseline must be a list of objects`, also when a Symbol elsewhere would have crashed first; and a chain of thousands of packages, which exhausted 0.2.0's stack, is now checked. A Symbol written into a message (a baseline entry's file, a kind in `mayUse`) still crashes (exit 2) when no such hole is present, though the TypeError's wording can differ when another value that cannot become a string (an object without a usable `toString`) is also present, because the checks run in another order. A configuration built from getters, Proxies or custom iterators can now be read differently, because validation reads values (including those of unknown keys) that 0.2.0 never read.
|
|
8
|
+
|
|
9
|
+
The configuration is validated by valibot schemas whose issues map to 0.2's messages, cycles between packages come from Tarjan's strongly connected components (iterative, so a long chain of packages cannot exhaust the stack) with the same routes as before, and the walk through development files reads its queue from a head index instead of `shift()`.
|
|
10
|
+
|
|
11
|
+
New runtime dependency: `valibot` 1.5.0 (MIT). The public types are unchanged and written out as before; the emitted declarations do not import valibot.
|
|
12
|
+
|
|
13
|
+
## 0.2.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 89ab407: New policy `imports.typeOnly`. With `"as-values"`, shipped code's type-only imports (every form, `/// <reference types>` included) are judged by `runtime-module`, `forbidden-external`, `undeclared-import` and `shipped-imports-development-file`. So `import type { Socket } from "bun"` fails in a kind with `runtimeModules: false`, and a vendor's types fail where the kind's `externals` do not allow it. A shipped `@types/x` still declares `x` for a type-only import. The default, `"relaxed"`, keeps 0.1's behaviour: types do not load, so those rules skip them.
|
|
18
|
+
|
|
19
|
+
A runtime's own types package (`node`, `@types/node`, `bun-types`, `@types/bun`, `@cloudflare/workers-types`, or a subpath of one) used type-only now counts as that runtime, in shipped and development files alike. So it is never reported as an outside package or as undeclared; until now an undeclared one gave `undeclared-import`. Under `"as-values"`, `runtime-module` judges it where the kind allows no runtime modules; under `"relaxed"` it passes, like other type-only imports.
|
|
20
|
+
|
|
3
21
|
## 0.1.1
|
|
4
22
|
|
|
5
23
|
- 0.1.0 was published without its built `dist` files, so neither the CLI nor the plugin could load. A `prepack` script now builds before every pack and publish, so a package cannot go out without them. Use 0.1.1.
|
package/README.md
CHANGED
|
@@ -79,6 +79,9 @@ A kind may use its own kind only when `mayUse` lists it. `imports` holds the pol
|
|
|
79
79
|
| `manifestEdgeFields` | all four fields | any subset |
|
|
80
80
|
| `requireDeclared` | `true` | `false` |
|
|
81
81
|
| `shippedFields` | `dependencies`, `peerDependencies`, `optionalDependencies` | any subset |
|
|
82
|
+
| `typeOnly` | `relaxed` | `as-values` |
|
|
83
|
+
|
|
84
|
+
`typeOnly: "as-values"` judges shipped code's type-only imports (`import type`, `export type`, type-level `import()`, `declare module`, `import type X = require()`, `/// <reference types>`) by `runtime-module`, `forbidden-external`, `undeclared-import` and `shipped-imports-development-file`, so `import type { Socket } from "bun"` fails in a kind without runtime modules. Two differences from value imports remain. A shipped field that declares `@types/x` declares `x` for a type-only import, and such an `@types/x` is an outside package too, so `externals` lists both. A runtime's own types package (`node` or `@types/node`, `bun`, `bun-types` or `@types/bun`, `@cloudflare/workers-types`) counts as that runtime, so it is judged by `runtime-module` and never needs declaring. That includes a published package whose emitted declarations reference those types: declare `@types/node` or `bun-types` for its consumers yourself; this check does not ask for it. By default types do not load, so those rules skip type-only imports. Kind edges and `typeOnlyUses` always see the real form.
|
|
82
85
|
|
|
83
86
|
Misspelled keys, unknown policies and baseline entries without a `target` are configuration errors, never silent defaults.
|
|
84
87
|
|
|
@@ -127,6 +130,12 @@ Where they can still differ: oxlint chooses which files it lints (its own ignore
|
|
|
127
130
|
- `/// <reference path="..." />` and JSDoc `@import` types are not read.
|
|
128
131
|
- A `require` passed around as a value (`const load = require; load("x")`) is not followed. Telling the global loader from a local of the same name needs scope analysis; until then the check reads `require` only where it is called.
|
|
129
132
|
|
|
133
|
+
## Contributing and releases
|
|
134
|
+
|
|
135
|
+
- Commits and pull request titles follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix:`, `feat:`, `chore:`); a `commit-msg` hook and CI check them with commitlint.
|
|
136
|
+
- Every pull request adds a changeset: `bunx changeset` and pick patch, minor or major, or `bunx changeset --empty` when nothing ships. Before 1.0, a breaking change is a minor bump.
|
|
137
|
+
- Versions follow [semantic versioning](https://semver.org/) and are never edited by hand. While changesets wait on main, the `release` workflow keeps a pull request `chore(release): vX.Y.Z` up to date with the bumped `package.json` and the new `CHANGELOG.md` entry. Merging it publishes that version to npm through trusted publishing (with provenance), tags it and makes a GitHub release. Empty changesets alone open no release; they are consumed with the next one.
|
|
138
|
+
|
|
130
139
|
## Licence
|
|
131
140
|
|
|
132
141
|
MIT. The specifier reader and resolver are adapted from phibkro/vektorprogrammet-reimagined, and the rules from that repository and phibkro/file-transfer, all by the same author. The escape-sequence rule closes a bypass found in reebeat/reebeat's tier check. No code from reebeat was copied.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as findingsForFile, c as withoutBaselined, n as createEngine, r as edgeRefusal, s as reads, t as baselineCovers, u as directoriesMatching } from "./engine-
|
|
1
|
+
import { a as findingsForFile, c as withoutBaselined, n as createEngine, r as edgeRefusal, s as reads, t as baselineCovers, u as directoriesMatching } from "./engine-Dc5tRuHT.mjs";
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
3
|
import nodePath from "node:path";
|
|
4
4
|
// src/graph.ts
|
|
@@ -87,37 +87,92 @@ const graphFindings = (engine) => {
|
|
|
87
87
|
findings.push(...packageCycles(engine));
|
|
88
88
|
return findings;
|
|
89
89
|
};
|
|
90
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* Tarjan's strongly connected components, iterative so that a long chain of packages cannot exhaust the stack. The
|
|
92
|
+
* search starts from `roots` in order and follows each node's `successors` in order, so components and routes are
|
|
93
|
+
* deterministic.
|
|
94
|
+
*/
|
|
95
|
+
const stronglyConnectedComponents = (roots, successors) => {
|
|
96
|
+
const index = /* @__PURE__ */ new Map();
|
|
97
|
+
const lowLink = /* @__PURE__ */ new Map();
|
|
98
|
+
const stack = [];
|
|
99
|
+
const onStack = /* @__PURE__ */ new Set();
|
|
100
|
+
const frames = [];
|
|
101
|
+
const pathPosition = /* @__PURE__ */ new Map();
|
|
102
|
+
const components = [];
|
|
103
|
+
const routes = [];
|
|
104
|
+
const enter = (name) => {
|
|
105
|
+
const order = index.size;
|
|
106
|
+
index.set(name, order);
|
|
107
|
+
lowLink.set(name, order);
|
|
108
|
+
stack.push(name);
|
|
109
|
+
onStack.add(name);
|
|
110
|
+
pathPosition.set(name, frames.length);
|
|
111
|
+
frames.push({
|
|
112
|
+
name,
|
|
113
|
+
next: 0
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
for (const root of roots) {
|
|
117
|
+
if (index.has(root)) continue;
|
|
118
|
+
enter(root);
|
|
119
|
+
for (let frame = frames.at(-1); frame !== void 0; frame = frames.at(-1)) {
|
|
120
|
+
const target = (successors.get(frame.name) ?? [])[frame.next];
|
|
121
|
+
if (target !== void 0) {
|
|
122
|
+
frame.next += 1;
|
|
123
|
+
if (!index.has(target)) enter(target);
|
|
124
|
+
else if (onStack.has(target)) {
|
|
125
|
+
lowLink.set(frame.name, Math.min(lowLink.get(frame.name) ?? 0, index.get(target) ?? 0));
|
|
126
|
+
const position = pathPosition.get(target);
|
|
127
|
+
if (position !== void 0) routes.push([...frames.slice(position).map((onPath) => onPath.name), target]);
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
frames.pop();
|
|
132
|
+
pathPosition.delete(frame.name);
|
|
133
|
+
const link = lowLink.get(frame.name) ?? 0;
|
|
134
|
+
if (link === index.get(frame.name)) {
|
|
135
|
+
const component = [];
|
|
136
|
+
for (let member = stack.pop(); member !== void 0; member = member === frame.name ? void 0 : stack.pop()) {
|
|
137
|
+
onStack.delete(member);
|
|
138
|
+
component.push(member);
|
|
139
|
+
}
|
|
140
|
+
components.push(component);
|
|
141
|
+
}
|
|
142
|
+
const parent = frames.at(-1);
|
|
143
|
+
if (parent !== void 0) lowLink.set(parent.name, Math.min(lowLink.get(parent.name) ?? 0, link));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
components,
|
|
148
|
+
routes
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* Cycles between workspace packages through their runtime dependency fields. Every route the search closes is a
|
|
153
|
+
* finding, once per set of packages, starting at the package the search reached first; so a component with several
|
|
154
|
+
* cycles can have several findings.
|
|
155
|
+
*/
|
|
91
156
|
const packageCycles = (engine) => {
|
|
92
157
|
const { workspace } = engine;
|
|
93
158
|
const edges = new Map(workspace.packages.map((entry) => [entry.name, [...new Set(runtimeFields.flatMap((field) => entry.manifest[field]))].filter((name) => workspace.byName.has(name) && name !== entry.name)]));
|
|
94
|
-
const state = /* @__PURE__ */ new Map();
|
|
95
159
|
const findings = [];
|
|
96
160
|
const seen = /* @__PURE__ */ new Set();
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
message: `dependency cycle: ${cycle.join(" -> ")}`,
|
|
111
|
-
target: key
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
return;
|
|
161
|
+
for (const cycle of stronglyConnectedComponents([...edges.keys()].toSorted(), edges).routes) {
|
|
162
|
+
const key = cycle.slice(0, -1).toSorted().join(" ");
|
|
163
|
+
if (!seen.has(key)) {
|
|
164
|
+
seen.add(key);
|
|
165
|
+
const first = cycle[0] ?? "";
|
|
166
|
+
findings.push({
|
|
167
|
+
rule: "package-cycle",
|
|
168
|
+
file: `${workspace.byName.get(first)?.directory ?? first}/package.json`,
|
|
169
|
+
line: 1,
|
|
170
|
+
column: 0,
|
|
171
|
+
message: `dependency cycle: ${cycle.join(" -> ")}`,
|
|
172
|
+
target: key
|
|
173
|
+
});
|
|
115
174
|
}
|
|
116
|
-
|
|
117
|
-
for (const next of edges.get(name) ?? []) visit(next, [...path, name]);
|
|
118
|
-
state.set(name, "done");
|
|
119
|
-
};
|
|
120
|
-
for (const name of [...edges.keys()].toSorted()) visit(name, []);
|
|
175
|
+
}
|
|
121
176
|
return findings;
|
|
122
177
|
};
|
|
123
178
|
/** Baseline entries that no finding in `findings` needs; each is a finding of its own, so the baseline only shrinks. */
|
package/dist/cli.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as Config } from "./config-
|
|
1
|
+
import { n as Config } from "./config-DO_LRXM8.mjs";
|
|
2
2
|
export declare const loadConfig: (path: string) => Promise<Config>;
|
package/dist/cli.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { d as findWorkspaceRoot } from "./engine-
|
|
3
|
-
import { n as formatFinding, t as checkWorkspace } from "./check-
|
|
2
|
+
import { d as findWorkspaceRoot } from "./engine-Dc5tRuHT.mjs";
|
|
3
|
+
import { n as formatFinding, t as checkWorkspace } from "./check-F4cLmfYv.mjs";
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
5
|
import nodePath from "node:path";
|
|
6
6
|
import process from "node:process";
|
|
@@ -63,6 +63,14 @@ interface ImportPolicy {
|
|
|
63
63
|
readonly requireDeclared?: boolean;
|
|
64
64
|
/** The fields that satisfy a shipped import. Default `dependencies`, `peerDependencies`, `optionalDependencies`. */
|
|
65
65
|
readonly shippedFields?: readonly ManifestField[];
|
|
66
|
+
/**
|
|
67
|
+
* How shipped code's type-only imports (`import type`, `export type`, a type-level `import()`, `declare module`, a
|
|
68
|
+
* types reference) are judged by `runtime-module`, `forbidden-external`, `undeclared-import` and
|
|
69
|
+
* `shipped-imports-development-file`. `"relaxed"` (default): types do not load, so those rules skip them and
|
|
70
|
+
* devDependencies declare them. `"as-values"`: judged like value imports, for kinds whose public types must not name
|
|
71
|
+
* a runtime or vendor either. Kind edges and `typeOnlyUses` always see the real form.
|
|
72
|
+
*/
|
|
73
|
+
readonly typeOnly?: "relaxed" | "as-values";
|
|
66
74
|
}
|
|
67
75
|
interface Config {
|
|
68
76
|
/** Scopes whose specifiers must name a workspace package (`@file-transfer/`). */
|
|
@@ -105,6 +113,7 @@ interface ResolvedConfig {
|
|
|
105
113
|
declare const defaultFileClasses: readonly FileClass[];
|
|
106
114
|
/** Identity, for type checking a configuration file. */
|
|
107
115
|
declare const defineConfig: (config: Config) => Config;
|
|
116
|
+
/** Problems that make a configuration unusable, as messages: the outline's alone when it does not hold, else every other. */
|
|
108
117
|
declare const configProblems: (config: Config) => string[];
|
|
109
118
|
declare const resolveConfig: (config: Config) => ResolvedConfig;
|
|
110
119
|
export { ImportPolicy as a, PackageRule as c, configProblems as d, defaultFileClasses as f, FilePolicy as i, ResolvedConfig as l, resolveConfig as m, Config as n, KindRule as o, defineConfig as p, FileClass as r, ManifestField as s, BaselineEntry as t, RuleName as u };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { builtinModules } from "node:module";
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
3
3
|
import nodePath from "node:path";
|
|
4
|
+
import * as v from "valibot";
|
|
4
5
|
import { ResolverFactory } from "oxc-resolver";
|
|
5
6
|
import { Visitor, parseSync } from "oxc-parser";
|
|
6
7
|
// src/config.ts
|
|
@@ -50,135 +51,367 @@ const allManifestFields = [
|
|
|
50
51
|
];
|
|
51
52
|
/** Identity, for type checking a configuration file. */
|
|
52
53
|
const defineConfig = (config) => config;
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
kind: [
|
|
68
|
-
"packages",
|
|
69
|
-
"mayUse",
|
|
70
|
-
"externals",
|
|
71
|
-
"runtimeModules"
|
|
72
|
-
],
|
|
73
|
-
package: [
|
|
74
|
-
"uses",
|
|
75
|
-
"externals",
|
|
76
|
-
"typeOnlyUses",
|
|
77
|
-
"valueSpecifiers",
|
|
78
|
-
"testSupportFiles"
|
|
79
|
-
],
|
|
80
|
-
fileClass: ["files", "policy"],
|
|
81
|
-
runtimeModules: ["prefixes", "names"],
|
|
82
|
-
imports: [
|
|
83
|
-
"computed",
|
|
84
|
-
"selfByName",
|
|
85
|
-
"unresolved",
|
|
86
|
-
"manifestEdgeFields",
|
|
87
|
-
"requireDeclared",
|
|
88
|
-
"shippedFields"
|
|
89
|
-
],
|
|
90
|
-
baseline: [
|
|
91
|
-
"file",
|
|
92
|
-
"rule",
|
|
93
|
-
"target",
|
|
94
|
-
"reason"
|
|
95
|
-
]
|
|
96
|
-
};
|
|
97
|
-
const choices = {
|
|
98
|
-
computed: [
|
|
54
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
55
|
+
const plainObject = v.custom(isPlainObject);
|
|
56
|
+
/** `schema` itself, which fails to type check unless it accepts exactly `Output`. */
|
|
57
|
+
const schemaOf = () => (schema) => schema;
|
|
58
|
+
const filePolicySchema = schemaOf()(v.picklist([
|
|
59
|
+
"shipped",
|
|
60
|
+
"development",
|
|
61
|
+
"unchecked"
|
|
62
|
+
]));
|
|
63
|
+
const manifestFieldSchema = schemaOf()(v.picklist(allManifestFields));
|
|
64
|
+
const ruleNameSchema = schemaOf()(v.picklist(ruleNames));
|
|
65
|
+
/** The values each import policy may take. */
|
|
66
|
+
const policies = {
|
|
67
|
+
computed: schemaOf()(v.picklist([
|
|
99
68
|
"error-in-shipped",
|
|
100
69
|
"error",
|
|
101
70
|
"allow"
|
|
102
|
-
],
|
|
103
|
-
selfByName: ["error", "allow"],
|
|
104
|
-
unresolved: ["error", "ignore"],
|
|
105
|
-
requireDeclared:
|
|
71
|
+
])),
|
|
72
|
+
selfByName: schemaOf()(v.picklist(["error", "allow"])),
|
|
73
|
+
unresolved: schemaOf()(v.picklist(["error", "ignore"])),
|
|
74
|
+
requireDeclared: schemaOf()(v.boolean()),
|
|
75
|
+
typeOnly: schemaOf()(v.picklist(["relaxed", "as-values"]))
|
|
106
76
|
};
|
|
77
|
+
/** 0.2's test for a list of strings. `every` skips the holes of a sparse array, where `v.array` reads `undefined`. */
|
|
107
78
|
const isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
108
|
-
const
|
|
109
|
-
/**
|
|
110
|
-
const
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
79
|
+
const stringList = v.custom(isStringList);
|
|
80
|
+
/** A list of manifest fields, holes skipped as in 0.2. */
|
|
81
|
+
const manifestFieldList = v.custom((value) => isStringList(value) && value.every((field) => v.is(manifestFieldSchema, field)));
|
|
82
|
+
/**
|
|
83
|
+
* A value in a message, written as 0.2 wrote it: a template literal, which throws for a Symbol. 0.2 threw that way
|
|
84
|
+
* (exit 2 with the TypeError's message), so the check still does.
|
|
85
|
+
*/
|
|
86
|
+
const asText = (value) => `${value}`;
|
|
87
|
+
/**
|
|
88
|
+
* Every own entry of an object, each value checked by `value`. Not `v.record`, which skips the keys `constructor`,
|
|
89
|
+
* `prototype` and `__proto__` that 0.2 checked. In an issue's path, the entry is its index and then 1.
|
|
90
|
+
*/
|
|
91
|
+
const ownEntries = (value) => v.pipe(plainObject, v.transform((input) => Object.entries(input)), v.array(v.tuple([v.string(), value])));
|
|
92
|
+
/** 0.2's outline test for a list of objects, which skips holes as `every` does. */
|
|
93
|
+
const objectList = v.custom((value) => Array.isArray(value) && value.every(isPlainObject));
|
|
94
|
+
/** The outline (objects where objects belong, lists where lists belong), checked first: nothing else is read until it holds. */
|
|
95
|
+
const outlineSchema = v.pipe(plainObject, v.looseObject({
|
|
96
|
+
kinds: ownEntries(plainObject),
|
|
97
|
+
packages: v.optional(ownEntries(plainObject)),
|
|
98
|
+
imports: v.optional(plainObject),
|
|
99
|
+
runtimeModules: v.optional(plainObject),
|
|
100
|
+
fileClasses: v.optional(objectList),
|
|
101
|
+
baseline: v.optional(objectList)
|
|
102
|
+
}));
|
|
103
|
+
const kindRuleSchema = v.strictObject({
|
|
104
|
+
packages: v.pipe(v.array(v.unknown()), v.minLength(1)),
|
|
105
|
+
mayUse: v.union([v.literal("any"), stringList]),
|
|
106
|
+
externals: v.optional(v.union([v.literal("any"), stringList])),
|
|
107
|
+
runtimeModules: v.optional(v.boolean())
|
|
108
|
+
});
|
|
109
|
+
const packageRuleSchema = v.strictObject({
|
|
110
|
+
uses: v.exactOptional(stringList),
|
|
111
|
+
externals: v.exactOptional(stringList),
|
|
112
|
+
typeOnlyUses: v.exactOptional(stringList),
|
|
113
|
+
valueSpecifiers: v.exactOptional(stringList),
|
|
114
|
+
testSupportFiles: v.exactOptional(stringList)
|
|
115
|
+
});
|
|
116
|
+
const fileClassSchema = v.strictObject({
|
|
117
|
+
files: stringList,
|
|
118
|
+
policy: filePolicySchema
|
|
119
|
+
});
|
|
120
|
+
const runtimeModulesSchema = v.strictObject({
|
|
121
|
+
prefixes: v.exactOptional(stringList),
|
|
122
|
+
names: v.exactOptional(stringList)
|
|
123
|
+
});
|
|
124
|
+
const importPolicySchema = v.strictObject({
|
|
125
|
+
computed: v.optional(policies.computed),
|
|
126
|
+
selfByName: v.optional(policies.selfByName),
|
|
127
|
+
unresolved: v.optional(policies.unresolved),
|
|
128
|
+
manifestEdgeFields: v.optional(manifestFieldList),
|
|
129
|
+
requireDeclared: v.optional(policies.requireDeclared),
|
|
130
|
+
shippedFields: v.optional(manifestFieldList),
|
|
131
|
+
typeOnly: v.optional(policies.typeOnly)
|
|
132
|
+
});
|
|
133
|
+
const baselineEntrySchema = v.strictObject({
|
|
134
|
+
file: v.optional(v.unknown()),
|
|
135
|
+
rule: ruleNameSchema,
|
|
136
|
+
target: v.pipe(v.string(), v.nonEmpty()),
|
|
137
|
+
reason: v.pipe(v.string(), v.check((reason) => reason.trim() !== ""))
|
|
138
|
+
});
|
|
139
|
+
const configSchema = v.strictObject({
|
|
140
|
+
workspaceScopes: stringList,
|
|
141
|
+
kinds: ownEntries(kindRuleSchema),
|
|
142
|
+
packages: v.optional(ownEntries(packageRuleSchema)),
|
|
143
|
+
fileClasses: v.optional(v.array(fileClassSchema)),
|
|
144
|
+
runtimeModules: v.optional(runtimeModulesSchema),
|
|
145
|
+
virtualModules: v.optional(stringList),
|
|
146
|
+
testOnlySpecifiers: v.optional(stringList),
|
|
147
|
+
imports: v.optional(importPolicySchema),
|
|
148
|
+
baseline: v.optional(v.array(baselineEntrySchema)),
|
|
149
|
+
ignore: v.optional(stringList),
|
|
150
|
+
output: v.optional(stringList)
|
|
151
|
+
});
|
|
152
|
+
/** The sections of the messages, in 0.2's order. */
|
|
153
|
+
const section = {
|
|
154
|
+
configurationKeys: 0,
|
|
155
|
+
kinds: 1,
|
|
156
|
+
packages: 2,
|
|
157
|
+
workspaceScopes: 3,
|
|
158
|
+
testOnlySpecifiers: 4,
|
|
159
|
+
virtualModules: 5,
|
|
160
|
+
ignore: 6,
|
|
161
|
+
output: 7,
|
|
162
|
+
runtimeModuleValues: 8,
|
|
163
|
+
importFields: 9,
|
|
164
|
+
fileClasses: 10,
|
|
165
|
+
runtimeModuleKeys: 11,
|
|
166
|
+
importKeys: 12,
|
|
167
|
+
importPolicies: 13,
|
|
168
|
+
baseline: 14
|
|
169
|
+
};
|
|
170
|
+
/** Within a kind: its unknown keys, then these fields, then the kinds it names that do not exist. */
|
|
171
|
+
const kindFieldOrder = [
|
|
172
|
+
"packages",
|
|
173
|
+
"mayUse",
|
|
174
|
+
"runtimeModules",
|
|
175
|
+
"externals"
|
|
176
|
+
];
|
|
177
|
+
const importFieldOrder = ["manifestEdgeFields", "shippedFields"];
|
|
178
|
+
const importPolicyOrder = [
|
|
179
|
+
"computed",
|
|
180
|
+
"selfByName",
|
|
181
|
+
"unresolved",
|
|
182
|
+
"requireDeclared",
|
|
183
|
+
"typeOnly"
|
|
184
|
+
];
|
|
185
|
+
const baselineFieldOrder = [
|
|
186
|
+
"file",
|
|
187
|
+
"rule",
|
|
188
|
+
"target",
|
|
189
|
+
"reason"
|
|
190
|
+
];
|
|
191
|
+
/** The values a policy may take, as its message lists them. */
|
|
192
|
+
const policyValues = (field) => {
|
|
193
|
+
const schema = policies[field];
|
|
194
|
+
return "options" in schema ? schema.options : [true, false];
|
|
195
|
+
};
|
|
196
|
+
/** One message per key of `object` that `entries` does not know, in the object's order. */
|
|
197
|
+
const unknownKeyProblems = (where, object, entries, order) => Object.keys(object).flatMap((key, position) => Object.hasOwn(entries, key) ? [] : [{
|
|
198
|
+
order: [...order, position],
|
|
199
|
+
message: `${where} has the unknown key ${key} (known: ${Object.keys(entries).join(", ")})`
|
|
200
|
+
}]);
|
|
201
|
+
/** For an object whose every value is a list of strings: the unknown keys whose value is not one, in the object's order. */
|
|
202
|
+
const unknownListProblems = (object, entries, order, message) => Object.entries(object).flatMap(([key, value], position) => Object.hasOwn(entries, key) || v.is(stringList, value) ? [] : [{
|
|
203
|
+
order: [...order, position],
|
|
204
|
+
message: message(key)
|
|
205
|
+
}]);
|
|
206
|
+
/** The value an issue's path passes through at `index`, as an object (an entry of `ownEntries` is a pair). */
|
|
207
|
+
const objectAt = (path, index) => {
|
|
208
|
+
const value = path[index]?.value;
|
|
209
|
+
return isPlainObject(value) ? value : {};
|
|
210
|
+
};
|
|
211
|
+
/** The key and value of the `ownEntries` entry at `index` of an issue's path. */
|
|
212
|
+
const entryAt = (path, index) => {
|
|
213
|
+
const pair = path[index]?.value;
|
|
214
|
+
const [name, value] = Array.isArray(pair) ? pair : [];
|
|
215
|
+
return {
|
|
216
|
+
name: String(name),
|
|
217
|
+
value: isPlainObject(value) ? value : {}
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
const outlineMessage = (path) => {
|
|
221
|
+
const field = path[0]?.key;
|
|
222
|
+
if (field === void 0) return "the configuration must be an object";
|
|
223
|
+
if (field === "kinds") return path.length === 1 ? "kinds must map each kind to its rule" : `kind ${entryAt(path, 1).name} must be an object`;
|
|
224
|
+
if (field === "packages") return path.length === 1 ? "packages must map package names to their rules" : `package ${entryAt(path, 1).name} must be an object`;
|
|
225
|
+
if (field === "imports" || field === "runtimeModules") return `${field} must be an object`;
|
|
226
|
+
return `${field === "fileClasses" ? "fileClasses" : "baseline"} must be a list of objects`;
|
|
227
|
+
};
|
|
228
|
+
const kindProblems = (path, unknownKey, holder) => {
|
|
229
|
+
const index = Number(path[1]?.key);
|
|
230
|
+
const { name } = entryAt(path, 1);
|
|
231
|
+
if (unknownKey) return unknownKeyProblems(`kind ${name}`, holder, kindRuleSchema.entries, [
|
|
232
|
+
section.kinds,
|
|
233
|
+
index,
|
|
234
|
+
0
|
|
235
|
+
]);
|
|
236
|
+
const field = String(path[3]?.key);
|
|
237
|
+
const message = {
|
|
238
|
+
packages: `kind ${name} names no packages`,
|
|
239
|
+
mayUse: `kind ${name}: mayUse must be "any" or a list of kinds`,
|
|
240
|
+
runtimeModules: `kind ${name}: runtimeModules must be true or false`,
|
|
241
|
+
externals: `kind ${name}: externals must be "any" or a list of package names`
|
|
242
|
+
}[field];
|
|
243
|
+
return message === void 0 ? [] : [{
|
|
244
|
+
order: [
|
|
245
|
+
section.kinds,
|
|
246
|
+
index,
|
|
247
|
+
kindFieldOrder.indexOf(field) + 1
|
|
248
|
+
],
|
|
249
|
+
message
|
|
250
|
+
}];
|
|
251
|
+
};
|
|
252
|
+
/** The kinds a kind may use that are not kinds: a reference across entries, which no schema of one entry sees. */
|
|
253
|
+
const kindReferenceProblems = (kinds) => {
|
|
254
|
+
const known = new Set(Object.keys(kinds));
|
|
115
255
|
const problems = [];
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
256
|
+
for (const [index, [name, kind]] of Object.entries(kinds).entries()) {
|
|
257
|
+
const mayUse = isPlainObject(kind) ? kind.mayUse : void 0;
|
|
258
|
+
if (!Array.isArray(mayUse)) continue;
|
|
259
|
+
for (const [position, used] of [...mayUse].entries()) if (typeof used !== "string" || !known.has(used)) problems.push({
|
|
260
|
+
order: [
|
|
261
|
+
section.kinds,
|
|
262
|
+
index,
|
|
263
|
+
kindFieldOrder.length + 1,
|
|
264
|
+
position
|
|
265
|
+
],
|
|
266
|
+
message: `kind ${name} may use ${asText(used)}, which is not a kind`
|
|
267
|
+
});
|
|
268
|
+
}
|
|
122
269
|
return problems;
|
|
123
270
|
};
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
271
|
+
const packageProblems = (path, unknownKey, holder) => {
|
|
272
|
+
const index = Number(path[1]?.key);
|
|
273
|
+
const { name, value } = entryAt(path, 1);
|
|
274
|
+
const listMessage = (key) => `package ${name}: ${key} must be a list of strings`;
|
|
275
|
+
if (unknownKey) return [...unknownKeyProblems(`package ${name}`, holder, packageRuleSchema.entries, [
|
|
276
|
+
section.packages,
|
|
277
|
+
index,
|
|
278
|
+
0
|
|
279
|
+
]), ...unknownListProblems(holder, packageRuleSchema.entries, [
|
|
280
|
+
section.packages,
|
|
281
|
+
index,
|
|
282
|
+
1
|
|
283
|
+
], listMessage)];
|
|
284
|
+
const field = String(path[3]?.key);
|
|
285
|
+
return [{
|
|
286
|
+
order: [
|
|
287
|
+
section.packages,
|
|
288
|
+
index,
|
|
289
|
+
1,
|
|
290
|
+
Object.keys(value).indexOf(field)
|
|
291
|
+
],
|
|
292
|
+
message: listMessage(field)
|
|
293
|
+
}];
|
|
294
|
+
};
|
|
295
|
+
const runtimeModulesProblems = (path, unknownKey, holder) => {
|
|
296
|
+
const listMessage = (key) => `runtimeModules.${key} must be a list of strings`;
|
|
297
|
+
if (unknownKey) return [...unknownKeyProblems("runtimeModules", holder, runtimeModulesSchema.entries, [section.runtimeModuleKeys]), ...unknownListProblems(holder, runtimeModulesSchema.entries, [section.runtimeModuleValues], listMessage)];
|
|
298
|
+
const field = String(path[1]?.key);
|
|
299
|
+
return [{
|
|
300
|
+
order: [section.runtimeModuleValues, Object.keys(objectAt(path, 0)).indexOf(field)],
|
|
301
|
+
message: listMessage(field)
|
|
302
|
+
}];
|
|
303
|
+
};
|
|
304
|
+
const importProblems = (path, unknownKey, holder) => {
|
|
305
|
+
if (unknownKey) return unknownKeyProblems("imports", holder, importPolicySchema.entries, [section.importKeys]);
|
|
306
|
+
const field = String(path[1]?.key);
|
|
307
|
+
if (importFieldOrder.includes(field)) return [{
|
|
308
|
+
order: [section.importFields, importFieldOrder.indexOf(field)],
|
|
309
|
+
message: `imports.${field} must be a list of ${allManifestFields.join(", ")}`
|
|
310
|
+
}];
|
|
311
|
+
return [{
|
|
312
|
+
order: [section.importPolicies, importPolicyOrder.indexOf(field)],
|
|
313
|
+
message: `imports.${field} is ${JSON.stringify(path[1]?.value)}; it must be one of ${policyValues(field).join(", ")}`
|
|
314
|
+
}];
|
|
315
|
+
};
|
|
316
|
+
const fileClassProblems = (path, unknownKey, holder) => {
|
|
317
|
+
const index = Number(path[1]?.key);
|
|
318
|
+
if (unknownKey) return unknownKeyProblems(`file class ${index}`, holder, fileClassSchema.entries, [
|
|
319
|
+
section.fileClasses,
|
|
320
|
+
index,
|
|
321
|
+
0
|
|
322
|
+
]);
|
|
323
|
+
return path[2]?.key === "files" ? [{
|
|
324
|
+
order: [
|
|
325
|
+
section.fileClasses,
|
|
326
|
+
index,
|
|
327
|
+
1
|
|
328
|
+
],
|
|
329
|
+
message: `file class ${index}: files must be a list of globs`
|
|
330
|
+
}] : [{
|
|
331
|
+
order: [
|
|
332
|
+
section.fileClasses,
|
|
333
|
+
index,
|
|
334
|
+
2
|
|
335
|
+
],
|
|
336
|
+
message: `file class ${index} has the unknown policy ${String(objectAt(path, 1).policy)}`
|
|
337
|
+
}];
|
|
338
|
+
};
|
|
339
|
+
const baselineProblems = (path, unknownKey, holder) => {
|
|
340
|
+
const index = Number(path[1]?.key);
|
|
341
|
+
const entry = objectAt(path, 1);
|
|
342
|
+
const file = asText(entry.file);
|
|
343
|
+
if (unknownKey) return unknownKeyProblems(`the baseline entry for ${file}`, holder, baselineEntrySchema.entries, [
|
|
344
|
+
section.baseline,
|
|
345
|
+
index,
|
|
346
|
+
0
|
|
347
|
+
]);
|
|
348
|
+
const field = String(path[2]?.key);
|
|
349
|
+
const message = {
|
|
350
|
+
rule: () => `the baseline entry for ${file} names the unknown rule ${String(entry.rule)}`,
|
|
351
|
+
target: () => `the baseline entry for ${asText(entry.rule)} in ${file} names no target, so it would hide every future finding of that rule there`,
|
|
352
|
+
reason: () => `the baseline entry for ${asText(entry.rule)} in ${file} has no reason`
|
|
353
|
+
}[field]?.();
|
|
354
|
+
return message === void 0 ? [] : [{
|
|
355
|
+
order: [
|
|
356
|
+
section.baseline,
|
|
357
|
+
index,
|
|
358
|
+
baselineFieldOrder.indexOf(field)
|
|
359
|
+
],
|
|
360
|
+
message
|
|
361
|
+
}];
|
|
362
|
+
};
|
|
363
|
+
/** The messages for one issue of `configSchema`. */
|
|
364
|
+
const problemsOf = (issue) => {
|
|
365
|
+
const path = issue.path ?? [];
|
|
366
|
+
const unknownKey = issue.type === "strict_object" && issue.expected === "never";
|
|
367
|
+
const at = unknownKey ? path.slice(0, -1) : path;
|
|
368
|
+
const holder = unknownKey ? path.at(-1)?.input : void 0;
|
|
369
|
+
const object = isPlainObject(holder) ? holder : {};
|
|
370
|
+
const field = at[0]?.key;
|
|
371
|
+
switch (field) {
|
|
372
|
+
case void 0: return unknownKey ? unknownKeyProblems("the configuration", object, configSchema.entries, [section.configurationKeys]) : [];
|
|
373
|
+
case "kinds": return kindProblems(path, unknownKey, object);
|
|
374
|
+
case "packages": return packageProblems(path, unknownKey, object);
|
|
375
|
+
case "runtimeModules": return runtimeModulesProblems(path, unknownKey, object);
|
|
376
|
+
case "imports": return importProblems(path, unknownKey, object);
|
|
377
|
+
case "fileClasses": return fileClassProblems(path, unknownKey, object);
|
|
378
|
+
case "baseline": return baselineProblems(path, unknownKey, object);
|
|
379
|
+
case "workspaceScopes": return [{
|
|
380
|
+
order: [section.workspaceScopes],
|
|
381
|
+
message: "workspaceScopes must be a list of scopes"
|
|
382
|
+
}];
|
|
383
|
+
case "testOnlySpecifiers":
|
|
384
|
+
case "virtualModules":
|
|
385
|
+
case "ignore":
|
|
386
|
+
case "output": return [{
|
|
387
|
+
order: [section[field]],
|
|
388
|
+
message: `${field} must be a list of strings`
|
|
389
|
+
}];
|
|
390
|
+
default: return [];
|
|
174
391
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (
|
|
392
|
+
};
|
|
393
|
+
const compareOrders = (left, right) => {
|
|
394
|
+
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
|
395
|
+
const difference = (left[index] ?? -1) - (right[index] ?? -1);
|
|
396
|
+
if (difference !== 0) return difference;
|
|
180
397
|
}
|
|
181
|
-
return
|
|
398
|
+
return 0;
|
|
399
|
+
};
|
|
400
|
+
/** Problems that make a configuration unusable, as messages: the outline's alone when it does not hold, else every other. */
|
|
401
|
+
const configProblems = (config) => {
|
|
402
|
+
const outline = v.safeParse(outlineSchema, config);
|
|
403
|
+
if (!outline.success) return [...new Set(outline.issues.map((issue) => outlineMessage(issue.path ?? [])))];
|
|
404
|
+
const sparse = ["fileClasses", "baseline"].filter((key) => [...config[key] ?? []].includes(void 0));
|
|
405
|
+
if (sparse.length > 0) return sparse.map((key) => `${key} must be a list of objects`);
|
|
406
|
+
for (const entry of config.baseline ?? []) asText(entry.file);
|
|
407
|
+
const problems = [...(v.safeParse(configSchema, config).issues ?? []).flatMap(problemsOf), ...kindReferenceProblems(config.kinds)].toSorted((left, right) => compareOrders(left.order, right.order));
|
|
408
|
+
const seen = /* @__PURE__ */ new Set();
|
|
409
|
+
return problems.flatMap((problem) => {
|
|
410
|
+
const key = problem.order.join(" ");
|
|
411
|
+
if (seen.has(key)) return [];
|
|
412
|
+
seen.add(key);
|
|
413
|
+
return [problem.message];
|
|
414
|
+
});
|
|
182
415
|
};
|
|
183
416
|
const resolveConfig = (config) => {
|
|
184
417
|
const problems = configProblems(config);
|
|
@@ -213,7 +446,8 @@ const resolveConfig = (config) => {
|
|
|
213
446
|
"dependencies",
|
|
214
447
|
"peerDependencies",
|
|
215
448
|
"optionalDependencies"
|
|
216
|
-
]
|
|
449
|
+
],
|
|
450
|
+
typeOnly: config.imports?.typeOnly ?? "relaxed"
|
|
217
451
|
},
|
|
218
452
|
baseline: config.baseline ?? [],
|
|
219
453
|
ignore: config.ignore ?? [],
|
|
@@ -267,20 +501,36 @@ const globExpression = (pattern) => {
|
|
|
267
501
|
const matchesGlob = (path, pattern) => globExpression(pattern).test(path);
|
|
268
502
|
/** Whether `path` matches any of `patterns`. */
|
|
269
503
|
const matchesAny = (path, patterns) => patterns.some((pattern) => matchesGlob(path, pattern));
|
|
504
|
+
// src/result.ts
|
|
505
|
+
const success = (value) => ({
|
|
506
|
+
ok: true,
|
|
507
|
+
value
|
|
508
|
+
});
|
|
509
|
+
const failure = (error) => ({
|
|
510
|
+
ok: false,
|
|
511
|
+
error
|
|
512
|
+
});
|
|
513
|
+
/** Runs `operation`, with what it throws as the failure. */
|
|
514
|
+
const attempt = (operation) => {
|
|
515
|
+
try {
|
|
516
|
+
return success(operation());
|
|
517
|
+
} catch (cause) {
|
|
518
|
+
return failure(cause);
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
/** The message of something thrown, which need not be an Error. */
|
|
522
|
+
const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
270
523
|
// src/workspace.ts
|
|
271
524
|
/**
|
|
272
525
|
* The workspace as `package.json` files declare it: the root's `workspaces` globs, and each package's real name (never
|
|
273
526
|
* guessed from its directory) and dependency fields.
|
|
274
527
|
*/
|
|
528
|
+
/** The manifest at `path`, or why it cannot be read. */
|
|
275
529
|
const readManifest = (path) => {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
throw new Error(`${path}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
281
|
-
}
|
|
282
|
-
if (typeof parsed !== "object" || parsed === null) throw new Error(`${path} is not a JSON object`);
|
|
283
|
-
return parsed;
|
|
530
|
+
const parsed = attempt(() => JSON.parse(readFileSync(path, "utf8")));
|
|
531
|
+
if (!parsed.ok) return failure(`${path}: ${messageOf(parsed.error)}`);
|
|
532
|
+
if (typeof parsed.value !== "object" || parsed.value === null) return failure(`${path} is not a JSON object`);
|
|
533
|
+
return success(parsed.value);
|
|
284
534
|
};
|
|
285
535
|
const namesOf = (field) => typeof field === "object" && field !== null && !Array.isArray(field) ? Object.keys(field).toSorted() : [];
|
|
286
536
|
const fieldsOf = (manifest) => ({
|
|
@@ -308,12 +558,9 @@ const workspaceGlobs = (manifest) => {
|
|
|
308
558
|
if (Array.isArray(field)) return field.filter((entry) => typeof entry === "string");
|
|
309
559
|
if (typeof field === "object" && field !== null && "packages" in field && Array.isArray(field.packages)) return field.packages.filter((entry) => typeof entry === "string");
|
|
310
560
|
};
|
|
311
|
-
const workspacesOf = (
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
} catch {
|
|
315
|
-
return [];
|
|
316
|
-
}
|
|
561
|
+
const workspacesOf = (path) => {
|
|
562
|
+
const manifest = readManifest(path);
|
|
563
|
+
return manifest.ok ? workspaceGlobs(manifest.value) ?? [] : [];
|
|
317
564
|
};
|
|
318
565
|
/** Whether `globs` (from the manifest in `candidate`) hold `directory`: one of them matches it or a directory above it. */
|
|
319
566
|
const holds = (candidate, globs, directory) => {
|
|
@@ -363,7 +610,9 @@ const directoriesMatching = (root, pattern) => {
|
|
|
363
610
|
return found;
|
|
364
611
|
};
|
|
365
612
|
const loadWorkspace = (root) => {
|
|
366
|
-
const
|
|
613
|
+
const rootRead = readManifest(nodePath.join(root, "package.json"));
|
|
614
|
+
if (!rootRead.ok) throw new Error(rootRead.error);
|
|
615
|
+
const rootManifest = rootRead.value;
|
|
367
616
|
const globs = workspaceGlobs(rootManifest) ?? [];
|
|
368
617
|
const directories = /* @__PURE__ */ new Set();
|
|
369
618
|
for (const pattern of globs.filter((glob) => !glob.startsWith("!"))) for (const directory of directoriesMatching(root, pattern)) if (existsSync(nodePath.join(root, directory, "package.json"))) directories.add(directory);
|
|
@@ -372,16 +621,15 @@ const loadWorkspace = (root) => {
|
|
|
372
621
|
const problems = [];
|
|
373
622
|
const byName = /* @__PURE__ */ new Map();
|
|
374
623
|
for (const directory of [...directories].toSorted()) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
manifest = readManifest(nodePath.join(root, directory, "package.json"));
|
|
378
|
-
} catch (error) {
|
|
624
|
+
const read = readManifest(nodePath.join(root, directory, "package.json"));
|
|
625
|
+
if (!read.ok) {
|
|
379
626
|
problems.push({
|
|
380
627
|
directory,
|
|
381
|
-
message: `package.json cannot be read: ${
|
|
628
|
+
message: `package.json cannot be read: ${read.error}`
|
|
382
629
|
});
|
|
383
630
|
continue;
|
|
384
631
|
}
|
|
632
|
+
const manifest = read.value;
|
|
385
633
|
if (typeof manifest.name !== "string" || manifest.name === "") {
|
|
386
634
|
problems.push({
|
|
387
635
|
directory,
|
|
@@ -800,6 +1048,19 @@ const importForms = (path, source) => {
|
|
|
800
1048
|
* it over every file and adds the whole-graph checks (`graph.ts`), so the two never disagree. Rules adapted from the
|
|
801
1049
|
* module graph checks of phibkro/file-transfer, phibkro/vektorprogrammet-reimagined and reebeat/reebeat.
|
|
802
1050
|
*/
|
|
1051
|
+
const nodeModules = ["node:module", ...builtinModules];
|
|
1052
|
+
/**
|
|
1053
|
+
* Packages that hold a runtime's types, and the runtime modules they describe: the package counts as the runtime when
|
|
1054
|
+
* the configuration names any of them a runtime module (Node's by its `node:` prefix or any bare built-in).
|
|
1055
|
+
*/
|
|
1056
|
+
const runtimeTypePackages = {
|
|
1057
|
+
node: nodeModules,
|
|
1058
|
+
"@types/node": nodeModules,
|
|
1059
|
+
bun: ["bun"],
|
|
1060
|
+
"bun-types": ["bun"],
|
|
1061
|
+
"@types/bun": ["bun"],
|
|
1062
|
+
"@cloudflare/workers-types": ["cloudflare:workers"]
|
|
1063
|
+
};
|
|
803
1064
|
/** The source files the engine reads; anything else (`.vue`, `.json`) is no finding in either front end. */
|
|
804
1065
|
const sourceFile = /\.[cm]?[jt]sx?$/u;
|
|
805
1066
|
const createEngine = (root, config) => {
|
|
@@ -887,9 +1148,11 @@ const developmentFileBreaks = (engine, file) => {
|
|
|
887
1148
|
if (known !== void 0 && [...known.read].every(([path, time]) => modifiedTime(nodePath.join(engine.workspace.root, path)) === time)) return known.breaks;
|
|
888
1149
|
const read = /* @__PURE__ */ new Map();
|
|
889
1150
|
const queue = [file];
|
|
1151
|
+
let head = 0;
|
|
890
1152
|
let breaks;
|
|
891
|
-
while (queue.length
|
|
892
|
-
const next = queue
|
|
1153
|
+
while (head < queue.length && breaks === void 0) {
|
|
1154
|
+
const next = queue[head] ?? file;
|
|
1155
|
+
head += 1;
|
|
893
1156
|
const absolute = nodePath.join(engine.workspace.root, next);
|
|
894
1157
|
const time = modifiedTime(absolute);
|
|
895
1158
|
if (read.has(next) || time === void 0) continue;
|
|
@@ -963,10 +1226,22 @@ const findingsForFile = (engine, file, text, options = {}) => {
|
|
|
963
1226
|
column: 0
|
|
964
1227
|
}, `could not be parsed, so its imports cannot be checked: ${message}`, "parse");
|
|
965
1228
|
for (const site of forms.computed) if (config.imports.computed === "error" || config.imports.computed === "error-in-shipped" && shipped) report("computed-import", site, `${site.text}: ${site.reason}; nothing can check what it loads`, site.text);
|
|
1229
|
+
/**
|
|
1230
|
+
* The packages that hold a runtime's own types, by the runtime module they describe: a type-only use of one
|
|
1231
|
+
* (`/// <reference types="node" />`, `import type {} from "bun-types"`) is a use of that runtime, judged by
|
|
1232
|
+
* `runtime-module` like `node:fs` and never as an outside package to declare.
|
|
1233
|
+
*/
|
|
1234
|
+
const runtimeOf = (site) => {
|
|
1235
|
+
return (site.typeOnly ? runtimeTypePackages[packageNameOf(site.specifier)] : void 0)?.find((module) => isRuntimeModule(config, module));
|
|
1236
|
+
};
|
|
966
1237
|
for (const site of forms.imports) {
|
|
967
|
-
const
|
|
1238
|
+
const typeOnly = site.typeOnly && config.imports.typeOnly === "relaxed";
|
|
968
1239
|
const specifier = withoutQuery(site.specifier);
|
|
969
|
-
const
|
|
1240
|
+
const runtime = runtimeOf(site);
|
|
1241
|
+
const target = runtime === void 0 ? engine.resolver.resolve(file, site.specifier) : {
|
|
1242
|
+
kind: "runtime",
|
|
1243
|
+
name: runtime
|
|
1244
|
+
};
|
|
970
1245
|
const resolvedName = target.kind === "external" || target.kind === "workspace" ? target.name : void 0;
|
|
971
1246
|
const subpathOf = (name) => specifier === name || specifier.startsWith(`${name}/`);
|
|
972
1247
|
const names = resolvedName === void 0 || subpathOf(resolvedName) ? [specifier] : [specifier, resolvedName];
|
|
@@ -982,7 +1257,7 @@ const findingsForFile = (engine, file, text, options = {}) => {
|
|
|
982
1257
|
};
|
|
983
1258
|
switch (target.kind) {
|
|
984
1259
|
case "runtime":
|
|
985
|
-
if (shipped && !typeOnly && kindRule?.runtimeModules === false) report("runtime-module", site, `imports the runtime module ${specifier}
|
|
1260
|
+
if (shipped && !typeOnly && kindRule?.runtimeModules === false) report("runtime-module", site, `imports ${runtime === void 0 ? `the runtime module ${specifier},` : `the runtime types package ${specifier},`} which ${article(kind)} package may not use in shipped code`, specifier);
|
|
986
1261
|
break;
|
|
987
1262
|
case "virtual": break;
|
|
988
1263
|
case "unknown-workspace":
|
|
@@ -1000,7 +1275,7 @@ const findingsForFile = (engine, file, text, options = {}) => {
|
|
|
1000
1275
|
if (config.imports.selfByName === "error") report("self-import-by-name", site, "imports its own package by its name; use a relative path", specifier);
|
|
1001
1276
|
break;
|
|
1002
1277
|
}
|
|
1003
|
-
const refusal = edgeRefusal(engine, owner, to, typeOnly, specifier);
|
|
1278
|
+
const refusal = edgeRefusal(engine, owner, to, site.typeOnly, specifier);
|
|
1004
1279
|
if (refusal !== void 0) report(refusal.rule, site, refusal.why, to.name);
|
|
1005
1280
|
if (config.imports.requireDeclared && !declares(engine, owner, to.name, !shipped || typeOnly)) report("undeclared-import", site, `imports ${to.name}, which ${owner.directory}/package.json does not declare${shipped ? " for shipped code" : ""}`, to.name);
|
|
1006
1281
|
break;
|
|
@@ -1024,7 +1299,7 @@ const findingsForFile = (engine, file, text, options = {}) => {
|
|
|
1024
1299
|
const permitted = (name) => allowed === "any" || allowed?.includes(name) === true || extra.includes(name);
|
|
1025
1300
|
if (shipped && !typeOnly && !permitted(target.name)) report("forbidden-external", site, `${article(kind)} package may not import ${target.name} in shipped code`, target.name);
|
|
1026
1301
|
const anywhere = !shipped || typeOnly;
|
|
1027
|
-
const declared = declares(engine, owner, target.name, anywhere) || declares(engine, owner, target.written, anywhere) || typeOnly && declares(engine, owner, typesPackage(target.name),
|
|
1302
|
+
const declared = declares(engine, owner, target.name, anywhere) || declares(engine, owner, target.written, anywhere) || site.typeOnly && declares(engine, owner, typesPackage(target.name), anywhere);
|
|
1028
1303
|
if (config.imports.requireDeclared && !declared) report("undeclared-import", site, `imports ${target.name}, which ${owner.directory}/package.json does not declare${shipped ? " for shipped code" : ""}`, target.name);
|
|
1029
1304
|
break;
|
|
1030
1305
|
}
|
|
@@ -1036,4 +1311,4 @@ const findingsForFile = (engine, file, text, options = {}) => {
|
|
|
1036
1311
|
const baselineCovers = (entry, finding) => entry.file === finding.file && entry.rule === finding.rule && entry.target === finding.target;
|
|
1037
1312
|
/** `findings` less those the baseline covers. */
|
|
1038
1313
|
const withoutBaselined = (engine, findings) => findings.filter((finding) => !engine.config.baseline.some((entry) => baselineCovers(entry, finding)));
|
|
1039
|
-
export {
|
|
1314
|
+
export { resolveConfig as S, matchesAny as _, findingsForFile as a, defaultFileClasses as b, withoutBaselined as c, findWorkspaceRoot as d, loadWorkspace as f, failure as g, attempt as h, effectivePolicy as i, importForms as l, packageOfFile as m, createEngine as n, policyOf as o, packageNameOf as p, edgeRefusal as r, reads as s, baselineCovers as t, directoriesMatching as u, matchesGlob as v, defineConfig as x, configProblems as y };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as ImportPolicy, c as PackageRule, d as configProblems, f as defaultFileClasses, i as FilePolicy, l as ResolvedConfig, m as resolveConfig, n as Config, o as KindRule, p as defineConfig, r as FileClass, s as ManifestField, t as BaselineEntry, u as RuleName } from "./config-
|
|
1
|
+
import { a as ImportPolicy, c as PackageRule, d as configProblems, f as defaultFileClasses, i as FilePolicy, l as ResolvedConfig, m as resolveConfig, n as Config, o as KindRule, p as defineConfig, r as FileClass, s as ManifestField, t as BaselineEntry, u as RuleName } from "./config-DO_LRXM8.mjs";
|
|
2
2
|
interface WorkspacePackage {
|
|
3
3
|
readonly name: string;
|
|
4
4
|
/** Repository-relative directory, `/`-separated (`libraries/parcel`). */
|
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { _ as
|
|
2
|
-
import { a as staleBaseline, i as graphFindings, n as formatFinding, r as listSourceFiles, t as checkWorkspace } from "./check-
|
|
1
|
+
import { S as resolveConfig, _ as matchesAny, a as findingsForFile, b as defaultFileClasses, c as withoutBaselined, d as findWorkspaceRoot, f as loadWorkspace, i as effectivePolicy, l as importForms, m as packageOfFile, n as createEngine, o as policyOf, p as packageNameOf, s as reads, v as matchesGlob, x as defineConfig, y as configProblems } from "./engine-Dc5tRuHT.mjs";
|
|
2
|
+
import { a as staleBaseline, i as graphFindings, n as formatFinding, r as listSourceFiles, t as checkWorkspace } from "./check-F4cLmfYv.mjs";
|
|
3
3
|
export { checkWorkspace, configProblems, createEngine, defaultFileClasses, defineConfig, effectivePolicy, findWorkspaceRoot, findingsForFile, formatFinding, graphFindings, importForms, listSourceFiles, loadWorkspace, matchesAny, matchesGlob, packageNameOf, packageOfFile, policyOf, reads, resolveConfig, staleBaseline, withoutBaselined };
|
package/dist/plugin.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as findingsForFile, c as withoutBaselined, d as findWorkspaceRoot, n as createEngine } from "./engine-
|
|
1
|
+
import { a as findingsForFile, c as withoutBaselined, d as findWorkspaceRoot, g as failure, h as attempt, n as createEngine } from "./engine-Dc5tRuHT.mjs";
|
|
2
2
|
import { statSync } from "node:fs";
|
|
3
3
|
import nodePath from "node:path";
|
|
4
4
|
// src/plugin.ts
|
|
@@ -23,7 +23,7 @@ const modified = (path) => {
|
|
|
23
23
|
}
|
|
24
24
|
};
|
|
25
25
|
const manifestsOf = (root, engine) => {
|
|
26
|
-
const directories = engine
|
|
26
|
+
const directories = engine.ok ? engine.value.workspace.packages.map((entry) => nodePath.join(root, entry.directory)) : [];
|
|
27
27
|
const paths = [
|
|
28
28
|
nodePath.join(root, "package.json"),
|
|
29
29
|
...directories.map((directory) => nodePath.join(directory, "package.json")),
|
|
@@ -32,11 +32,8 @@ const manifestsOf = (root, engine) => {
|
|
|
32
32
|
return new Map(paths.map((path) => [path, modified(path)]));
|
|
33
33
|
};
|
|
34
34
|
const build = (root, config) => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
} catch (error) {
|
|
38
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
39
|
-
}
|
|
35
|
+
const built = attempt(() => createEngine(root, config));
|
|
36
|
+
return built.ok ? built : failure(built.error instanceof Error ? built.error : new Error(String(built.error)));
|
|
40
37
|
};
|
|
41
38
|
/** The engine for `root` and `config`, rebuilt when a package.json it read has changed. */
|
|
42
39
|
const engineFor = (root, config) => {
|
|
@@ -90,14 +87,15 @@ const plugin = {
|
|
|
90
87
|
});
|
|
91
88
|
return;
|
|
92
89
|
}
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
90
|
+
const built = engineFor(root, config);
|
|
91
|
+
if (!built.ok) {
|
|
95
92
|
context.report({
|
|
96
|
-
message:
|
|
93
|
+
message: built.error.message,
|
|
97
94
|
loc: { start }
|
|
98
95
|
});
|
|
99
96
|
return;
|
|
100
97
|
}
|
|
98
|
+
const engine = built.value;
|
|
101
99
|
const file = nodePath.relative(root, context.filename).split(nodePath.sep).join("/");
|
|
102
100
|
for (const finding of withoutBaselined(engine, findingsForFile(engine, file, context.sourceCode.text))) {
|
|
103
101
|
const message = `${finding.rule}: ${finding.message}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ox-dep-lint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Module dependency graph rules for oxlint: package kinds, outside packages, runtime and test-only modules, undeclared, escaping and computed imports",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Philip B. Krogh",
|
|
@@ -32,13 +32,19 @@
|
|
|
32
32
|
"build": "vp pack",
|
|
33
33
|
"check": "vp check --deny-warnings && vp test run",
|
|
34
34
|
"test": "vp test run",
|
|
35
|
-
"prepack": "vp pack"
|
|
35
|
+
"prepack": "vp pack",
|
|
36
|
+
"prepare": "vp config",
|
|
37
|
+
"changeset": "changeset"
|
|
36
38
|
},
|
|
37
39
|
"dependencies": {
|
|
38
40
|
"oxc-parser": "0.153.0",
|
|
39
|
-
"oxc-resolver": "11.24.2"
|
|
41
|
+
"oxc-resolver": "11.24.2",
|
|
42
|
+
"valibot": "1.5.0"
|
|
40
43
|
},
|
|
41
44
|
"devDependencies": {
|
|
45
|
+
"@changesets/cli": "3.0.3",
|
|
46
|
+
"@commitlint/cli": "21.2.3",
|
|
47
|
+
"@commitlint/config-conventional": "21.2.3",
|
|
42
48
|
"@types/node": "24.9.1",
|
|
43
49
|
"oxlint": "1.86.0",
|
|
44
50
|
"typescript": "7.0.2",
|