ox-dep-lint 0.3.0 → 0.5.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 +39 -0
- package/README.md +31 -24
- package/dist/check-CWi8LUnB.mjs +348 -0
- package/dist/cli.d.mts +1 -1
- package/dist/cli.mjs +2 -2
- package/dist/{config-DO_LRXM8.d.mts → config-CmWm5RyK.d.mts} +1 -1
- package/dist/{engine-Dc5tRuHT.mjs → engine-CwR59HVO.mjs} +212 -18
- package/dist/index.d.mts +23 -6
- package/dist/index.mjs +3 -3
- package/dist/plugin.mjs +4 -2
- package/package.json +1 -1
- package/dist/check-F4cLmfYv.mjs +0 -223
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 57698bb: `invalid-package` now reports a workspace member whose `dependencies`, `devDependencies`, `optionalDependencies` or `peerDependencies` is not an object: a string (`"dependencies": "some string"`), a list, a number, a boolean or `null`. 0.4 read no names from such a field and said nothing, so a forbidden or undeclared edge written there passed unseen. The finding names the field and what it holds, once per field; the member is still checked, as if that field declared nothing. The root `package.json` is not judged this way.
|
|
8
|
+
|
|
9
|
+
This is a minor release rather than a patch because it can fail a check that passed on 0.4 (the rule in `.changeset/README.md`: before 1.0, a change to what the rules report is a minor bump). Fix the field, or baseline the finding by its own target, the member's directory and the field joined by `#`:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
baseline: [
|
|
13
|
+
{
|
|
14
|
+
file: "tools/quality/package.json",
|
|
15
|
+
rule: "invalid-package",
|
|
16
|
+
target: "tools/quality#peerDependencies",
|
|
17
|
+
reason: "...",
|
|
18
|
+
},
|
|
19
|
+
]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Such an entry covers only that field. If the `package.json` later cannot be read or loses its name, which drops the member from the check, that is still reported, with the directory as its target.
|
|
23
|
+
|
|
24
|
+
### Patch Changes
|
|
25
|
+
|
|
26
|
+
- 57698bb: A path that disappears while the check runs is skipped instead of reported as `unreadable-path`. A test that creates and removes a temporary tree inside a package while the CLI walks it (tsukuri's `tools/quality/.scan-x/...`) no longer fails the check with `ENOENT`. This covers every place the check lists, stats or reads: the walk of each package, the `workspaces` and kind globs, a member's `package.json`, a symbolic link's destination, and a development file that shipped code imports. `ENOTDIR` (a directory on the way replaced by a file) is skipped the same way. A path that exists but cannot be read (`EACCES` and every other error) is still a finding: `unreadable-path`, or `invalid-package` for a member's `package.json`, or `shipped-imports-development-file` for a development file that shipped code imports, or `symbolic-link` (to an unreadable destination) for a link, and the oxlint plugin, which shares these functions, behaves the same. The file count the CLI prints no longer includes files that disappeared.
|
|
27
|
+
|
|
28
|
+
## 0.4.0
|
|
29
|
+
|
|
30
|
+
### Minor Changes
|
|
31
|
+
|
|
32
|
+
- e75db50: Sources under a workspace glob no longer escape the check unreported: each one is read, or the reason it cannot be read is a finding. Plain files at a glob's level (`packages/x.ts`, outside every package directory) and entries that are neither files, directories nor links (FIFOs, sockets) are still not reported. This can add findings to a repository that passed before, so upgrade with the CLI once and baseline or fix what it reports.
|
|
33
|
+
|
|
34
|
+
- The walk now reads dot directories inside a package (`src/.internal/x.ts`), as oxlint already did; it skips only `node_modules` and `.git`.
|
|
35
|
+
- `output` now defaults to `dist/**`, `coverage/**` and the caches tools write at a package root: `.wrangler/**`, `.astro/**`, `.svelte-kit/**`, `.next/**`, `.turbo/**`, `.vercel/**`, `.nuxt/**`, `.output/**`. Setting `output` replaces the defaults; it does not add to them.
|
|
36
|
+
- `symbolic-link`: every symbolic link under a package directory, whatever it points at and whatever its name (`.d.ts`, `.md`, `.json`, extensionless); a link a `workspaces` or kind glob matches or names literally; and a link a glob passes through (`packages` itself a link). Links are never followed: 0.2 read a link to a file as if it were the file, skipped a link to a directory silently, and followed a linked `packages`. Only links inside directories the check skips on purpose are not reported: `node_modules`, `.git`, and directories that `ignore`, `output` or an unchecked file class cover through a trailing `/**`. A baseline entry names the link as `file` and its destination, as written, as `target`; baselined or not, what a link points at is never read. A Nix `result` link inside a package is a finding; `ignore: ["**/result/**"]` clears it. The oxlint plugin reports it on each file oxlint reaches through a link and reads nothing else in that file.
|
|
37
|
+
- `unreadable-path`: a directory the walk, a `workspaces` glob or a kind's glob cannot list, or a source file the check cannot read (`EACCES`), unless the check skips it on purpose. 0.2 stopped with exit code 2 and the error instead.
|
|
38
|
+
- `directory-without-package`: a directory a root `workspaces` glob matches that has no `package.json` and lies neither inside nor above a member, so nothing checks its files; and a dot directory such a glob would match (`packages/.template`), with or without a `package.json`, since npm and Bun workspaces never match dot directories. Only the topmost of nested ones is reported, and a negated glob (`!tools/legacy` or `!tools/legacy/**`) leaves it out. No new configuration key: add `dir/**` to `ignore`, or baseline it, to keep one.
|
|
39
|
+
|
|
40
|
+
A workspaces entry or kind glob that names or passes through a symbolic link no longer finds packages there: the link is reported instead of followed. `listSourceFiles` no longer lists a file reached through a link; the new `walkSources` returns the files with the walk's findings. `Workspace` has a new field, `globs`, the root's `workspaces` globs.
|
|
41
|
+
|
|
3
42
|
## 0.3.0
|
|
4
43
|
|
|
5
44
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -11,27 +11,30 @@ ox-dep-lint replaces the hand-written module graph checks of three repositories
|
|
|
11
11
|
|
|
12
12
|
## What it checks
|
|
13
13
|
|
|
14
|
-
| Rule | Finding
|
|
15
|
-
| ---------------------------------- |
|
|
16
|
-
| `forbidden-edge` | A load of a workspace package that its kind (or its `uses`) does not allow. The same check runs on edges declared in `package.json`: all four dependency fields by default, devDependencies included.
|
|
17
|
-
| `type-only-edge` | A value import of a package listed in `typeOnlyUses`. Only `import type` and `export type` declarations are type-only; an inline `import { type X }` still loads the module under `verbatimModuleSyntax`.
|
|
18
|
-
| `unknown-workspace-package` | A name under a workspace scope that no package has, in an import or a `package.json`.
|
|
19
|
-
| `package-path-escape` | A package specifier with `.` or `..` after the name (`@acme/a/../b/src/x.ts`).
|
|
20
|
-
| `relative-import-leaves-package` | A relative path, alias, root-absolute path or worker URL that resolves into another package, into `node_modules` or out of the repository. Import a package by its name instead.
|
|
21
|
-
| `shipped-imports-development-file` | Shipped code importing a development file (of its own package, or one another package exports), or a file under `output` or `ignore`, that would not pass as shipped code itself: a test helper using `fast-check`, a script using `node:fs`. A development file that would pass, such as a shared helper under `scripts/`, may be imported.
|
|
22
|
-
| `forbidden-external` | An outside package that the kind's `externals` does not allow, in shipped code or in `package.json`'s shipped fields. An outside package is named by the package it installs (the `name` in its installed `package.json`, or an `npm:` alias's target), so an alias cannot pass one package off as another; `externals` name real packages.
|
|
23
|
-
| `runtime-module` | `node:*`, a bare Node built-in (`fs`), `bun`, `bun:*` or `cloudflare:*`, also when a package import (`#crypto`) maps to one, in the shipped code of a kind with `runtimeModules: false`.
|
|
24
|
-
| `test-only-in-shipped` | A `testOnlySpecifiers` match (`fast-check`, `vite-plus/test`) in shipped code, with any `?query` stripped.
|
|
25
|
-
| `undeclared-import` | A package the file's own `package.json` does not declare. Shipped code needs `dependencies`, `peerDependencies` or `optionalDependencies` (`imports.shippedFields`); type-only imports and development files may also use devDependencies and the root's. This covers what knip misses: files no entry reaches, and shipped code leaning on a root devDependency.
|
|
26
|
-
| `computed-import` | A load whose module the check cannot read: `import(expression)`, `require(expression)`, `import.meta.glob`, a kept `createRequire` function, or a literal written with an escape sequence (which is also checked by the module it spells). Shipped code only, by default.
|
|
27
|
-
| `unresolved-import` | A relative path or alias (`#x`, `@/x`, `~/x`) that names no file.
|
|
28
|
-
| `self-import-by-name` | A package importing itself by its own name (off by default).
|
|
29
|
-
| `package-without-kind` | A workspace package that no kind's globs hold.
|
|
30
|
-
| `package-outside-workspace` | A directory a kind names, with a `package.json`, that the root `workspaces` leave out, so nothing in it would be checked.
|
|
31
|
-
| `invalid-package` | A workspace member whose `package.json` cannot be read, has no name, or has another member's name.
|
|
32
|
-
| `package
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
14
|
+
| Rule | Finding | Front end |
|
|
15
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
16
|
+
| `forbidden-edge` | A load of a workspace package that its kind (or its `uses`) does not allow. The same check runs on edges declared in `package.json`: all four dependency fields by default, devDependencies included. | both |
|
|
17
|
+
| `type-only-edge` | A value import of a package listed in `typeOnlyUses`. Only `import type` and `export type` declarations are type-only; an inline `import { type X }` still loads the module under `verbatimModuleSyntax`. | both |
|
|
18
|
+
| `unknown-workspace-package` | A name under a workspace scope that no package has, in an import or a `package.json`. | both |
|
|
19
|
+
| `package-path-escape` | A package specifier with `.` or `..` after the name (`@acme/a/../b/src/x.ts`). | both |
|
|
20
|
+
| `relative-import-leaves-package` | A relative path, alias, root-absolute path or worker URL that resolves into another package, into `node_modules` or out of the repository. Import a package by its name instead. | both |
|
|
21
|
+
| `shipped-imports-development-file` | Shipped code importing a development file (of its own package, or one another package exports), or a file under `output` or `ignore`, that would not pass as shipped code itself: a test helper using `fast-check`, a script using `node:fs`. A development file that would pass, such as a shared helper under `scripts/`, may be imported. | both |
|
|
22
|
+
| `forbidden-external` | An outside package that the kind's `externals` does not allow, in shipped code or in `package.json`'s shipped fields. An outside package is named by the package it installs (the `name` in its installed `package.json`, or an `npm:` alias's target), so an alias cannot pass one package off as another; `externals` name real packages. | both, `package.json` in the CLI |
|
|
23
|
+
| `runtime-module` | `node:*`, a bare Node built-in (`fs`), `bun`, `bun:*` or `cloudflare:*`, also when a package import (`#crypto`) maps to one, in the shipped code of a kind with `runtimeModules: false`. | both |
|
|
24
|
+
| `test-only-in-shipped` | A `testOnlySpecifiers` match (`fast-check`, `vite-plus/test`) in shipped code, with any `?query` stripped. | both |
|
|
25
|
+
| `undeclared-import` | A package the file's own `package.json` does not declare. Shipped code needs `dependencies`, `peerDependencies` or `optionalDependencies` (`imports.shippedFields`); type-only imports and development files may also use devDependencies and the root's. This covers what knip misses: files no entry reaches, and shipped code leaning on a root devDependency. | both |
|
|
26
|
+
| `computed-import` | A load whose module the check cannot read: `import(expression)`, `require(expression)`, `import.meta.glob`, a kept `createRequire` function, or a literal written with an escape sequence (which is also checked by the module it spells). Shipped code only, by default. | both |
|
|
27
|
+
| `unresolved-import` | A relative path or alias (`#x`, `@/x`, `~/x`) that names no file. | both |
|
|
28
|
+
| `self-import-by-name` | A package importing itself by its own name (off by default). | both |
|
|
29
|
+
| `package-without-kind` | A workspace package that no kind's globs hold. | both |
|
|
30
|
+
| `package-outside-workspace` | A directory a kind names, with a `package.json`, that the root `workspaces` leave out, so nothing in it would be checked. | CLI |
|
|
31
|
+
| `invalid-package` | A workspace member whose `package.json` cannot be read, has no name, or has another member's name; or one with a dependency field (`dependencies`, `devDependencies`, `optionalDependencies`, `peerDependencies`) that is not an object (`"dependencies": "x"`, a list, a number, `null`), so no edge in it could be read. Such a member is still checked, as if that field declared nothing. A field's finding has the target `<directory>#<field>` (`tools/quality#peerDependencies`), so a baseline entry for it cannot cover the other causes, whose target is the directory. | CLI |
|
|
32
|
+
| `directory-without-package` | A directory a root `workspaces` glob matches that has no `package.json` and lies neither inside nor above a member, so no package owns its files and nothing checks them (tsukuri's "directory in no tier"). Also a dot directory such a glob would match (`packages/.template`), with or without a `package.json`: npm and Bun workspaces never match dot directories, so it is no package. Only the topmost of nested ones is reported; a negated glob (`!tools/legacy` or `!tools/legacy/**`) leaves the directory out. Silence one with `ignore` (`tools/vendored/**`) or the baseline. | CLI |
|
|
33
|
+
| `symbolic-link` | Every symbolic link under a package directory, whatever it points at (a file, a directory, nothing, a cycle) and whatever its name (`.d.ts`, `.md`, `.json`, extensionless); a link a `workspaces` or kind glob matches or names (`packages/x -> ../elsewhere`); and a link a glob passes through (`packages` itself a link). The check never follows one. It skips only links inside directories it skips on purpose: `node_modules`, `.git`, and directories `ignore`, `output` or an unchecked file class cover through a trailing `/**`. | both (the plugin on each file oxlint reaches through a link) |
|
|
34
|
+
| `unreadable-path` | A directory the walk, a `workspaces` glob or a kind glob cannot list, or a source file the check cannot read (`EACCES`), unless the check skips it on purpose. It used to stop the check with exit code 2. A path removed while the check runs (`ENOENT`, or `ENOTDIR` when a directory on the way became a file), such as a test's temporary tree, is skipped with no finding. | CLI |
|
|
35
|
+
| `package-cycle` | A cycle between packages through their runtime dependency fields. | CLI |
|
|
36
|
+
| `stale-baseline` | A baseline entry that no finding needs any more, so the baseline can only shrink. | CLI |
|
|
37
|
+
| `parse-error` | A file that does not parse, so its imports cannot be checked. | both |
|
|
35
38
|
|
|
36
39
|
A load is any of: `import` and `export ... from` (static, `defer` and `source` phases), `import()`, a type-level `import("...")`, `import x = require("...")`, a string-named `declare module "..."`, `/// <reference types="..." />`, `require`, `require.resolve`, `module.require`, `import.meta.require`, `import.meta.resolve`, `process.getBuiltinModule`, `createRequire(...)("...")`, and a `new URL("...", import.meta.url)` passed to `new Worker`, `new SharedWorker` or `navigator.serviceWorker.register` (bundlers load it as a module, extension or not; any other `new URL` locates a file to read or an asset to serve, and is not a load). `require.resolve` and `import.meta.resolve` with a literal are edges; with an expression they load nothing.
|
|
37
40
|
|
|
@@ -43,7 +46,7 @@ The first matching file class decides how a file is checked. Globs in a class ar
|
|
|
43
46
|
- **development:** kind edges, relative imports and unresolved imports are still checked. The file may use devDependencies (its own and the root's), test-only specifiers, runtime modules, any outside package and computed `import()`.
|
|
44
47
|
- **unchecked:** the file is not read.
|
|
45
48
|
|
|
46
|
-
By default, `test/**`, `tests/**`, `e2e/**`, `scripts/**`, package-root `*.config.*`, `**/__tests__/**`, `**/fixtures/**` and `**/*.{test,spec,bench}.*` are development files. A package's
|
|
49
|
+
By default, `test/**`, `tests/**`, `e2e/**`, `scripts/**`, package-root `*.config.*`, `**/__tests__/**`, `**/fixtures/**` and `**/*.{test,spec,bench}.*` are development files. A package's build output and the caches tools write at its root are not read: `output` defaults to `dist/**`, `coverage/**`, `.wrangler/**`, `.astro/**`, `.svelte-kit/**`, `.next/**`, `.turbo/**`, `.vercel/**`, `.nuxt/**` and `.output/**`. Setting `output` replaces these defaults rather than adding to them, so list the ones you still want (`[]` reads everything). Shipped code that imports a file under `output` still has that file judged. A package's `testSupportFiles` are development files too (a conformance suite's entry, for example).
|
|
47
50
|
|
|
48
51
|
## Configuration
|
|
49
52
|
|
|
@@ -85,6 +88,10 @@ A kind may use its own kind only when `mayUse` lists it. `imports` holds the pol
|
|
|
85
88
|
|
|
86
89
|
Misspelled keys, unknown policies and baseline entries without a `target` are configuration errors, never silent defaults.
|
|
87
90
|
|
|
91
|
+
The root `workspaces` globs also say where packages live: a directory they match without a `package.json` is a `directory-without-package` finding, so no new key is needed. To keep such a directory (vendored sources that are not a package), add `dir/**` to `ignore` or baseline it with `file` and `target` both set to the directory.
|
|
92
|
+
|
|
93
|
+
A `symbolic-link` baseline entry names the link as `file` and its destination, as the link writes it, as `target`, so pointing the link elsewhere makes the entry stale. Baselining a link does not make the check read through it: what a link points at is never read, baselined or not. A Nix `result` link (from `nix build`) left inside a package is a finding too; build outside packages, or add `**/result/**` to `ignore`.
|
|
94
|
+
|
|
88
95
|
## Installing
|
|
89
96
|
|
|
90
97
|
```sh
|
|
@@ -95,7 +102,7 @@ Releases are published from a `v*` tag by `.github/workflows/release.yml`, with
|
|
|
95
102
|
|
|
96
103
|
## Running it
|
|
97
104
|
|
|
98
|
-
The CLI walks every workspace package's directory (skipping `node_modules
|
|
105
|
+
The CLI walks every workspace package's directory, dot directories included (`src/.internal/`), skipping only `node_modules` and `.git` and reading nothing under `output`, so generated and gitignored sources and nested repositories are checked too, and runs the whole-graph checks. Tool caches such as `.wrangler` and `.astro` are in the default `output`; for another cache, set `output` to the defaults plus its glob, since `output` replaces the defaults. The CLI never follows a symbolic link: every link is a `symbolic-link` finding, and a directory it cannot list or a file it cannot read is an `unreadable-path` finding; one that disappears between being listed and being read is skipped. What it still does not report: plain files directly at a workspace glob's level (`packages/x.ts`), and FIFOs and sockets.
|
|
99
106
|
|
|
100
107
|
```sh
|
|
101
108
|
ox-dep-lint # finds ox-dep-lint.config.{ts,mts,js,mjs,json} at the workspace root
|
|
@@ -120,7 +127,7 @@ export default defineConfig({
|
|
|
120
127
|
|
|
121
128
|
The plugin and the CLI call one function per file, and a test runs both under real oxlint and requires the same findings at the same places. In a long-lived process (an editor's language server) the plugin rebuilds its view of the workspace when a `package.json` changes, and looks again for a file it could not resolve.
|
|
122
129
|
|
|
123
|
-
Where they can still differ: oxlint chooses which files it lints (its own ignores), and the CLI reads the files of workspace packages. oxlint shows line-1 columns of a file that starts with a byte order mark three further on than the CLI does.
|
|
130
|
+
Where they can still differ: oxlint chooses which files it lints (its own ignores), and the CLI reads the files of workspace packages. oxlint follows symbolic links, so the plugin reports `symbolic-link` at line 1 of each file it is given through a link (and reads nothing else in it), where the CLI reports the link once; a link to a file oxlint does not lint (`README.md`) is reported by the CLI alone. Both read dot directories. oxlint shows line-1 columns of a file that starts with a byte order mark three further on than the CLI does.
|
|
124
131
|
|
|
125
132
|
## Not yet
|
|
126
133
|
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { C as attempt, E as vanished, O as matchesGlob, c as linkDestination, d as reads, f as reportsLink, g as withoutBaselined, h as unreadablePathFinding, i as edgeRefusal, l as passedOver, m as symbolicLinkFinding, n as coversDirectory, o as findingsForFile, p as skipsDirectory, r as createEngine, s as ignoresDirectory, t as baselineCovers, v as directoriesMatching, w as codeOf } from "./engine-CwR59HVO.mjs";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import nodePath from "node:path";
|
|
4
|
+
// src/graph.ts
|
|
5
|
+
/**
|
|
6
|
+
* Checks that need the whole workspace rather than one file: edges declared only in `package.json`, cycles between
|
|
7
|
+
* packages, packages no kind holds, directories the workspaces match that no package owns, and baseline entries no
|
|
8
|
+
* finding needs any more. Only the CLI runs these, because an oxlint plugin sees one file at a time and never sees
|
|
9
|
+
* `package.json`.
|
|
10
|
+
*/
|
|
11
|
+
const runtimeFields = [
|
|
12
|
+
"dependencies",
|
|
13
|
+
"optionalDependencies",
|
|
14
|
+
"peerDependencies"
|
|
15
|
+
];
|
|
16
|
+
const graphFindings = (engine) => {
|
|
17
|
+
const { config, workspace } = engine;
|
|
18
|
+
const findings = [];
|
|
19
|
+
const at = {
|
|
20
|
+
line: 1,
|
|
21
|
+
column: 0
|
|
22
|
+
};
|
|
23
|
+
for (const problem of workspace.problems) {
|
|
24
|
+
const target = problem.field === void 0 ? problem.directory : `${problem.directory}#${problem.field}`;
|
|
25
|
+
findings.push({
|
|
26
|
+
rule: "invalid-package",
|
|
27
|
+
file: `${problem.directory}/package.json`,
|
|
28
|
+
...at,
|
|
29
|
+
message: problem.message,
|
|
30
|
+
target
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const members = /* @__PURE__ */ new Set([...workspace.packages.map((entry) => entry.directory), ...workspace.problems.map((problem) => problem.directory)]);
|
|
34
|
+
const unlistedByKinds = {
|
|
35
|
+
links: [],
|
|
36
|
+
unreadable: [],
|
|
37
|
+
dotDirectories: []
|
|
38
|
+
};
|
|
39
|
+
for (const kind of Object.values(config.kinds)) for (const pattern of kind.packages) for (const directory of directoriesMatching(workspace.root, pattern, unlistedByKinds)) if (!members.has(directory) && existsSync(nodePath.join(workspace.root, directory, "package.json"))) {
|
|
40
|
+
members.add(directory);
|
|
41
|
+
findings.push({
|
|
42
|
+
rule: "package-outside-workspace",
|
|
43
|
+
file: `${directory}/package.json`,
|
|
44
|
+
...at,
|
|
45
|
+
message: `${directory} is a package a kind names, but the root package.json's workspaces leave it out, so nothing in it is checked`,
|
|
46
|
+
target: directory
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
findings.push(...directoriesWithoutPackage(engine, members, unlistedByKinds));
|
|
50
|
+
for (const entry of workspace.packages) {
|
|
51
|
+
const manifest = `${entry.directory}/package.json`;
|
|
52
|
+
if (engine.kindOf(entry) === void 0) {
|
|
53
|
+
findings.push({
|
|
54
|
+
rule: "package-without-kind",
|
|
55
|
+
file: manifest,
|
|
56
|
+
...at,
|
|
57
|
+
message: `${entry.name} matches no kind: add its directory to one`,
|
|
58
|
+
target: entry.name
|
|
59
|
+
});
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const kindRule = config.kinds[engine.kindOf(entry) ?? ""];
|
|
63
|
+
const extra = config.packages[entry.name]?.externals ?? [];
|
|
64
|
+
for (const field of config.imports.shippedFields) for (const name of entry.manifest[field]) {
|
|
65
|
+
const outside = !workspace.byName.has(name) && !config.workspaceScopes.some((scope) => name.startsWith(scope));
|
|
66
|
+
const installed = entry.aliases.get(name) ?? name;
|
|
67
|
+
if (outside && kindRule !== void 0 && kindRule.externals !== "any" && !kindRule.externals.includes(installed) && !extra.includes(installed)) findings.push({
|
|
68
|
+
rule: "forbidden-external",
|
|
69
|
+
file: manifest,
|
|
70
|
+
...at,
|
|
71
|
+
message: `${field} names ${installed === name ? name : `${name} (${installed})`}, which its kind does not allow`,
|
|
72
|
+
target: installed
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
for (const field of config.imports.manifestEdgeFields) for (const name of entry.manifest[field]) {
|
|
76
|
+
const to = workspace.byName.get(name);
|
|
77
|
+
if (to === void 0) {
|
|
78
|
+
if (config.workspaceScopes.some((scope) => name.startsWith(scope))) findings.push({
|
|
79
|
+
rule: "unknown-workspace-package",
|
|
80
|
+
file: manifest,
|
|
81
|
+
...at,
|
|
82
|
+
message: `${field} names ${name}, which is not a workspace package`,
|
|
83
|
+
target: name
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const refusal = edgeRefusal(engine, entry, to, true);
|
|
88
|
+
if (refusal !== void 0 && to.name !== entry.name) findings.push({
|
|
89
|
+
rule: refusal.rule,
|
|
90
|
+
file: manifest,
|
|
91
|
+
...at,
|
|
92
|
+
message: `${field}: ${refusal.why}`,
|
|
93
|
+
target: name
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
findings.push(...packageCycles(engine));
|
|
98
|
+
return findings;
|
|
99
|
+
};
|
|
100
|
+
const isDirectory = (absolute) => {
|
|
101
|
+
try {
|
|
102
|
+
return statSync(absolute).isDirectory();
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
/** Whether `absolute`, links followed, is something other than a directory; false when nothing is there. */
|
|
108
|
+
const isFile = (absolute) => {
|
|
109
|
+
try {
|
|
110
|
+
return !statSync(absolute).isDirectory();
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* What the root's `workspaces` globs (and a kind's globs) match that is no package: a directory without a
|
|
117
|
+
* `package.json`, whose files no package owns and so nothing checks (only the topmost of nested ones); a dot directory,
|
|
118
|
+
* which no workspaces glob matches; a directory the globs cannot list; and a symbolic link to a directory (or to
|
|
119
|
+
* nothing), which is never followed. Inside a member the walk reports these, so they are left to it. A directory above a member (under `packages/**`), one a negated glob or `ignore` leaves out, and
|
|
120
|
+
* anything under `node_modules` or `.git` are not reported.
|
|
121
|
+
*/
|
|
122
|
+
const directoriesWithoutPackage = (engine, members, unlistedByKinds) => {
|
|
123
|
+
const { workspace } = engine;
|
|
124
|
+
const negated = workspace.globs.filter((glob) => glob.startsWith("!")).map((glob) => glob.slice(1).replace(/^\.\//u, "").replace(/\/$/u, ""));
|
|
125
|
+
const inside = (path) => [...members].some((member) => path === member || path.startsWith(`${member}/`));
|
|
126
|
+
const above = (path) => [...members].some((member) => member.startsWith(`${path}/`));
|
|
127
|
+
const leftOut = (path) => path.split("/").some(passedOver) || negated.some((pattern) => matchesGlob(path, pattern) || coversDirectory(pattern, path)) || ignoresDirectory(engine, path);
|
|
128
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
129
|
+
const unlisted = {
|
|
130
|
+
links: [...unlistedByKinds.links],
|
|
131
|
+
unreadable: [...unlistedByKinds.unreadable],
|
|
132
|
+
dotDirectories: []
|
|
133
|
+
};
|
|
134
|
+
for (const glob of workspace.globs.filter((pattern) => !pattern.startsWith("!"))) for (const directory of directoriesMatching(workspace.root, glob, unlisted)) if (!candidates.has(directory)) candidates.set(directory, glob);
|
|
135
|
+
const withoutPackage = (directory, glob) => {
|
|
136
|
+
if (above(directory) || !isDirectory(nodePath.join(workspace.root, directory))) return;
|
|
137
|
+
const listed = attempt(() => readdirSync(nodePath.join(workspace.root, directory)));
|
|
138
|
+
if (!listed.ok) return vanished(listed.error) ? void 0 : unreadablePathFinding(directory, codeOf(listed.error));
|
|
139
|
+
return {
|
|
140
|
+
rule: "directory-without-package",
|
|
141
|
+
file: directory,
|
|
142
|
+
line: 1,
|
|
143
|
+
column: 0,
|
|
144
|
+
message: `${directory} matches the workspaces glob ${glob} but has no package.json, so no package owns what it holds and nothing in it is checked: make it a package, remove it, or add ${directory}/** to ignore`,
|
|
145
|
+
target: directory
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
const dotDirectory = (directory) => ({
|
|
149
|
+
rule: "directory-without-package",
|
|
150
|
+
file: directory,
|
|
151
|
+
line: 1,
|
|
152
|
+
column: 0,
|
|
153
|
+
message: `${directory} is a dot directory, which workspaces globs never match, so it is no package${existsSync(nodePath.join(workspace.root, directory, "package.json")) ? " though it has a package.json" : ""} and nothing in it is checked: rename it, list it in workspaces by its full path, remove it, or add ${directory}/** to ignore`,
|
|
154
|
+
target: directory
|
|
155
|
+
});
|
|
156
|
+
const pending = [
|
|
157
|
+
...unlisted.unreadable.map(({ directory, code }) => ({
|
|
158
|
+
path: directory,
|
|
159
|
+
finding: () => unreadablePathFinding(directory, code)
|
|
160
|
+
})),
|
|
161
|
+
...[...candidates].map(([directory, glob]) => ({
|
|
162
|
+
path: directory,
|
|
163
|
+
finding: () => withoutPackage(directory, glob)
|
|
164
|
+
})),
|
|
165
|
+
...unlisted.dotDirectories.map((directory) => ({
|
|
166
|
+
path: directory,
|
|
167
|
+
finding: () => dotDirectory(directory)
|
|
168
|
+
})),
|
|
169
|
+
...unlisted.links.map((link) => ({
|
|
170
|
+
path: link,
|
|
171
|
+
finding: () => {
|
|
172
|
+
const destination = isFile(nodePath.join(workspace.root, link)) ? void 0 : linkDestination(nodePath.join(workspace.root, link));
|
|
173
|
+
return destination === void 0 ? void 0 : symbolicLinkFinding(link, destination);
|
|
174
|
+
}
|
|
175
|
+
}))
|
|
176
|
+
].toSorted((left, right) => left.path.split("/").length - right.path.split("/").length || left.path.localeCompare(right.path));
|
|
177
|
+
const findings = /* @__PURE__ */ new Map();
|
|
178
|
+
for (const { path, finding } of pending) {
|
|
179
|
+
const reportedAbove = [...findings.keys()].some((reported) => path.startsWith(`${reported}/`));
|
|
180
|
+
if (findings.has(path) || inside(path) || leftOut(path) || reportedAbove) continue;
|
|
181
|
+
const made = finding();
|
|
182
|
+
if (made !== void 0) findings.set(path, made);
|
|
183
|
+
}
|
|
184
|
+
return [...findings.values()].toSorted((left, right) => left.file.localeCompare(right.file));
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* Tarjan's strongly connected components, iterative so that a long chain of packages cannot exhaust the stack. The
|
|
188
|
+
* search starts from `roots` in order and follows each node's `successors` in order, so components and routes are
|
|
189
|
+
* deterministic.
|
|
190
|
+
*/
|
|
191
|
+
const stronglyConnectedComponents = (roots, successors) => {
|
|
192
|
+
const index = /* @__PURE__ */ new Map();
|
|
193
|
+
const lowLink = /* @__PURE__ */ new Map();
|
|
194
|
+
const stack = [];
|
|
195
|
+
const onStack = /* @__PURE__ */ new Set();
|
|
196
|
+
const frames = [];
|
|
197
|
+
const pathPosition = /* @__PURE__ */ new Map();
|
|
198
|
+
const components = [];
|
|
199
|
+
const routes = [];
|
|
200
|
+
const enter = (name) => {
|
|
201
|
+
const order = index.size;
|
|
202
|
+
index.set(name, order);
|
|
203
|
+
lowLink.set(name, order);
|
|
204
|
+
stack.push(name);
|
|
205
|
+
onStack.add(name);
|
|
206
|
+
pathPosition.set(name, frames.length);
|
|
207
|
+
frames.push({
|
|
208
|
+
name,
|
|
209
|
+
next: 0
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
for (const root of roots) {
|
|
213
|
+
if (index.has(root)) continue;
|
|
214
|
+
enter(root);
|
|
215
|
+
for (let frame = frames.at(-1); frame !== void 0; frame = frames.at(-1)) {
|
|
216
|
+
const target = (successors.get(frame.name) ?? [])[frame.next];
|
|
217
|
+
if (target !== void 0) {
|
|
218
|
+
frame.next += 1;
|
|
219
|
+
if (!index.has(target)) enter(target);
|
|
220
|
+
else if (onStack.has(target)) {
|
|
221
|
+
lowLink.set(frame.name, Math.min(lowLink.get(frame.name) ?? 0, index.get(target) ?? 0));
|
|
222
|
+
const position = pathPosition.get(target);
|
|
223
|
+
if (position !== void 0) routes.push([...frames.slice(position).map((onPath) => onPath.name), target]);
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
frames.pop();
|
|
228
|
+
pathPosition.delete(frame.name);
|
|
229
|
+
const link = lowLink.get(frame.name) ?? 0;
|
|
230
|
+
if (link === index.get(frame.name)) {
|
|
231
|
+
const component = [];
|
|
232
|
+
for (let member = stack.pop(); member !== void 0; member = member === frame.name ? void 0 : stack.pop()) {
|
|
233
|
+
onStack.delete(member);
|
|
234
|
+
component.push(member);
|
|
235
|
+
}
|
|
236
|
+
components.push(component);
|
|
237
|
+
}
|
|
238
|
+
const parent = frames.at(-1);
|
|
239
|
+
if (parent !== void 0) lowLink.set(parent.name, Math.min(lowLink.get(parent.name) ?? 0, link));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
components,
|
|
244
|
+
routes
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* Cycles between workspace packages through their runtime dependency fields. Every route the search closes is a
|
|
249
|
+
* finding, once per set of packages, starting at the package the search reached first; so a component with several
|
|
250
|
+
* cycles can have several findings.
|
|
251
|
+
*/
|
|
252
|
+
const packageCycles = (engine) => {
|
|
253
|
+
const { workspace } = engine;
|
|
254
|
+
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)]));
|
|
255
|
+
const findings = [];
|
|
256
|
+
const seen = /* @__PURE__ */ new Set();
|
|
257
|
+
for (const cycle of stronglyConnectedComponents([...edges.keys()].toSorted(), edges).routes) {
|
|
258
|
+
const key = cycle.slice(0, -1).toSorted().join(" ");
|
|
259
|
+
if (!seen.has(key)) {
|
|
260
|
+
seen.add(key);
|
|
261
|
+
const first = cycle[0] ?? "";
|
|
262
|
+
findings.push({
|
|
263
|
+
rule: "package-cycle",
|
|
264
|
+
file: `${workspace.byName.get(first)?.directory ?? first}/package.json`,
|
|
265
|
+
line: 1,
|
|
266
|
+
column: 0,
|
|
267
|
+
message: `dependency cycle: ${cycle.join(" -> ")}`,
|
|
268
|
+
target: key
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return findings;
|
|
273
|
+
};
|
|
274
|
+
/** Baseline entries that no finding in `findings` needs; each is a finding of its own, so the baseline only shrinks. */
|
|
275
|
+
const staleBaseline = (engine, findings) => engine.config.baseline.filter((entry) => !findings.some((finding) => baselineCovers(entry, finding))).map((entry) => ({
|
|
276
|
+
rule: "stale-baseline",
|
|
277
|
+
file: entry.file,
|
|
278
|
+
line: 1,
|
|
279
|
+
column: 0,
|
|
280
|
+
message: `the baseline entry for ${entry.rule}${entry.target === void 0 ? "" : ` (${entry.target})`} is no longer needed: remove it`,
|
|
281
|
+
target: entry.rule
|
|
282
|
+
}));
|
|
283
|
+
// src/check.ts
|
|
284
|
+
/** The whole check: every source file of the workspace through the engine, plus the whole-graph checks and the baseline. */
|
|
285
|
+
/**
|
|
286
|
+
* Every source file the engine reads, found by walking each package's directory (skipping `node_modules` and `.git`;
|
|
287
|
+
* other dot directories hold sources too). A walk rather than `git ls-files`: generated and gitignored sources still
|
|
288
|
+
* ship, and a nested repository or submodule inside a package is still part of it. A symbolic link is never followed:
|
|
289
|
+
* every one is a finding, whatever it points at, unless it lies in a directory the check skips on purpose; and so is a
|
|
290
|
+
* directory the walk cannot list. A path removed between being listed and being read is skipped, with no finding.
|
|
291
|
+
*/
|
|
292
|
+
const walkSources = (engine) => {
|
|
293
|
+
const files = /* @__PURE__ */ new Set();
|
|
294
|
+
const findings = /* @__PURE__ */ new Map();
|
|
295
|
+
const { root } = engine.workspace;
|
|
296
|
+
const walk = (relative) => {
|
|
297
|
+
let entries;
|
|
298
|
+
try {
|
|
299
|
+
entries = readdirSync(nodePath.join(root, relative), { withFileTypes: true });
|
|
300
|
+
} catch (cause) {
|
|
301
|
+
if (!vanished(cause) && !skipsDirectory(engine, relative)) findings.set(relative, unreadablePathFinding(relative, codeOf(cause)));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
for (const entry of entries) {
|
|
305
|
+
const path = `${relative}/${entry.name}`;
|
|
306
|
+
if (entry.isDirectory()) {
|
|
307
|
+
if (!passedOver(entry.name)) walk(path);
|
|
308
|
+
} else if (entry.isSymbolicLink()) {
|
|
309
|
+
const destination = reportsLink(engine, path) ? linkDestination(nodePath.join(root, path)) : void 0;
|
|
310
|
+
if (destination !== void 0) findings.set(path, symbolicLinkFinding(path, destination));
|
|
311
|
+
} else if (entry.isFile() && reads(engine, path)) files.add(path);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
for (const entry of engine.workspace.packages) walk(entry.directory);
|
|
315
|
+
return {
|
|
316
|
+
files: [...files].toSorted(),
|
|
317
|
+
findings: [...findings.values()].toSorted((left, right) => left.file.localeCompare(right.file))
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
/** The source files `walkSources` finds. */
|
|
321
|
+
const listSourceFiles = (engine) => [...walkSources(engine).files];
|
|
322
|
+
const checkWorkspace = (root, config) => {
|
|
323
|
+
const engine = createEngine(root, config);
|
|
324
|
+
const { files, findings: walked } = walkSources(engine);
|
|
325
|
+
const perFile = [];
|
|
326
|
+
let checked = 0;
|
|
327
|
+
for (const file of files) {
|
|
328
|
+
const text = attempt(() => readFileSync(nodePath.join(root, file), "utf8"));
|
|
329
|
+
if (text.ok) {
|
|
330
|
+
checked += 1;
|
|
331
|
+
perFile.push(...findingsForFile(engine, file, text.value));
|
|
332
|
+
} else if (!vanished(text.error)) {
|
|
333
|
+
checked += 1;
|
|
334
|
+
perFile.push(unreadablePathFinding(file, codeOf(text.error), "file"));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const all = [
|
|
338
|
+
...walked,
|
|
339
|
+
...perFile.filter((finding) => finding.rule !== "package-without-kind"),
|
|
340
|
+
...graphFindings(engine)
|
|
341
|
+
];
|
|
342
|
+
return {
|
|
343
|
+
files: checked,
|
|
344
|
+
findings: [...withoutBaselined(engine, all), ...staleBaseline(engine, all)]
|
|
345
|
+
};
|
|
346
|
+
};
|
|
347
|
+
const formatFinding = (finding) => `${finding.file}:${finding.line}:${finding.column + 1} ${finding.rule}: ${finding.message}`;
|
|
348
|
+
export { graphFindings as a, walkSources as i, formatFinding as n, staleBaseline as o, listSourceFiles as r, checkWorkspace as t };
|
package/dist/cli.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as Config } from "./config-
|
|
1
|
+
import { n as Config } from "./config-CmWm5RyK.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 {
|
|
3
|
-
import { n as formatFinding, t as checkWorkspace } from "./check-
|
|
2
|
+
import { y as findWorkspaceRoot } from "./engine-CwR59HVO.mjs";
|
|
3
|
+
import { n as formatFinding, t as checkWorkspace } from "./check-CWi8LUnB.mjs";
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
5
|
import nodePath from "node:path";
|
|
6
6
|
import process from "node:process";
|
|
@@ -33,7 +33,7 @@ interface FileClass {
|
|
|
33
33
|
readonly files: readonly string[];
|
|
34
34
|
readonly policy: FilePolicy;
|
|
35
35
|
}
|
|
36
|
-
declare const ruleNames: readonly ["unknown-workspace-package", "package-without-kind", "package-outside-workspace", "invalid-package", "forbidden-edge", "type-only-edge", "relative-import-leaves-package", "package-path-escape", "shipped-imports-development-file", "self-import-by-name", "forbidden-external", "runtime-module", "test-only-in-shipped", "undeclared-import", "computed-import", "unresolved-import", "package-cycle", "stale-baseline", "parse-error"];
|
|
36
|
+
declare const ruleNames: readonly ["unknown-workspace-package", "package-without-kind", "package-outside-workspace", "directory-without-package", "invalid-package", "symbolic-link", "unreadable-path", "forbidden-edge", "type-only-edge", "relative-import-leaves-package", "package-path-escape", "shipped-imports-development-file", "self-import-by-name", "forbidden-external", "runtime-module", "test-only-in-shipped", "undeclared-import", "computed-import", "unresolved-import", "package-cycle", "stale-baseline", "parse-error"];
|
|
37
37
|
type RuleName = (typeof ruleNames)[number];
|
|
38
38
|
/** A finding the repository tolerates for now. The check fails when no finding needs it any more, so the list shrinks. */
|
|
39
39
|
interface BaselineEntry {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { builtinModules } from "node:module";
|
|
2
|
-
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, statSync } from "node:fs";
|
|
3
3
|
import nodePath from "node:path";
|
|
4
4
|
import * as v from "valibot";
|
|
5
5
|
import { ResolverFactory } from "oxc-resolver";
|
|
@@ -9,7 +9,10 @@ const ruleNames = [
|
|
|
9
9
|
"unknown-workspace-package",
|
|
10
10
|
"package-without-kind",
|
|
11
11
|
"package-outside-workspace",
|
|
12
|
+
"directory-without-package",
|
|
12
13
|
"invalid-package",
|
|
14
|
+
"symbolic-link",
|
|
15
|
+
"unreadable-path",
|
|
13
16
|
"forbidden-edge",
|
|
14
17
|
"type-only-edge",
|
|
15
18
|
"relative-import-leaves-package",
|
|
@@ -41,8 +44,22 @@ const defaultFileClasses = [{
|
|
|
41
44
|
],
|
|
42
45
|
policy: "development"
|
|
43
46
|
}];
|
|
44
|
-
/**
|
|
45
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Build output, coverage reports and the caches tools write under a package root (generated, never written by hand),
|
|
49
|
+
* never read unless `output` says otherwise. Shipped code that imports from them is still judged.
|
|
50
|
+
*/
|
|
51
|
+
const defaultOutput = [
|
|
52
|
+
"dist/**",
|
|
53
|
+
"coverage/**",
|
|
54
|
+
".wrangler/**",
|
|
55
|
+
".astro/**",
|
|
56
|
+
".svelte-kit/**",
|
|
57
|
+
".next/**",
|
|
58
|
+
".turbo/**",
|
|
59
|
+
".vercel/**",
|
|
60
|
+
".nuxt/**",
|
|
61
|
+
".output/**"
|
|
62
|
+
];
|
|
46
63
|
const allManifestFields = [
|
|
47
64
|
"dependencies",
|
|
48
65
|
"devDependencies",
|
|
@@ -520,6 +537,17 @@ const attempt = (operation) => {
|
|
|
520
537
|
};
|
|
521
538
|
/** The message of something thrown, which need not be an Error. */
|
|
522
539
|
const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
540
|
+
/**
|
|
541
|
+
* Whether something thrown by `node:fs` says the path is no longer there: `ENOENT`, or `ENOTDIR` when a directory on the
|
|
542
|
+
* way was replaced by a file. A path removed between being listed and being read (a test's temporary tree, an editor's
|
|
543
|
+
* swap file) fails this way, and is skipped rather than reported as unreadable.
|
|
544
|
+
*/
|
|
545
|
+
const vanished = (cause) => {
|
|
546
|
+
const code = codeOf(cause);
|
|
547
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
548
|
+
};
|
|
549
|
+
/** The error code of something thrown by `node:fs` (`EACCES`), or its message when it has none. */
|
|
550
|
+
const codeOf = (cause) => typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string" ? cause.code : messageOf(cause);
|
|
523
551
|
// src/workspace.ts
|
|
524
552
|
/**
|
|
525
553
|
* The workspace as `package.json` files declare it: the root's `workspaces` globs, and each package's real name (never
|
|
@@ -527,12 +555,36 @@ const messageOf = (cause) => cause instanceof Error ? cause.message : String(cau
|
|
|
527
555
|
*/
|
|
528
556
|
/** The manifest at `path`, or why it cannot be read. */
|
|
529
557
|
const readManifest = (path) => {
|
|
530
|
-
const
|
|
531
|
-
if (!
|
|
532
|
-
|
|
558
|
+
const text = attempt(() => readFileSync(path, "utf8"));
|
|
559
|
+
if (!text.ok) return failure({
|
|
560
|
+
message: `${path}: ${messageOf(text.error)}`,
|
|
561
|
+
vanished: vanished(text.error)
|
|
562
|
+
});
|
|
563
|
+
const parsed = attempt(() => JSON.parse(text.value));
|
|
564
|
+
if (!parsed.ok) return failure({
|
|
565
|
+
message: `${path}: ${messageOf(parsed.error)}`,
|
|
566
|
+
vanished: false
|
|
567
|
+
});
|
|
568
|
+
if (typeof parsed.value !== "object" || parsed.value === null) return failure({
|
|
569
|
+
message: `${path} is not a JSON object`,
|
|
570
|
+
vanished: false
|
|
571
|
+
});
|
|
533
572
|
return success(parsed.value);
|
|
534
573
|
};
|
|
535
|
-
|
|
574
|
+
/** Whether a dependency field has the shape npm and Bun require: an object of package names to version ranges. */
|
|
575
|
+
const isRecord = (field) => typeof field === "object" && field !== null && !Array.isArray(field);
|
|
576
|
+
const namesOf = (field) => isRecord(field) ? Object.keys(field).toSorted() : [];
|
|
577
|
+
/**
|
|
578
|
+
* A message for each dependency field of `manifest` that is present and not an object (`"dependencies": "x"`, a list, a
|
|
579
|
+
* number, null). Such a field declares nothing the check can read, so without a finding its edges would pass unseen.
|
|
580
|
+
*/
|
|
581
|
+
const malformedFields = (manifest) => allManifestFields.filter((field) => manifest[field] !== void 0 && !isRecord(manifest[field])).map((field) => {
|
|
582
|
+
const value = manifest[field];
|
|
583
|
+
return {
|
|
584
|
+
field,
|
|
585
|
+
message: `${field} is ${value === null ? "null" : Array.isArray(value) ? "a list" : `a ${typeof value}`}, not an object of package names to version ranges, so no dependency in it is read`
|
|
586
|
+
};
|
|
587
|
+
});
|
|
536
588
|
const fieldsOf = (manifest) => ({
|
|
537
589
|
dependencies: namesOf(manifest.dependencies),
|
|
538
590
|
devDependencies: namesOf(manifest.devDependencies),
|
|
@@ -547,7 +599,7 @@ const aliasesOf = (manifest) => {
|
|
|
547
599
|
manifest.devDependencies,
|
|
548
600
|
manifest.optionalDependencies,
|
|
549
601
|
manifest.peerDependencies
|
|
550
|
-
]) if (
|
|
602
|
+
]) if (isRecord(field)) for (const [name, spec] of Object.entries(field)) {
|
|
551
603
|
const target = typeof spec === "string" ? /^npm:(?<target>@?[^@]+)/u.exec(spec)?.groups?.target : void 0;
|
|
552
604
|
if (target !== void 0) aliases.set(name, target);
|
|
553
605
|
}
|
|
@@ -589,7 +641,21 @@ const findWorkspaceRoot = (start) => {
|
|
|
589
641
|
if (nodePath.dirname(candidate) === candidate) return root ?? nearest;
|
|
590
642
|
}
|
|
591
643
|
};
|
|
592
|
-
|
|
644
|
+
/** The first of `path`'s directories (repository-relative), itself included, that is a symbolic link. */
|
|
645
|
+
const linkAlong = (root, path) => {
|
|
646
|
+
const parts = path === "" ? [] : path.split("/");
|
|
647
|
+
return parts.map((_, index) => parts.slice(0, index + 1).join("/")).find((prefix) => {
|
|
648
|
+
const status = attempt(() => lstatSync(nodePath.join(root, prefix)));
|
|
649
|
+
return status.ok && status.value.isSymbolicLink();
|
|
650
|
+
});
|
|
651
|
+
};
|
|
652
|
+
/**
|
|
653
|
+
* The directories under `root` that `pattern` matches, skipping `node_modules` and dot directories, as npm and Bun
|
|
654
|
+
* workspaces do. A symbolic link is never followed, not even one the pattern names literally or passes through (a
|
|
655
|
+
* `packages` that is a link), and a directory that cannot be listed is passed over; with `unlisted`, those and the dot
|
|
656
|
+
* directories the pattern would match are collected there, so the caller can report them.
|
|
657
|
+
*/
|
|
658
|
+
const directoriesMatching = (root, pattern, unlisted) => {
|
|
593
659
|
const clean = (pattern.startsWith("!") ? pattern.slice(1) : pattern).replace(/^\.\//u, "").replace(/\/$/u, "");
|
|
594
660
|
const fixed = [];
|
|
595
661
|
for (const segment of clean.split("/")) {
|
|
@@ -602,16 +668,34 @@ const directoriesMatching = (root, pattern) => {
|
|
|
602
668
|
const walk = (relative, depth) => {
|
|
603
669
|
if (matchesGlob(relative, clean)) found.push(relative);
|
|
604
670
|
if (depth >= depthLimit) return;
|
|
605
|
-
const
|
|
606
|
-
|
|
671
|
+
const listed = attempt(() => readdirSync(nodePath.join(root, relative), { withFileTypes: true }));
|
|
672
|
+
if (!listed.ok) {
|
|
673
|
+
if (!vanished(listed.error)) unlisted?.unreadable.push({
|
|
674
|
+
directory: relative,
|
|
675
|
+
code: codeOf(listed.error)
|
|
676
|
+
});
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
for (const entry of listed.value) {
|
|
680
|
+
const path = relative === "" ? entry.name : `${relative}/${entry.name}`;
|
|
681
|
+
if (entry.isDirectory() && entry.name !== "node_modules" && !entry.name.startsWith(".")) walk(path, depth + 1);
|
|
682
|
+
else if (entry.isDirectory() && entry.name.startsWith(".") && entry.name !== ".git" && matchesGlob(path, clean)) unlisted?.dotDirectories.push(path);
|
|
683
|
+
else if (entry.isSymbolicLink() && matchesGlob(path, clean)) unlisted?.links.push(path);
|
|
684
|
+
}
|
|
607
685
|
};
|
|
686
|
+
const link = linkAlong(root, base);
|
|
687
|
+
if (link !== void 0) {
|
|
688
|
+
unlisted?.links.push(link);
|
|
689
|
+
return [];
|
|
690
|
+
}
|
|
608
691
|
if (base === clean) return existsSync(nodePath.join(root, base)) ? [base] : [];
|
|
609
|
-
|
|
692
|
+
const status = attempt(() => statSync(nodePath.join(root, base)));
|
|
693
|
+
if (status.ok && status.value.isDirectory()) walk(base, base === "" ? 0 : base.split("/").length);
|
|
610
694
|
return found;
|
|
611
695
|
};
|
|
612
696
|
const loadWorkspace = (root) => {
|
|
613
697
|
const rootRead = readManifest(nodePath.join(root, "package.json"));
|
|
614
|
-
if (!rootRead.ok) throw new Error(rootRead.error);
|
|
698
|
+
if (!rootRead.ok) throw new Error(rootRead.error.message);
|
|
615
699
|
const rootManifest = rootRead.value;
|
|
616
700
|
const globs = workspaceGlobs(rootManifest) ?? [];
|
|
617
701
|
const directories = /* @__PURE__ */ new Set();
|
|
@@ -622,10 +706,11 @@ const loadWorkspace = (root) => {
|
|
|
622
706
|
const byName = /* @__PURE__ */ new Map();
|
|
623
707
|
for (const directory of [...directories].toSorted()) {
|
|
624
708
|
const read = readManifest(nodePath.join(root, directory, "package.json"));
|
|
709
|
+
if (!read.ok && read.error.vanished) continue;
|
|
625
710
|
if (!read.ok) {
|
|
626
711
|
problems.push({
|
|
627
712
|
directory,
|
|
628
|
-
message: `package.json cannot be read: ${read.error}`
|
|
713
|
+
message: `package.json cannot be read: ${read.error.message}`
|
|
629
714
|
});
|
|
630
715
|
continue;
|
|
631
716
|
}
|
|
@@ -645,6 +730,11 @@ const loadWorkspace = (root) => {
|
|
|
645
730
|
});
|
|
646
731
|
continue;
|
|
647
732
|
}
|
|
733
|
+
for (const { field, message } of malformedFields(manifest)) problems.push({
|
|
734
|
+
directory,
|
|
735
|
+
message,
|
|
736
|
+
field
|
|
737
|
+
});
|
|
648
738
|
const entry = {
|
|
649
739
|
name: manifest.name,
|
|
650
740
|
directory,
|
|
@@ -659,7 +749,8 @@ const loadWorkspace = (root) => {
|
|
|
659
749
|
packages,
|
|
660
750
|
byName,
|
|
661
751
|
rootManifest: fieldsOf(rootManifest),
|
|
662
|
-
problems
|
|
752
|
+
problems,
|
|
753
|
+
globs
|
|
663
754
|
};
|
|
664
755
|
};
|
|
665
756
|
/** The package whose directory holds the repository-relative `file`, the deepest one when packages nest. */
|
|
@@ -1120,6 +1211,97 @@ const reads = (engine, file) => {
|
|
|
1120
1211
|
const owner = packageOfFile(engine.workspace, file);
|
|
1121
1212
|
return owner !== void 0 && !matchesAny(file.slice(owner.directory.length + 1), engine.config.output);
|
|
1122
1213
|
};
|
|
1214
|
+
/** Whether `glob` matches every path under `directory`: it is `**`, or it or a directory above it matches what comes before a trailing `/**`. */
|
|
1215
|
+
const coversDirectory = (glob, directory) => {
|
|
1216
|
+
if (glob === "**") return true;
|
|
1217
|
+
if (!glob.endsWith("/**") || directory === "") return false;
|
|
1218
|
+
const parts = directory.split("/");
|
|
1219
|
+
return parts.some((_, index) => matchesGlob(parts.slice(0, index + 1).join("/"), glob.slice(0, -3)));
|
|
1220
|
+
};
|
|
1221
|
+
/**
|
|
1222
|
+
* Whether `glob` might match a path under `directory`: its leading segments agree with the directory's until a `**`,
|
|
1223
|
+
* and it is longer than the directory. A glob with braces is taken to match, so a doubt counts as a match.
|
|
1224
|
+
*/
|
|
1225
|
+
const mayMatchUnder = (glob, directory) => {
|
|
1226
|
+
if (glob.includes("{")) return true;
|
|
1227
|
+
const globParts = glob.split("/");
|
|
1228
|
+
const directoryParts = directory === "" ? [] : directory.split("/");
|
|
1229
|
+
for (const [index, part] of directoryParts.entries()) {
|
|
1230
|
+
const globPart = globParts[index];
|
|
1231
|
+
if (globPart === void 0) return false;
|
|
1232
|
+
if (globPart === "**") return true;
|
|
1233
|
+
if (!matchesGlob(part, globPart)) return false;
|
|
1234
|
+
}
|
|
1235
|
+
return globParts.length > directoryParts.length;
|
|
1236
|
+
};
|
|
1237
|
+
/** Whether an `ignore` glob covers every file under the repository-relative `directory`. */
|
|
1238
|
+
const ignoresDirectory = (engine, directory) => engine.config.ignore.some((glob) => coversDirectory(glob, directory));
|
|
1239
|
+
/**
|
|
1240
|
+
* Whether the check reads no file under the repository-relative `directory`, so a symbolic link or an unreadable
|
|
1241
|
+
* directory there hides nothing: an `ignore` glob covers it, or (relative to the package that owns it) an `output` glob
|
|
1242
|
+
* or an unchecked file class does, with no earlier class that might match a file in it. A glob covers a directory only
|
|
1243
|
+
* through a trailing `/**`; any doubt counts as read, so the answer errs towards a finding.
|
|
1244
|
+
*/
|
|
1245
|
+
const skipsDirectory = (engine, directory) => {
|
|
1246
|
+
const { config } = engine;
|
|
1247
|
+
if (ignoresDirectory(engine, directory)) return true;
|
|
1248
|
+
const owner = packageOfFile(engine.workspace, `${directory}/`);
|
|
1249
|
+
if (owner === void 0) return true;
|
|
1250
|
+
const relative = directory.slice(owner.directory.length + 1);
|
|
1251
|
+
if (config.output.some((glob) => coversDirectory(glob, relative))) return true;
|
|
1252
|
+
for (const fileClass of config.fileClasses) {
|
|
1253
|
+
if (fileClass.files.some((glob) => coversDirectory(glob, relative))) return fileClass.policy === "unchecked";
|
|
1254
|
+
if (fileClass.policy !== "unchecked" && fileClass.files.some((glob) => mayMatchUnder(glob, relative))) return false;
|
|
1255
|
+
}
|
|
1256
|
+
return false;
|
|
1257
|
+
};
|
|
1258
|
+
/** Directories the walk never enters, wherever they are: dependencies and git's own data. */
|
|
1259
|
+
const passedOver = (name) => name === "node_modules" || name === ".git";
|
|
1260
|
+
/**
|
|
1261
|
+
* Whether a symbolic link at the repository-relative `path` is a finding: always, whatever it points at, unless it lies
|
|
1262
|
+
* in (or is) a directory the check skips on purpose (`node_modules`, `.git`, or one `skipsDirectory` covers).
|
|
1263
|
+
*/
|
|
1264
|
+
const reportsLink = (engine, path) => !path.split("/").some(passedOver) && !skipsDirectory(engine, path);
|
|
1265
|
+
/** A symbolic link the check met and did not follow, at `path` (repository-relative), with the destination it names. */
|
|
1266
|
+
const symbolicLinkFinding = (path, destination) => ({
|
|
1267
|
+
rule: "symbolic-link",
|
|
1268
|
+
file: path,
|
|
1269
|
+
line: 1,
|
|
1270
|
+
column: 0,
|
|
1271
|
+
message: `${path} is a symbolic link to ${destination}, which the check never follows, so what it reaches goes unchecked: replace it with the files themselves, or baseline it`,
|
|
1272
|
+
target: destination
|
|
1273
|
+
});
|
|
1274
|
+
/** A directory the check needed to list, or a source file it needed to read, and could not, with the error code that said why. */
|
|
1275
|
+
const unreadablePathFinding = (path, code, kind = "directory") => ({
|
|
1276
|
+
rule: "unreadable-path",
|
|
1277
|
+
file: path,
|
|
1278
|
+
line: 1,
|
|
1279
|
+
column: 0,
|
|
1280
|
+
message: kind === "directory" ? `${path} cannot be read (${code}), so the files in it cannot be checked: make it readable, or add ${path}/** to ignore` : `${path} cannot be read (${code}), so its imports cannot be checked: make it readable, or add it to ignore`,
|
|
1281
|
+
target: path
|
|
1282
|
+
});
|
|
1283
|
+
/** The destination a symbolic link names, as written, or undefined when the link has been removed since it was seen. */
|
|
1284
|
+
const linkDestination = (absolute) => {
|
|
1285
|
+
const destination = attempt(() => readlinkSync(absolute));
|
|
1286
|
+
if (destination.ok) return destination.value;
|
|
1287
|
+
return vanished(destination.error) ? void 0 : "an unreadable destination";
|
|
1288
|
+
};
|
|
1289
|
+
/**
|
|
1290
|
+
* The first symbolic link on the way from `owner`'s directory down to `file` (both repository-relative), the file
|
|
1291
|
+
* itself included. The CLI's walk never follows one, but oxlint does, and lints the files behind it under the link's
|
|
1292
|
+
* path.
|
|
1293
|
+
*/
|
|
1294
|
+
const linkOnPath = (engine, owner, file) => {
|
|
1295
|
+
const parts = file.slice(owner.directory.length + 1).split("/");
|
|
1296
|
+
for (const [index] of parts.entries()) {
|
|
1297
|
+
const path = [owner.directory, ...parts.slice(0, index + 1)].join("/");
|
|
1298
|
+
try {
|
|
1299
|
+
if (lstatSync(nodePath.join(engine.workspace.root, path)).isSymbolicLink()) return path;
|
|
1300
|
+
} catch {
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1123
1305
|
/** The rules that hold for shipped code and not for development files. */
|
|
1124
1306
|
const shippedOnlyRules = /* @__PURE__ */ new Set([
|
|
1125
1307
|
"runtime-module",
|
|
@@ -1160,7 +1342,13 @@ const developmentFileBreaks = (engine, file) => {
|
|
|
1160
1342
|
const directory = nodePath.dirname(next);
|
|
1161
1343
|
read.set(directory, modifiedTime(nodePath.join(engine.workspace.root, directory)) ?? -1);
|
|
1162
1344
|
const reached = [];
|
|
1163
|
-
const
|
|
1345
|
+
const text = attempt(() => readFileSync(absolute, "utf8"));
|
|
1346
|
+
if (!text.ok && vanished(text.error)) continue;
|
|
1347
|
+
if (!text.ok) {
|
|
1348
|
+
breaks = `${next} cannot be read (${codeOf(text.error)}), so it cannot be judged`;
|
|
1349
|
+
break;
|
|
1350
|
+
}
|
|
1351
|
+
const finding = findingsForFile(engine, next, text.value, { developmentImports: reached }).find((candidate) => shippedOnlyRules.has(candidate.rule));
|
|
1164
1352
|
if (finding !== void 0) breaks = `${next === file ? "" : `through ${next}, `}${finding.rule} at line ${finding.line}: ${finding.message}`;
|
|
1165
1353
|
queue.push(...reached);
|
|
1166
1354
|
}
|
|
@@ -1180,7 +1368,13 @@ const findingsForFile = (engine, file, text, options = {}) => {
|
|
|
1180
1368
|
const asShipped = developmentImports !== void 0;
|
|
1181
1369
|
const { config, workspace } = engine;
|
|
1182
1370
|
const owner = packageOfFile(workspace, file);
|
|
1183
|
-
if (owner === void 0 || !sourceFile.test(file)
|
|
1371
|
+
if (owner === void 0 || !sourceFile.test(file)) return [];
|
|
1372
|
+
const link = linkOnPath(engine, owner, file);
|
|
1373
|
+
if (link !== void 0) {
|
|
1374
|
+
const destination = reportsLink(engine, link) ? linkDestination(nodePath.join(workspace.root, link)) : void 0;
|
|
1375
|
+
return destination === void 0 ? [] : [symbolicLinkFinding(link, destination)];
|
|
1376
|
+
}
|
|
1377
|
+
if (!reads(engine, file) && developmentImports === void 0) return [];
|
|
1184
1378
|
const packageRelative = file.slice(owner.directory.length + 1);
|
|
1185
1379
|
const classified = effectivePolicy(engine, owner, packageRelative);
|
|
1186
1380
|
const policy = asShipped ? "shipped" : classified;
|
|
@@ -1311,4 +1505,4 @@ const findingsForFile = (engine, file, text, options = {}) => {
|
|
|
1311
1505
|
const baselineCovers = (entry, finding) => entry.file === finding.file && entry.rule === finding.rule && entry.target === finding.target;
|
|
1312
1506
|
/** `findings` less those the baseline covers. */
|
|
1313
1507
|
const withoutBaselined = (engine, findings) => findings.filter((finding) => !engine.config.baseline.some((entry) => baselineCovers(entry, finding)));
|
|
1314
|
-
export { resolveConfig as S,
|
|
1508
|
+
export { defaultFileClasses as A, attempt as C, matchesAny as D, vanished as E, resolveConfig as M, matchesGlob as O, packageOfFile as S, failure as T, importForms as _, effectivePolicy as a, loadWorkspace as b, linkDestination as c, reads as d, reportsLink as f, withoutBaselined as g, unreadablePathFinding as h, edgeRefusal as i, defineConfig as j, configProblems as k, passedOver as l, symbolicLinkFinding as m, coversDirectory as n, findingsForFile as o, skipsDirectory as p, createEngine as r, ignoresDirectory as s, baselineCovers as t, policyOf as u, directoriesMatching as v, codeOf as w, packageNameOf as x, findWorkspaceRoot 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-CmWm5RyK.mjs";
|
|
2
2
|
interface WorkspacePackage {
|
|
3
3
|
readonly name: string;
|
|
4
4
|
/** Repository-relative directory, `/`-separated (`libraries/parcel`). */
|
|
@@ -14,11 +14,18 @@ interface Workspace {
|
|
|
14
14
|
readonly byName: ReadonlyMap<string, WorkspacePackage>;
|
|
15
15
|
/** The root package's own dependency fields. */
|
|
16
16
|
readonly rootManifest: Readonly<Record<ManifestField, readonly string[]>>;
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Members whose package.json is wrong. With no name, a name another member has, or a package.json that cannot be read,
|
|
19
|
+
* the member cannot be checked and is not in `packages`; with a dependency field that is not an object, it is, as if
|
|
20
|
+
* that field declared nothing, and `field` names it.
|
|
21
|
+
*/
|
|
18
22
|
readonly problems: readonly {
|
|
19
23
|
readonly directory: string;
|
|
20
24
|
readonly message: string;
|
|
25
|
+
readonly field?: ManifestField;
|
|
21
26
|
}[];
|
|
27
|
+
/** The root package.json's `workspaces` globs, negations (`!apps/old`) included. */
|
|
28
|
+
readonly globs: readonly string[];
|
|
22
29
|
}
|
|
23
30
|
/**
|
|
24
31
|
* The workspace root for a file in `start`. The nearest directory whose `package.json` `workspaces` hold `start` is a
|
|
@@ -125,11 +132,21 @@ export declare const findingsForFile: (engine: Engine, file: string, text: strin
|
|
|
125
132
|
}) => Finding[];
|
|
126
133
|
/** `findings` less those the baseline covers. */
|
|
127
134
|
export declare const withoutBaselined: (engine: Engine, findings: readonly Finding[]) => Finding[];
|
|
135
|
+
/** The source files of the workspace's packages, and what kept the walk from seeing all of them. */
|
|
136
|
+
interface SourceWalk {
|
|
137
|
+
readonly files: readonly string[];
|
|
138
|
+
/** A `symbolic-link` finding for each link outside the directories it skips, an `unreadable-path` one for each directory it could not list. */
|
|
139
|
+
readonly findings: readonly Finding[];
|
|
140
|
+
}
|
|
128
141
|
/**
|
|
129
|
-
* Every source file the engine reads, found by walking each package's directory (skipping `node_modules` and
|
|
130
|
-
* directories). A walk rather than `git ls-files`: generated and gitignored sources still
|
|
131
|
-
* or submodule inside a package is still part of it.
|
|
142
|
+
* Every source file the engine reads, found by walking each package's directory (skipping `node_modules` and `.git`;
|
|
143
|
+
* other dot directories hold sources too). A walk rather than `git ls-files`: generated and gitignored sources still
|
|
144
|
+
* ship, and a nested repository or submodule inside a package is still part of it. A symbolic link is never followed:
|
|
145
|
+
* every one is a finding, whatever it points at, unless it lies in a directory the check skips on purpose; and so is a
|
|
146
|
+
* directory the walk cannot list. A path removed between being listed and being read is skipped, with no finding.
|
|
132
147
|
*/
|
|
148
|
+
export declare const walkSources: (engine: Engine) => SourceWalk;
|
|
149
|
+
/** The source files `walkSources` finds. */
|
|
133
150
|
export declare const listSourceFiles: (engine: Engine) => string[];
|
|
134
151
|
interface CheckResult {
|
|
135
152
|
readonly files: number;
|
|
@@ -167,4 +184,4 @@ interface ImportForms {
|
|
|
167
184
|
readonly parseErrors: readonly string[];
|
|
168
185
|
}
|
|
169
186
|
export declare const importForms: (path: string, source: string) => ImportForms;
|
|
170
|
-
export { type BaselineEntry, type CheckResult, type ComputedSite, type Config, type Engine, type FileClass, type FilePolicy, type Finding, type ImportForms, type ImportPolicy, type ImportSite, type KindRule, type ManifestField, type PackageRule, type ResolvedConfig, type RuleName, type Workspace, type WorkspacePackage, configProblems, defaultFileClasses, defineConfig, resolveConfig };
|
|
187
|
+
export { type BaselineEntry, type CheckResult, type ComputedSite, type Config, type Engine, type FileClass, type FilePolicy, type Finding, type ImportForms, type ImportPolicy, type ImportSite, type KindRule, type ManifestField, type PackageRule, type ResolvedConfig, type RuleName, type SourceWalk, type Workspace, type WorkspacePackage, configProblems, defaultFileClasses, defineConfig, resolveConfig };
|
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { a as
|
|
3
|
-
export { checkWorkspace, configProblems, createEngine, defaultFileClasses, defineConfig, effectivePolicy, findWorkspaceRoot, findingsForFile, formatFinding, graphFindings, importForms, listSourceFiles, loadWorkspace, matchesAny, matchesGlob, packageNameOf, packageOfFile, policyOf, reads, resolveConfig, staleBaseline, withoutBaselined };
|
|
1
|
+
import { A as defaultFileClasses, D as matchesAny, M as resolveConfig, O as matchesGlob, S as packageOfFile, _ as importForms, a as effectivePolicy, b as loadWorkspace, d as reads, g as withoutBaselined, j as defineConfig, k as configProblems, o as findingsForFile, r as createEngine, u as policyOf, x as packageNameOf, y as findWorkspaceRoot } from "./engine-CwR59HVO.mjs";
|
|
2
|
+
import { a as graphFindings, i as walkSources, n as formatFinding, o as staleBaseline, r as listSourceFiles, t as checkWorkspace } from "./check-CWi8LUnB.mjs";
|
|
3
|
+
export { checkWorkspace, configProblems, createEngine, defaultFileClasses, defineConfig, effectivePolicy, findWorkspaceRoot, findingsForFile, formatFinding, graphFindings, importForms, listSourceFiles, loadWorkspace, matchesAny, matchesGlob, packageNameOf, packageOfFile, policyOf, reads, resolveConfig, staleBaseline, walkSources, withoutBaselined };
|
package/dist/plugin.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { C as attempt, T as failure, g as withoutBaselined, o as findingsForFile, r as createEngine, y as findWorkspaceRoot } from "./engine-CwR59HVO.mjs";
|
|
2
2
|
import { statSync } from "node:fs";
|
|
3
3
|
import nodePath from "node:path";
|
|
4
4
|
// src/plugin.ts
|
|
@@ -9,7 +9,9 @@ import nodePath from "node:path";
|
|
|
9
9
|
* jsPlugins: ["ox-dep-lint/plugin"],
|
|
10
10
|
* rules: { "dep-lint/imports": ["error", depLintConfig] }
|
|
11
11
|
*
|
|
12
|
-
* Whole-graph checks (package.json edges and externals, package cycles,
|
|
12
|
+
* Whole-graph checks (package.json edges and externals, package cycles, directories no package owns, unreadable
|
|
13
|
+
* directories, stale baseline entries) need the CLI. oxlint follows symbolic links, so a file it reaches through one is
|
|
14
|
+
* reported as `symbolic-link` and not read further, as the CLI never reads it.
|
|
13
15
|
*/
|
|
14
16
|
/** How often (milliseconds) a long-lived process (an editor's language server) looks for changed package.json files. */
|
|
15
17
|
const recheckInterval = 1e3;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ox-dep-lint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|
package/dist/check-F4cLmfYv.mjs
DELETED
|
@@ -1,223 +0,0 @@
|
|
|
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
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
-
import nodePath from "node:path";
|
|
4
|
-
// src/graph.ts
|
|
5
|
-
/**
|
|
6
|
-
* Checks that need the whole workspace rather than one file: edges declared only in `package.json`, cycles between
|
|
7
|
-
* packages, packages no kind holds, and baseline entries no finding needs any more. Only the CLI runs these, because an
|
|
8
|
-
* oxlint plugin sees one file at a time and never sees `package.json`.
|
|
9
|
-
*/
|
|
10
|
-
const runtimeFields = [
|
|
11
|
-
"dependencies",
|
|
12
|
-
"optionalDependencies",
|
|
13
|
-
"peerDependencies"
|
|
14
|
-
];
|
|
15
|
-
const graphFindings = (engine) => {
|
|
16
|
-
const { config, workspace } = engine;
|
|
17
|
-
const findings = [];
|
|
18
|
-
const at = {
|
|
19
|
-
line: 1,
|
|
20
|
-
column: 0
|
|
21
|
-
};
|
|
22
|
-
for (const problem of workspace.problems) findings.push({
|
|
23
|
-
rule: "invalid-package",
|
|
24
|
-
file: `${problem.directory}/package.json`,
|
|
25
|
-
...at,
|
|
26
|
-
message: problem.message,
|
|
27
|
-
target: problem.directory
|
|
28
|
-
});
|
|
29
|
-
const members = /* @__PURE__ */ new Set([...workspace.packages.map((entry) => entry.directory), ...workspace.problems.map((problem) => problem.directory)]);
|
|
30
|
-
for (const kind of Object.values(config.kinds)) for (const pattern of kind.packages) for (const directory of directoriesMatching(workspace.root, pattern)) if (!members.has(directory) && existsSync(nodePath.join(workspace.root, directory, "package.json"))) {
|
|
31
|
-
members.add(directory);
|
|
32
|
-
findings.push({
|
|
33
|
-
rule: "package-outside-workspace",
|
|
34
|
-
file: `${directory}/package.json`,
|
|
35
|
-
...at,
|
|
36
|
-
message: `${directory} is a package a kind names, but the root package.json's workspaces leave it out, so nothing in it is checked`,
|
|
37
|
-
target: directory
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
for (const entry of workspace.packages) {
|
|
41
|
-
const manifest = `${entry.directory}/package.json`;
|
|
42
|
-
if (engine.kindOf(entry) === void 0) {
|
|
43
|
-
findings.push({
|
|
44
|
-
rule: "package-without-kind",
|
|
45
|
-
file: manifest,
|
|
46
|
-
...at,
|
|
47
|
-
message: `${entry.name} matches no kind: add its directory to one`,
|
|
48
|
-
target: entry.name
|
|
49
|
-
});
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
const kindRule = config.kinds[engine.kindOf(entry) ?? ""];
|
|
53
|
-
const extra = config.packages[entry.name]?.externals ?? [];
|
|
54
|
-
for (const field of config.imports.shippedFields) for (const name of entry.manifest[field]) {
|
|
55
|
-
const outside = !workspace.byName.has(name) && !config.workspaceScopes.some((scope) => name.startsWith(scope));
|
|
56
|
-
const installed = entry.aliases.get(name) ?? name;
|
|
57
|
-
if (outside && kindRule !== void 0 && kindRule.externals !== "any" && !kindRule.externals.includes(installed) && !extra.includes(installed)) findings.push({
|
|
58
|
-
rule: "forbidden-external",
|
|
59
|
-
file: manifest,
|
|
60
|
-
...at,
|
|
61
|
-
message: `${field} names ${installed === name ? name : `${name} (${installed})`}, which its kind does not allow`,
|
|
62
|
-
target: installed
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
for (const field of config.imports.manifestEdgeFields) for (const name of entry.manifest[field]) {
|
|
66
|
-
const to = workspace.byName.get(name);
|
|
67
|
-
if (to === void 0) {
|
|
68
|
-
if (config.workspaceScopes.some((scope) => name.startsWith(scope))) findings.push({
|
|
69
|
-
rule: "unknown-workspace-package",
|
|
70
|
-
file: manifest,
|
|
71
|
-
...at,
|
|
72
|
-
message: `${field} names ${name}, which is not a workspace package`,
|
|
73
|
-
target: name
|
|
74
|
-
});
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
const refusal = edgeRefusal(engine, entry, to, true);
|
|
78
|
-
if (refusal !== void 0 && to.name !== entry.name) findings.push({
|
|
79
|
-
rule: refusal.rule,
|
|
80
|
-
file: manifest,
|
|
81
|
-
...at,
|
|
82
|
-
message: `${field}: ${refusal.why}`,
|
|
83
|
-
target: name
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
findings.push(...packageCycles(engine));
|
|
88
|
-
return findings;
|
|
89
|
-
};
|
|
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
|
-
*/
|
|
156
|
-
const packageCycles = (engine) => {
|
|
157
|
-
const { workspace } = engine;
|
|
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)]));
|
|
159
|
-
const findings = [];
|
|
160
|
-
const seen = /* @__PURE__ */ new Set();
|
|
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
|
-
});
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return findings;
|
|
177
|
-
};
|
|
178
|
-
/** Baseline entries that no finding in `findings` needs; each is a finding of its own, so the baseline only shrinks. */
|
|
179
|
-
const staleBaseline = (engine, findings) => engine.config.baseline.filter((entry) => !findings.some((finding) => baselineCovers(entry, finding))).map((entry) => ({
|
|
180
|
-
rule: "stale-baseline",
|
|
181
|
-
file: entry.file,
|
|
182
|
-
line: 1,
|
|
183
|
-
column: 0,
|
|
184
|
-
message: `the baseline entry for ${entry.rule}${entry.target === void 0 ? "" : ` (${entry.target})`} is no longer needed: remove it`,
|
|
185
|
-
target: entry.rule
|
|
186
|
-
}));
|
|
187
|
-
// src/check.ts
|
|
188
|
-
/** The whole check: every source file of the workspace through the engine, plus the whole-graph checks and the baseline. */
|
|
189
|
-
const isFile = (path) => {
|
|
190
|
-
try {
|
|
191
|
-
return statSync(path).isFile();
|
|
192
|
-
} catch {
|
|
193
|
-
return false;
|
|
194
|
-
}
|
|
195
|
-
};
|
|
196
|
-
/**
|
|
197
|
-
* Every source file the engine reads, found by walking each package's directory (skipping `node_modules` and dot
|
|
198
|
-
* directories). A walk rather than `git ls-files`: generated and gitignored sources still ship, and a nested repository
|
|
199
|
-
* or submodule inside a package is still part of it.
|
|
200
|
-
*/
|
|
201
|
-
const listSourceFiles = (engine) => {
|
|
202
|
-
const files = /* @__PURE__ */ new Set();
|
|
203
|
-
const walk = (relative) => {
|
|
204
|
-
for (const entry of readdirSync(nodePath.join(engine.workspace.root, relative), { withFileTypes: true })) {
|
|
205
|
-
const path = `${relative}/${entry.name}`;
|
|
206
|
-
if (entry.isDirectory() && entry.name !== "node_modules" && !entry.name.startsWith(".")) walk(path);
|
|
207
|
-
else if ((entry.isFile() || entry.isSymbolicLink() && isFile(nodePath.join(engine.workspace.root, path))) && reads(engine, path)) files.add(path);
|
|
208
|
-
}
|
|
209
|
-
};
|
|
210
|
-
for (const entry of engine.workspace.packages) walk(entry.directory);
|
|
211
|
-
return [...files].toSorted();
|
|
212
|
-
};
|
|
213
|
-
const checkWorkspace = (root, config) => {
|
|
214
|
-
const engine = createEngine(root, config);
|
|
215
|
-
const files = listSourceFiles(engine);
|
|
216
|
-
const all = [...files.flatMap((file) => findingsForFile(engine, file, readFileSync(nodePath.join(root, file), "utf8"))).filter((finding) => finding.rule !== "package-without-kind"), ...graphFindings(engine)];
|
|
217
|
-
return {
|
|
218
|
-
files: files.length,
|
|
219
|
-
findings: [...withoutBaselined(engine, all), ...staleBaseline(engine, all)]
|
|
220
|
-
};
|
|
221
|
-
};
|
|
222
|
-
const formatFinding = (finding) => `${finding.file}:${finding.line}:${finding.column + 1} ${finding.rule}: ${finding.message}`;
|
|
223
|
-
export { staleBaseline as a, graphFindings as i, formatFinding as n, listSourceFiles as r, checkWorkspace as t };
|