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 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 | 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. | CLI |
32
- | `package-cycle` | A cycle between packages through their runtime dependency fields. | CLI |
33
- | `stale-baseline` | A baseline entry that no finding needs any more, so the baseline can only shrink. | CLI |
34
- | `parse-error` | A file that does not parse, so its imports cannot be checked. | both |
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 `dist/**` and `coverage/**` are not read; `output` changes that list (`[]` reads everything). A package's `testSupportFiles` are development files too (a conformance suite's entry, for example).
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`, dot directories and `output`), so generated and gitignored sources and nested repositories are checked too, and runs the whole-graph checks:
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-DO_LRXM8.mjs";
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 { d as findWorkspaceRoot } from "./engine-Dc5tRuHT.mjs";
3
- import { n as formatFinding, t as checkWorkspace } from "./check-F4cLmfYv.mjs";
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
- /** Build output and coverage reports under a package, never read unless `output` says otherwise. */
45
- const defaultOutput = ["dist/**", "coverage/**"];
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 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`);
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
- const namesOf = (field) => typeof field === "object" && field !== null && !Array.isArray(field) ? Object.keys(field).toSorted() : [];
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 (typeof field === "object" && field !== null && !Array.isArray(field)) for (const [name, spec] of Object.entries(field)) {
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
- const directoriesMatching = (root, pattern) => {
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 absolute = nodePath.join(root, relative);
606
- for (const entry of readdirSync(absolute, { withFileTypes: true })) if (entry.isDirectory() && entry.name !== "node_modules" && !entry.name.startsWith(".")) walk(relative === "" ? entry.name : `${relative}/${entry.name}`, depth + 1);
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
- if (existsSync(nodePath.join(root, base)) && statSync(nodePath.join(root, base)).isDirectory()) walk(base, base === "" ? 0 : base.split("/").length);
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 finding = findingsForFile(engine, next, readFileSync(absolute, "utf8"), { developmentImports: reached }).find((candidate) => shippedOnlyRules.has(candidate.rule));
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) || !reads(engine, file) && developmentImports === void 0) return [];
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, 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 };
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-DO_LRXM8.mjs";
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
- /** Members that cannot be checked: no name, a name another member has, an unreadable package.json. */
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 dot
130
- * directories). A walk rather than `git ls-files`: generated and gitignored sources still ship, and a nested repository
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 { 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
- 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 { a as findingsForFile, c as withoutBaselined, d as findWorkspaceRoot, g as failure, h as attempt, n as createEngine } from "./engine-Dc5tRuHT.mjs";
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, stale baseline entries) need the CLI.
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.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",
@@ -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 };