vigiles 32.1.1 → 33.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,22 +34,7 @@ exports.findUntestedSurfacesInFiles = findUntestedSurfacesInFiles;
34
34
  const posix_path_js_1 = require("./posix-path.js");
35
35
  const layout_js_1 = require("./core/layout.js");
36
36
  const coverage_evidence_js_1 = require("./coverage-evidence.js");
37
- // Mirrors src/test-coverage.ts constants. VALUES are re-declared, never imported
38
- // — test-coverage.ts pulls in node:fs/glob, and this twin must stay browser-safe.
39
- const DEFAULT_TEST_SUFFIXES = [
40
- ".harness.ts",
41
- ".harness.mts",
42
- ".harness.cts",
43
- ".harness.js",
44
- ".harness.mjs",
45
- ".harness.cjs",
46
- ".eval.ts",
47
- ".eval.mts",
48
- ".eval.cts",
49
- ".eval.js",
50
- ".eval.mjs",
51
- ".eval.cjs",
52
- ];
37
+ const source_kinds_js_1 = require("./source-kinds.js");
53
38
  const IGNORE_MARKER = "vigiles:ignore-test";
54
39
  /** Mirror of the DEFAULT_IGNORE globs (root-anchored), as a key predicate. */
55
40
  function isIgnored(key) {
@@ -190,7 +175,7 @@ function discoverTests(files) {
190
175
  for (const path of Object.keys(files)) {
191
176
  if (isIgnored(path))
192
177
  continue;
193
- if (DEFAULT_TEST_SUFFIXES.some((s) => path.endsWith(s))) {
178
+ if ((0, source_kinds_js_1.isVigilesTest)((0, posix_path_js_1.basename)(path))) {
194
179
  out.push((0, coverage_evidence_js_1.prepareTest)(path));
195
180
  }
196
181
  }
@@ -78,6 +78,7 @@ const ts_runner_caps_js_1 = require("./ts-runner-caps.js");
78
78
  const glob_1 = require("glob");
79
79
  const glob_ignore_js_1 = require("./core/glob-ignore.js");
80
80
  const exclude_js_1 = require("./exclude.js");
81
+ const source_kinds_js_1 = require("./source-kinds.js");
81
82
  const layout_js_1 = require("./core/layout.js");
82
83
  const coverage_evidence_js_1 = require("./coverage-evidence.js");
83
84
  const coverage_artifact_js_1 = require("./coverage-artifact.js");
@@ -98,9 +99,6 @@ function surfaceGlobs(dir, leaf, materializeRoot) {
98
99
  // ---------------------------------------------------------------------------
99
100
  // Internals
100
101
  // ---------------------------------------------------------------------------
101
- /** Every extension Node executes directly. `.mts`/`.cts` are real (TS 4.7+) and
102
- * Node 22 strips their types with no toolchain — measured, not assumed. */
103
- const RUNNABLE_EXTS = "{ts,mts,cts,js,mjs,cjs}";
104
102
  /**
105
103
  * 🔴 `*.test.*` USED TO BE HERE, AND REMOVING IT IS THE POINT.
106
104
  *
@@ -128,10 +126,11 @@ const RUNNABLE_EXTS = "{ts,mts,cts,js,mjs,cjs}";
128
126
  * for hooks/agents and dropping them for skills — because a rule with a per-kind
129
127
  * exception is what this file just spent a day removing.
130
128
  */
131
- const DEFAULT_TEST_GLOBS = [
132
- `**/*.harness.${RUNNABLE_EXTS}`,
133
- `**/*.eval.${RUNNABLE_EXTS}`,
134
- ];
129
+ // Read off the table in `source-kinds.ts` — the same one `vigiles test`,
130
+ // `vigiles eval` and the hook/provider discovery classify by — so the patterns
131
+ // that credit a test and the files a hook directory refuses to compile cannot
132
+ // drift apart.
133
+ const DEFAULT_TEST_GLOBS = source_kinds_js_1.DEFAULT_TEST_GLOBS;
135
134
  const DEFAULT_IGNORE = [
136
135
  "node_modules/**",
137
136
  "dist/**",
@@ -298,6 +297,10 @@ function discoverTests(basePath, globs, ignore) {
298
297
  cwd: basePath,
299
298
  ignore,
300
299
  dot: true,
300
+ // `glob` defaults this to TRUE on macOS and Windows, so `a.hook.HARNESS.mjs`
301
+ // was a test there and unclaimed on Linux — and the classifier is
302
+ // case-sensitive. One answer on every OS.
303
+ nocase: false,
301
304
  });
302
305
  // Prepared ONCE per file (comment-strip + declaration parse), not once per
303
306
  // (surface × file) pair — the matching below is quadratic by nature.
@@ -737,7 +740,7 @@ function staleRunNote(report) {
737
740
  ];
738
741
  }
739
742
  /** Names a default vitest/jest run collects — the suffixes vigiles will not use. */
740
- const FOREIGN_RUNNER_SUFFIX = /\.(test|spec)\.(ts|mts|cts|js|mjs|cjs)$/;
743
+ const FOREIGN_RUNNER_SUFFIX = new RegExp(`\\.(test|spec)\\.(${source_kinds_js_1.RUNNABLE_EXTS.join("|")})$`);
741
744
  /**
742
745
  * The would-be colocated tests that only their NAME disqualifies.
743
746
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "32.1.1",
3
+ "version": "33.1.0",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -67,6 +67,8 @@
67
67
  "hooks",
68
68
  "skills",
69
69
  "!skills/**/*.spec.ts",
70
+ "packages/eslint-config/index.mjs",
71
+ "packages/eslint-config/README.md",
70
72
  "README.md",
71
73
  "LICENSE"
72
74
  ],
@@ -75,7 +77,7 @@
75
77
  "build:core": "tsc",
76
78
  "test": "npm run build && vitest run",
77
79
  "coverage": "npm run build && vitest run --coverage",
78
- "lint": "eslint src/",
80
+ "lint": "eslint src/ && node scripts/lint-ceilings.mjs",
79
81
  "fmt": "prettier --write .",
80
82
  "fmt:check": "prettier --check .",
81
83
  "test:unit": "npm run build && vitest run --project unit",
@@ -112,7 +114,8 @@
112
114
  "conventional-changelog-conventionalcommits": "9.3.1",
113
115
  "eslint": "^10.1.0",
114
116
  "eslint-import-resolver-typescript": "^4.4.5",
115
- "eslint-plugin-boundaries": "^6.0.2",
117
+ "eslint-plugin-boundaries": "^7.2.0",
118
+ "eslint-plugin-functional": "^10.0.1",
116
119
  "eslint-plugin-sonarjs": "^4.0.2",
117
120
  "globals": "^17.4.0",
118
121
  "jest": "^30.4.2",
@@ -0,0 +1,17 @@
1
+ # @vigiles/eslint-config (private)
2
+
3
+ The strict lint rules vigiles and paperlint share: size limits, functional
4
+ rules, no type assertions, and `layers()`, the hexagonal boundary.
5
+
6
+ **This is not part of vigiles' API.** It has no entry in vigiles' `exports`, no
7
+ version of its own, and it can change in any release without notice. It ships
8
+ inside the `vigiles` npm package only so that paperlint can import it by path:
9
+
10
+ ```js
11
+ // PRIVATE file of vigiles, not a public API. If it moves, this import fails loudly.
12
+ import { layers } from "./node_modules/vigiles/packages/eslint-config/index.mjs";
13
+ ```
14
+
15
+ Nobody else should depend on it. The plugins it uses (eslint-plugin-boundaries,
16
+ eslint-plugin-functional, eslint-plugin-sonarjs, typescript-eslint) are not
17
+ bundled; the importing repo installs its own.
@@ -0,0 +1,344 @@
1
+ // @ts-check
2
+ /**
3
+ * The strict lint rules vigiles and paperlint share.
4
+ *
5
+ * `.mjs`, not `.ts`: ESLint loads a config without a build step, and paperlint
6
+ * imports this file by path from `node_modules/vigiles/packages/eslint-config/`.
7
+ * That import is deliberate and private: vigiles' `exports` has no entry for this
8
+ * file, so nobody else should depend on it.
9
+ *
10
+ * The plugins are not bundled. Each repo installs eslint-plugin-boundaries,
11
+ * eslint-plugin-functional and eslint-plugin-sonarjs itself, and the imports
12
+ * below resolve to that copy.
13
+ *
14
+ * What each repo keeps for itself: its folder layout (passed to `layers`), its
15
+ * ceiling table for old functions (passed to `ceilings`) and its
16
+ * eslint-suppressions.json.
17
+ */
18
+ import boundaries from "eslint-plugin-boundaries";
19
+ import functional from "eslint-plugin-functional";
20
+ import sonarjs from "eslint-plugin-sonarjs";
21
+
22
+ /** @typedef {import("eslint").Linter.Config} Config */
23
+ /** @typedef {import("eslint").Linter.RuleEntry} RuleEntry */
24
+ /** @typedef {{ files: string[], ignores?: string[] }} Scope */
25
+
26
+ /** One collection library: remeda. These would be a second one for the same job. */
27
+ export const ONE_COLLECTION_LIBRARY = ["lodash", "lodash-es", "ramda"].map(
28
+ (name) => ({ name, message: "Use remeda." }),
29
+ );
30
+
31
+ /** `x as unknown as T` switches the type checker off. For `no-restricted-syntax`. */
32
+ export const AS_UNKNOWN_AS = {
33
+ selector:
34
+ "TSAsExpression > TSAsExpression[typeAnnotation.type='TSUnknownKeyword']",
35
+ message:
36
+ "`as unknown as` switches the type checker off. Convert with a function, or fix the type.",
37
+ };
38
+
39
+ /** A function is one job; at thirty lines a second one starts to hide. */
40
+ const MAX_LINES = { max: 30, skipComments: true, skipBlankLines: true };
41
+
42
+ /**
43
+ * Limits on the size of a function. These rules report one finding per function
44
+ * however far over the limit it is, so a suppressed finding would let the
45
+ * function keep growing. Old functions get a ceiling instead (see `ceilings`).
46
+ */
47
+ export const SIZE_LIMITS = {
48
+ complexity: 10,
49
+ "sonarjs/cognitive-complexity": 10,
50
+ "max-depth": 3,
51
+ "max-params": 4,
52
+ "max-lines-per-function": MAX_LINES.max,
53
+ "max-nested-callbacks": 3,
54
+ };
55
+
56
+ /** @typedef {keyof typeof SIZE_LIMITS} SizeRule */
57
+
58
+ /**
59
+ * @param {string} rule
60
+ * @param {number} n
61
+ * @returns {RuleEntry}
62
+ */
63
+ const sizeRule = (rule, n) =>
64
+ rule === "max-lines-per-function"
65
+ ? ["error", { ...MAX_LINES, max: n }]
66
+ : ["error", n];
67
+
68
+ /**
69
+ * The strict rules for TypeScript source. Needs the @typescript-eslint plugin registered.
70
+ *
71
+ * @param {string[]} files
72
+ * @returns {Config}
73
+ */
74
+ export const strictTypeScript = (files) => ({
75
+ files,
76
+ plugins: { sonarjs },
77
+ rules: {
78
+ ...Object.fromEntries(
79
+ Object.entries(SIZE_LIMITS).map(([rule, n]) => [rule, sizeRule(rule, n)]),
80
+ ),
81
+ "@typescript-eslint/no-explicit-any": "error",
82
+ // `x as T` is a claim the checker takes on trust. Parse or narrow instead.
83
+ "@typescript-eslint/consistent-type-assertions": [
84
+ "error",
85
+ { assertionStyle: "never" },
86
+ ],
87
+ "@typescript-eslint/switch-exhaustiveness-check": [
88
+ "error",
89
+ { considerDefaultExhaustiveForUnions: true },
90
+ ],
91
+ // Not set here: `no-restricted-imports` and `no-restricted-syntax`. Flat config
92
+ // replaces a rule's options per file group, so a repo that sets either for its
93
+ // own reasons would silently drop ours. Merge ONE_COLLECTION_LIBRARY and
94
+ // AS_UNKNOWN_AS into the repo's own blocks instead.
95
+ },
96
+ });
97
+
98
+ /**
99
+ * A test's `describe` callback is a list of cases, not a function to split.
100
+ *
101
+ * @param {string[]} files
102
+ * @returns {Config}
103
+ */
104
+ export const testSizes = (files) => ({
105
+ files,
106
+ rules: { "max-lines-per-function": sizeRule("max-lines-per-function", 60) },
107
+ });
108
+
109
+ /**
110
+ * Per-file ceilings for functions written before the limits: each number is the
111
+ * file's measured maximum, so its functions may shrink and may not grow. Lower a
112
+ * number after a refactor; a file leaves the table once it is under the limits.
113
+ *
114
+ * @param {Record<string, Partial<Record<SizeRule, number>>>} table
115
+ * @returns {Config[]}
116
+ */
117
+ export const ceilings = (table) =>
118
+ Object.entries(table).map(([file, max]) => ({
119
+ files: [file],
120
+ rules: Object.fromEntries(
121
+ Object.entries(max).map(([rule, n]) => [rule, sizeRule(rule, n)]),
122
+ ),
123
+ }));
124
+
125
+ /**
126
+ * No `let`, no mutation, no loops, readonly parameters. Tests may mutate their fixtures.
127
+ *
128
+ * @param {Scope} scope
129
+ * @returns {Config}
130
+ */
131
+ export const functionalCode = ({ files, ignores }) => ({
132
+ files,
133
+ ignores,
134
+ plugins: { functional },
135
+ rules: {
136
+ "functional/no-let": "error",
137
+ "functional/immutable-data": "error",
138
+ "functional/no-loop-statements": "error",
139
+ "functional/prefer-immutable-types": [
140
+ "error",
141
+ { enforcement: "ReadonlyShallow", ignoreInferredTypes: true },
142
+ ],
143
+ },
144
+ });
145
+
146
+ /** Node modules that ARE effects. Only an `io` file, a test or the root may import one. */
147
+ export const IO_MODULES = [
148
+ "fs",
149
+ "fs/promises",
150
+ "child_process",
151
+ "os",
152
+ "net",
153
+ "http",
154
+ "https",
155
+ "worker_threads",
156
+ "readline",
157
+ "readline/promises",
158
+ ].flatMap((m) => [m, `node:${m}`]);
159
+
160
+ /** Libraries the domain and ports may use: types, pure path arithmetic, hashing, collections. */
161
+ export const PURE_LIBRARIES = [
162
+ "ts-essentials",
163
+ "remeda",
164
+ "path",
165
+ "node:path",
166
+ "crypto",
167
+ "node:crypto",
168
+ ];
169
+
170
+ /** What app files may use besides: Node's pure helpers. */
171
+ export const APP_LIBRARIES = [
172
+ ...PURE_LIBRARIES,
173
+ "util",
174
+ "node:util",
175
+ "url",
176
+ "node:url",
177
+ "module",
178
+ "node:module",
179
+ ];
180
+
181
+ const ORIGINS = ["external", "core"];
182
+ /** @param {string[]} source */
183
+ const libraries = (source) => ({ to: { module: { origin: ORIGINS, source } } });
184
+ /** @param {...string} types */
185
+ const elements = (...types) => ({ element: { types: { anyOf: types } } });
186
+ const APP = { file: { categories: "app" } };
187
+ const ROOT = { file: { categories: "root" } };
188
+
189
+ /** Who may import whom: the domain knows only itself, an adapter never another adapter. */
190
+ /**
191
+ * @param {string[]} domainLibraries
192
+ * @param {string[]} appLibraries
193
+ */
194
+ const knowledgePolicies = (domainLibraries, appLibraries) => [
195
+ { from: elements("domain"), allow: { to: elements("domain") } },
196
+ { from: elements("port"), allow: { to: elements("domain", "port") } },
197
+ { from: APP, allow: { to: [elements("domain", "port"), APP] } },
198
+ { from: elements("adapter"), allow: { to: elements("domain", "port") } },
199
+ { from: ROOT, allow: { to: [{ element: { type: "*" } }, APP, ROOT] } },
200
+ { from: elements("domain", "port"), allow: libraries(domainLibraries) },
201
+ { from: APP, allow: libraries(appLibraries) },
202
+ {
203
+ from: [elements("adapter"), ROOT],
204
+ allow: { to: { module: { origin: ORIGINS } } },
205
+ },
206
+ {
207
+ from: { file: { categories: "test" } },
208
+ allow: {
209
+ to: [
210
+ { element: { type: "*" } },
211
+ APP,
212
+ ROOT,
213
+ { module: { origin: ORIGINS } },
214
+ ],
215
+ },
216
+ },
217
+ ];
218
+
219
+ /**
220
+ * Effects only in io files, tests and the root. Written as "nobody, then these
221
+ * three": a file with no category has `categories: null`, and a `noneOf` query
222
+ * never matches null, so the negative form would exempt every plain file.
223
+ */
224
+ /** @param {string} ioPattern */
225
+ const effectPolicies = (ioPattern) => [
226
+ {
227
+ disallow: { to: { module: { origin: "core", source: IO_MODULES } } },
228
+ message: `{{ dependency.source }} is I/O in a file that is not ${ioPattern}. Take a port instead, and do the I/O in an adapter's io file or the root.`,
229
+ },
230
+ {
231
+ from: { file: { categories: ["io", "test", "root"] } },
232
+ allow: { to: { module: { origin: "core", source: IO_MODULES } } },
233
+ },
234
+ ];
235
+
236
+ /**
237
+ * @param {string} type
238
+ * @param {string | undefined} pattern
239
+ * @param {Record<string, unknown>} [extra]
240
+ */
241
+ const element = (type, pattern, extra = {}) =>
242
+ pattern === undefined
243
+ ? []
244
+ : [{ type, pattern, partialMatch: false, ...extra }];
245
+
246
+ /**
247
+ * Where a repo keeps each layer. Patterns are relative to `root`.
248
+ *
249
+ * @typedef {object} Layout
250
+ * @property {string[]} files the files the rules apply to
251
+ * @property {string} root the repository directory
252
+ * @property {string} domain folder of the domain, e.g. "src/core"
253
+ * @property {string} [ports] folder of the ports, if the repo has one
254
+ * @property {string} adapters adapter folders, one element each, e.g. "src/adapters/*"
255
+ * @property {string} cliRoot the composition root: the only non-test file that may wire anything to anything
256
+ * @property {string} app the flat application files, e.g. "src/*.ts"
257
+ * @property {string} tests test files
258
+ * @property {string} io files allowed to do I/O, e.g. "src/adapters/*\/*.io.ts"
259
+ * @property {string[]} [domainLibraries] what the domain and ports may import (default PURE_LIBRARIES)
260
+ * @property {string[]} [appLibraries] what app files may import (default APP_LIBRARIES)
261
+ * @property {object} [resolver] import/resolver settings (default: the TypeScript resolver)
262
+ * @property {object[]} [extraElements] more boundaries elements, e.g. a folder of plain-JS modules
263
+ * @property {object[]} [extraAllows] more allow-policies, checked before the effects rule
264
+ */
265
+
266
+ /**
267
+ * The hexagonal layers, as one eslint-plugin-boundaries block. Every linted file
268
+ * must be a declared element or category, so a new file cannot sit outside the
269
+ * rules.
270
+ *
271
+ * `root` must be the repository directory. Without `boundaries/root-path` the
272
+ * plugin matches patterns against `process.cwd()`, and lint started from any
273
+ * other directory classifies nothing and passes.
274
+ *
275
+ * @param {Layout} o
276
+ * @returns {Config}
277
+ */
278
+ export const layers = (o) => ({
279
+ files: o.files,
280
+ // @ts-expect-error The plugin's types describe an ES module whose `default` is
281
+ // the plugin; Node hands a default import of this CommonJS file the plugin
282
+ // itself (keys meta, rules, configs). Runtime is right, the declaration is not.
283
+ plugins: { boundaries },
284
+ settings: {
285
+ "boundaries/root-path": o.root,
286
+ "import/resolver": o.resolver ?? { typescript: { alwaysTryTypes: true } },
287
+ "boundaries/elements": [
288
+ ...element("port", o.ports),
289
+ ...element("domain", o.domain),
290
+ ...element("adapter", o.adapters, { capture: ["name"] }),
291
+ ...(o.extraElements ?? []),
292
+ ],
293
+ "boundaries/files": [
294
+ { category: "root", pattern: o.cliRoot, exclusive: true },
295
+ { category: "app", pattern: o.app },
296
+ { category: "test", pattern: o.tests },
297
+ { category: "io", pattern: o.io },
298
+ ],
299
+ },
300
+ rules: {
301
+ "boundaries/no-unknown-files": "error",
302
+ "boundaries/no-unknown-dependencies": "error",
303
+ "boundaries/dependencies": [
304
+ "error",
305
+ {
306
+ default: "disallow",
307
+ checkAllOrigins: true,
308
+ policies: [
309
+ ...knowledgePolicies(
310
+ o.domainLibraries ?? PURE_LIBRARIES,
311
+ o.appLibraries ?? APP_LIBRARIES,
312
+ ),
313
+ ...(o.extraAllows ?? []),
314
+ ...effectPolicies(o.io),
315
+ ],
316
+ },
317
+ ],
318
+ },
319
+ });
320
+
321
+ /**
322
+ * `process` and `fetch` are not imports, so `layers` cannot see them. Same scope as its effects rule.
323
+ *
324
+ * @param {Scope} scope
325
+ * @returns {Config}
326
+ */
327
+ export const ioGlobals = ({ files, ignores }) => ({
328
+ files,
329
+ ignores,
330
+ rules: {
331
+ "no-restricted-globals": [
332
+ "error",
333
+ {
334
+ name: "process",
335
+ message:
336
+ "The environment is an input: the root reads it and passes a value in.",
337
+ },
338
+ {
339
+ name: "fetch",
340
+ message: "Network access belongs in an adapter's io file.",
341
+ },
342
+ ],
343
+ },
344
+ });