@theholocron/astromech 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -119,7 +119,7 @@ one place, `src/linters.ts`.
119
119
  | `actionlint` | `GITHUB_ACTIONS` | yes | `actionlint` (usually CI-only) |
120
120
  | `gitleaks` | `GITLEAKS` | yes | `gitleaks dir` (usually CI-only) |
121
121
  | `editorconfig` | `EDITORCONFIG` | yes | `editorconfig-checker` (usually CI-only) |
122
- | `commitlint` | `GIT_COMMITLINT` | yes | `commitlint --last` |
122
+ | `commitlint` | `GIT_COMMITLINT` | yes | (commit-msg hook + CI) |
123
123
  | `git-merge-conflict-markers` | `GIT_MERGE_CONFLICT_MARKERS` | yes | — (CI only) |
124
124
  | `markdownlint` | `MARKDOWN` | on `.markdownlint*` | `markdownlint-cli2` |
125
125
 
package/dist/index.d.mts CHANGED
@@ -5,12 +5,19 @@ import { r as TasksConfig } from "./schema-DbpiBZCP.mjs";
5
5
  *
6
6
  * Resolution:
7
7
  *
8
+ * 0. task === "lint" → the linter aggregate (see below)
8
9
  * 1. turbo.json defines the task → `turbo run <task>`
9
10
  * 2. package.json has a `<task>` script → `<pm> run <task>`
10
11
  * (unless it's the `holocron run …` thin caller — that recurses)
11
12
  * 3. TASKS[task].local resolves → `<tool> <args> <org-flags> <passthrough>`
12
13
  * 4. known task, nothing to run → "no <task> task" (exit 0, or 1 with --required)
13
14
  * 5. unknown task → "unknown task" (exit 1)
15
+ *
16
+ * `lint` runs the resolved linter set (`config.tasks` `linters`, else
17
+ * auto-detected): the eslint slot goes through the standard turbo / script /
18
+ * `eslint .` resolution (so turbo caching is kept); every other linter runs
19
+ * its `localBin` when found on PATH. Missing tools are flagged; the exit code
20
+ * is the worst of the lot.
14
21
  */
15
22
  /** Minimal structural logger — `@theholocron/logger`'s `Logger` satisfies it. */
16
23
  interface RunLogger {
@@ -35,6 +42,8 @@ interface RunDeps {
35
42
  readFile: (path: string) => string;
36
43
  fileExists: (path: string) => boolean;
37
44
  listDir: (path: string) => string[];
45
+ /** `node_modules/.bin/<bin>` or a PATH entry; `null` when not runnable. */
46
+ lookPath: (cwd: string, bin: string) => string | null;
38
47
  }
39
48
  interface RunTaskInput extends RunDeps {
40
49
  /** Registry task name, e.g. `"test"`. */
@@ -47,6 +56,8 @@ interface RunTaskInput extends RunDeps {
47
56
  dryRun?: boolean;
48
57
  /** Turn "no such task for this repo" (normally exit 0) into a failure. */
49
58
  required?: boolean;
59
+ /** The `lint` task's explicit linter list from `config.tasks`, if any. */
60
+ linters?: string[];
50
61
  }
51
62
  interface RunTaskReport {
52
63
  status: "ok" | "fail" | "skip" | "dry-run" | "unknown";
@@ -242,6 +253,8 @@ interface AstromechOptions {
242
253
  readFile?: (path: string) => string;
243
254
  fileExists?: (path: string) => boolean;
244
255
  listDir?: (path: string) => string[];
256
+ /** Injectable binary lookup (tests). Default checks `node_modules/.bin` then `PATH`. */
257
+ lookPath?: (cwd: string, bin: string) => string | null;
245
258
  }
246
259
  interface RunOptions {
247
260
  /** Args after `--`, forwarded to the tool / turbo / script. */
package/dist/index.mjs CHANGED
@@ -51,24 +51,159 @@ const TASKS = {
51
51
  /** Every task name the registry knows. */
52
52
  const KNOWN_TASKS = new Set(Object.keys(TASKS));
53
53
  //#endregion
54
+ //#region src/linters.ts
55
+ /**
56
+ * Known linters, in execution order. `always` entries are the current
57
+ * hard-coded super-linter baseline; `prettier` is always-on because the org
58
+ * applies it universally (super-linter only lints files that exist).
59
+ */
60
+ const LINTERS = {
61
+ eslint: {
62
+ validate: ["VALIDATE_JAVASCRIPT_ES", "VALIDATE_TYPESCRIPT_ES"],
63
+ localBin: "eslint",
64
+ localArgs: ["."],
65
+ detect: [
66
+ "eslint.config.ts",
67
+ "eslint.config.js",
68
+ "eslint.config.mjs",
69
+ "eslint.config.cjs",
70
+ ".eslintrc",
71
+ ".eslintrc.json",
72
+ ".eslintrc.yml",
73
+ ".eslintrc.yaml",
74
+ ".eslintrc.cjs"
75
+ ],
76
+ configInput: "eslint-config"
77
+ },
78
+ prettier: {
79
+ validate: [
80
+ "VALIDATE_JAVASCRIPT_PRETTIER",
81
+ "VALIDATE_JSX_PRETTIER",
82
+ "VALIDATE_TYPESCRIPT_PRETTIER",
83
+ "VALIDATE_TSX",
84
+ "VALIDATE_MARKDOWN_PRETTIER"
85
+ ],
86
+ fix: [
87
+ "FIX_JAVASCRIPT_PRETTIER",
88
+ "FIX_JSX_PRETTIER",
89
+ "FIX_TYPESCRIPT_PRETTIER",
90
+ "FIX_TSX",
91
+ "FIX_MARKDOWN_PRETTIER"
92
+ ],
93
+ always: true,
94
+ localBin: "prettier",
95
+ localArgs: ["--check", "."],
96
+ configInput: "prettier-config"
97
+ },
98
+ yamllint: {
99
+ validate: ["VALIDATE_YAML"],
100
+ always: true,
101
+ localBin: "yamllint",
102
+ localArgs: ["."],
103
+ installHint: "brew install yamllint",
104
+ configInput: "yaml-config"
105
+ },
106
+ actionlint: {
107
+ validate: ["VALIDATE_GITHUB_ACTIONS"],
108
+ always: true,
109
+ localBin: "actionlint",
110
+ localArgs: [],
111
+ installHint: "brew install actionlint"
112
+ },
113
+ gitleaks: {
114
+ validate: ["VALIDATE_GITLEAKS"],
115
+ always: true,
116
+ localBin: "gitleaks",
117
+ localArgs: ["dir", "--no-banner"],
118
+ installHint: "brew install gitleaks"
119
+ },
120
+ editorconfig: {
121
+ validate: ["VALIDATE_EDITORCONFIG"],
122
+ always: true,
123
+ localBin: "editorconfig-checker",
124
+ localArgs: [],
125
+ installHint: "brew install editorconfig-checker"
126
+ },
127
+ commitlint: {
128
+ validate: ["VALIDATE_GIT_COMMITLINT"],
129
+ always: true
130
+ },
131
+ "git-merge-conflict-markers": {
132
+ validate: ["VALIDATE_GIT_MERGE_CONFLICT_MARKERS"],
133
+ always: true
134
+ },
135
+ markdownlint: {
136
+ validate: ["VALIDATE_MARKDOWN"],
137
+ localBin: "markdownlint-cli2",
138
+ localArgs: ["**/*.md"],
139
+ detect: [
140
+ ".markdownlint.json",
141
+ ".markdownlint.jsonc",
142
+ ".markdownlint.yaml",
143
+ ".markdownlint.yml",
144
+ ".markdownlint-cli2.jsonc",
145
+ ".markdownlint-cli2.yaml",
146
+ ".markdownlint-cli2.mjs"
147
+ ]
148
+ }
149
+ };
150
+ /** Every linter name the registry knows. */
151
+ const LINTER_NAMES = new Set(Object.keys(LINTERS));
152
+ /**
153
+ * Resolve the linter set for a repo. An `explicit` list (from
154
+ * `config.tasks`) wins verbatim; otherwise every `always` linter plus every
155
+ * linter whose `detect` filenames are present at the repo root. Result is
156
+ * ordered by {@link LINTERS} declaration order.
157
+ *
158
+ * @throws when an `explicit` name is not in the registry — a typo is a
159
+ * config bug, not a linter to silently skip.
160
+ */
161
+ function resolveLinters(opts) {
162
+ const order = Object.keys(LINTERS);
163
+ if (opts.explicit && opts.explicit.length > 0) {
164
+ const unknown = opts.explicit.filter((n) => !LINTER_NAMES.has(n));
165
+ if (unknown.length > 0) throw new Error(`unknown linter${unknown.length > 1 ? "s" : ""} ${unknown.map((n) => `"${n}"`).join(", ")} — known: ${order.join(", ")}`);
166
+ const wanted = new Set(opts.explicit);
167
+ return order.filter((n) => wanted.has(n)).map((name) => ({
168
+ name,
169
+ def: LINTERS[name]
170
+ }));
171
+ }
172
+ const present = new Set(opts.rootFiles);
173
+ return order.filter((name) => {
174
+ const def = LINTERS[name];
175
+ return def.always === true || def.detect.some((f) => present.has(f));
176
+ }).map((name) => ({
177
+ name,
178
+ def: LINTERS[name]
179
+ }));
180
+ }
181
+ //#endregion
54
182
  //#region src/run.ts
55
183
  /**
56
184
  * `holocron run <task> [-- <passthrough>]` — run a registry task locally.
57
185
  *
58
186
  * Resolution:
59
187
  *
188
+ * 0. task === "lint" → the linter aggregate (see below)
60
189
  * 1. turbo.json defines the task → `turbo run <task>`
61
190
  * 2. package.json has a `<task>` script → `<pm> run <task>`
62
191
  * (unless it's the `holocron run …` thin caller — that recurses)
63
192
  * 3. TASKS[task].local resolves → `<tool> <args> <org-flags> <passthrough>`
64
193
  * 4. known task, nothing to run → "no <task> task" (exit 0, or 1 with --required)
65
194
  * 5. unknown task → "unknown task" (exit 1)
195
+ *
196
+ * `lint` runs the resolved linter set (`config.tasks` `linters`, else
197
+ * auto-detected): the eslint slot goes through the standard turbo / script /
198
+ * `eslint .` resolution (so turbo caching is kept); every other linter runs
199
+ * its `localBin` when found on PATH. Missing tools are flagged; the exit code
200
+ * is the worst of the lot.
66
201
  */
67
- function runTask(input) {
68
- const { print, logger, exec, readFile, fileExists, listDir, task, cwd } = input;
69
- const passthrough = input.passthrough ?? [];
202
+ /** The per-command runner — `runTask` and `runLintAggregate` share it. */
203
+ function makeRunOne(input) {
204
+ const { print, logger, exec, task, cwd } = input;
70
205
  const dryRun = input.dryRun ?? false;
71
- const run = (cmd, args) => {
206
+ return (cmd, args) => {
72
207
  const command = [cmd, ...args].join(" ");
73
208
  if (dryRun) {
74
209
  print(`would run: ${command}`);
@@ -97,6 +232,12 @@ function runTask(input) {
97
232
  ...status === "fail" ? { message: `\`${command}\` exited ${exitCode}` } : {}
98
233
  };
99
234
  };
235
+ }
236
+ function runTask(input) {
237
+ const { print, logger, readFile, fileExists, listDir, task, cwd } = input;
238
+ const passthrough = input.passthrough ?? [];
239
+ const run = makeRunOne(input);
240
+ if (task === "lint") return runLintAggregate(input);
100
241
  if (turboDefinesTask(cwd, task, readFile, fileExists)) {
101
242
  const args = [
102
243
  "run",
@@ -150,6 +291,85 @@ function runTask(input) {
150
291
  message: `unknown task "${task}"`
151
292
  };
152
293
  }
294
+ /**
295
+ * `holocron run lint` — the resolved linter set, run natively. The eslint
296
+ * slot reuses turbo / the `lint` script / `eslint .`; the rest run their
297
+ * `localBin` when it resolves on PATH. Worst exit code wins.
298
+ */
299
+ function runLintAggregate(input) {
300
+ const { print, logger, readFile, fileExists, listDir, lookPath, cwd } = input;
301
+ const passthrough = input.passthrough ?? [];
302
+ const dryRun = input.dryRun ?? false;
303
+ const runOne = makeRunOne(input);
304
+ const pass = passthrough.length ? ["--", ...passthrough] : [];
305
+ let rootFiles;
306
+ try {
307
+ rootFiles = listDir(cwd);
308
+ } catch {
309
+ rootFiles = [];
310
+ }
311
+ const resolved = resolveLinters({
312
+ explicit: input.linters,
313
+ rootFiles
314
+ });
315
+ const reports = [];
316
+ /** Run one linter's `localBin` natively, or flag it. */
317
+ const runLinter = (name, bin, args, hint) => {
318
+ if (!bin) {
319
+ print(`· ${name} (CI only)`);
320
+ return;
321
+ }
322
+ const found = lookPath(cwd, bin);
323
+ if (!found) {
324
+ print(`! ${name} — ${bin} not on PATH${hint ? `. ${hint}` : ""} (enforced in CI)`);
325
+ return;
326
+ }
327
+ reports.push(runOne(found, [...args, ...passthrough]));
328
+ };
329
+ if (resolved.some((r) => r.name === "eslint")) {
330
+ const script = packageJsonScript(cwd, "lint", readFile, fileExists);
331
+ if (turboDefinesTask(cwd, "lint", readFile, fileExists)) reports.push(runOne(resolveBin(cwd, "turbo", fileExists), [
332
+ "run",
333
+ "lint",
334
+ ...pass
335
+ ]));
336
+ else if (script && !/^holocron run\b/.test(script.trim())) reports.push(runOne(packageManager(cwd, readFile, fileExists), [
337
+ "run",
338
+ "lint",
339
+ ...pass
340
+ ]));
341
+ else runLinter("eslint", "eslint", ["."]);
342
+ }
343
+ for (const { name, def } of resolved) {
344
+ if (name === "eslint") continue;
345
+ runLinter(name, def.localBin, def.localArgs ?? [], def.installHint);
346
+ }
347
+ if (reports.length === 0) {
348
+ const msg = "no lint tooling available locally — every resolved linter is CI-only here";
349
+ print(input.required ? `✗ ${msg} (required)` : `· ${msg}`);
350
+ logger[input.required ? "warn" : "debug"]({
351
+ task: "lint",
352
+ status: input.required ? "fail" : "skip"
353
+ }, "run: lint");
354
+ return {
355
+ status: input.required ? "fail" : "skip",
356
+ message: msg
357
+ };
358
+ }
359
+ const command = reports.map((r) => r.command).filter((c) => Boolean(c)).join(" && ");
360
+ if (dryRun) return {
361
+ status: "dry-run",
362
+ command
363
+ };
364
+ return reports.some((r) => r.status === "fail") ? {
365
+ status: "fail",
366
+ command,
367
+ message: "one or more linters failed"
368
+ } : {
369
+ status: "ok",
370
+ command
371
+ };
372
+ }
153
373
  const mkRunner = (tool, args = []) => ({
154
374
  tool,
155
375
  args
@@ -203,136 +423,6 @@ function packageJsonScript(cwd, task, readFile, fileExists) {
203
423
  }
204
424
  }
205
425
  //#endregion
206
- //#region src/linters.ts
207
- /**
208
- * Known linters, in execution order. `always` entries are the current
209
- * hard-coded super-linter baseline; `prettier` is always-on because the org
210
- * applies it universally (super-linter only lints files that exist).
211
- */
212
- const LINTERS = {
213
- eslint: {
214
- validate: ["VALIDATE_JAVASCRIPT_ES", "VALIDATE_TYPESCRIPT_ES"],
215
- localBin: "eslint",
216
- localArgs: ["."],
217
- detect: [
218
- "eslint.config.ts",
219
- "eslint.config.js",
220
- "eslint.config.mjs",
221
- "eslint.config.cjs",
222
- ".eslintrc",
223
- ".eslintrc.json",
224
- ".eslintrc.yml",
225
- ".eslintrc.yaml",
226
- ".eslintrc.cjs"
227
- ],
228
- configInput: "eslint-config"
229
- },
230
- prettier: {
231
- validate: [
232
- "VALIDATE_JAVASCRIPT_PRETTIER",
233
- "VALIDATE_JSX_PRETTIER",
234
- "VALIDATE_TYPESCRIPT_PRETTIER",
235
- "VALIDATE_TSX",
236
- "VALIDATE_MARKDOWN_PRETTIER"
237
- ],
238
- fix: [
239
- "FIX_JAVASCRIPT_PRETTIER",
240
- "FIX_JSX_PRETTIER",
241
- "FIX_TYPESCRIPT_PRETTIER",
242
- "FIX_TSX",
243
- "FIX_MARKDOWN_PRETTIER"
244
- ],
245
- always: true,
246
- localBin: "prettier",
247
- localArgs: ["--check", "."],
248
- configInput: "prettier-config"
249
- },
250
- yamllint: {
251
- validate: ["VALIDATE_YAML"],
252
- always: true,
253
- localBin: "yamllint",
254
- localArgs: ["."],
255
- installHint: "brew install yamllint",
256
- configInput: "yaml-config"
257
- },
258
- actionlint: {
259
- validate: ["VALIDATE_GITHUB_ACTIONS"],
260
- always: true,
261
- localBin: "actionlint",
262
- localArgs: [],
263
- installHint: "brew install actionlint"
264
- },
265
- gitleaks: {
266
- validate: ["VALIDATE_GITLEAKS"],
267
- always: true,
268
- localBin: "gitleaks",
269
- localArgs: ["dir", "--no-banner"],
270
- installHint: "brew install gitleaks"
271
- },
272
- editorconfig: {
273
- validate: ["VALIDATE_EDITORCONFIG"],
274
- always: true,
275
- localBin: "editorconfig-checker",
276
- localArgs: [],
277
- installHint: "brew install editorconfig-checker"
278
- },
279
- commitlint: {
280
- validate: ["VALIDATE_GIT_COMMITLINT"],
281
- always: true,
282
- localBin: "commitlint",
283
- localArgs: ["--last"]
284
- },
285
- "git-merge-conflict-markers": {
286
- validate: ["VALIDATE_GIT_MERGE_CONFLICT_MARKERS"],
287
- always: true
288
- },
289
- markdownlint: {
290
- validate: ["VALIDATE_MARKDOWN"],
291
- localBin: "markdownlint-cli2",
292
- localArgs: ["**/*.md"],
293
- detect: [
294
- ".markdownlint.json",
295
- ".markdownlint.jsonc",
296
- ".markdownlint.yaml",
297
- ".markdownlint.yml",
298
- ".markdownlint-cli2.jsonc",
299
- ".markdownlint-cli2.yaml",
300
- ".markdownlint-cli2.mjs"
301
- ]
302
- }
303
- };
304
- /** Every linter name the registry knows. */
305
- const LINTER_NAMES = new Set(Object.keys(LINTERS));
306
- /**
307
- * Resolve the linter set for a repo. An `explicit` list (from
308
- * `config.tasks`) wins verbatim; otherwise every `always` linter plus every
309
- * linter whose `detect` filenames are present at the repo root. Result is
310
- * ordered by {@link LINTERS} declaration order.
311
- *
312
- * @throws when an `explicit` name is not in the registry — a typo is a
313
- * config bug, not a linter to silently skip.
314
- */
315
- function resolveLinters(opts) {
316
- const order = Object.keys(LINTERS);
317
- if (opts.explicit && opts.explicit.length > 0) {
318
- const unknown = opts.explicit.filter((n) => !LINTER_NAMES.has(n));
319
- if (unknown.length > 0) throw new Error(`unknown linter${unknown.length > 1 ? "s" : ""} ${unknown.map((n) => `"${n}"`).join(", ")} — known: ${order.join(", ")}`);
320
- const wanted = new Set(opts.explicit);
321
- return order.filter((n) => wanted.has(n)).map((name) => ({
322
- name,
323
- def: LINTERS[name]
324
- }));
325
- }
326
- const present = new Set(opts.rootFiles);
327
- return order.filter((name) => {
328
- const def = LINTERS[name];
329
- return def.always === true || def.detect.some((f) => present.has(f));
330
- }).map((name) => ({
331
- name,
332
- def: LINTERS[name]
333
- }));
334
- }
335
- //#endregion
336
426
  //#region src/super-linter.ts
337
427
  /**
338
428
  * `superLinterConfig()` — turn the resolved linter set into the exact
@@ -676,6 +766,13 @@ const realExec = (cmd, args, opts) => {
676
766
  stdio: "inherit"
677
767
  }).status ?? -1 };
678
768
  };
769
+ /** `node_modules/.bin/<bin>`, else the first `PATH` entry that has it, else `null`. */
770
+ const realLookPath = (cwd, bin) => {
771
+ const local = join(cwd, "node_modules", ".bin", bin);
772
+ if (existsSync(local)) return local;
773
+ for (const dir of (process.env["PATH"] ?? "").split(":")) if (dir && existsSync(join(dir, bin))) return join(dir, bin);
774
+ return null;
775
+ };
679
776
  function createAstromech(options) {
680
777
  const deps = {
681
778
  print: options.print ?? ((line) => console.log(line)),
@@ -683,7 +780,8 @@ function createAstromech(options) {
683
780
  exec: options.exec ?? realExec,
684
781
  readFile: options.readFile ?? ((path) => readFileSync(path, "utf8")),
685
782
  fileExists: options.fileExists ?? ((path) => existsSync(path)),
686
- listDir: options.listDir ?? ((path) => readdirSync(path))
783
+ listDir: options.listDir ?? ((path) => readdirSync(path)),
784
+ lookPath: options.lookPath ?? realLookPath
687
785
  };
688
786
  const items = () => (options.config?.tasks ?? []).map((i) => normalizeTaskEntry(i));
689
787
  const rootFiles = () => {
@@ -701,7 +799,8 @@ function createAstromech(options) {
701
799
  cwd: options.cwd,
702
800
  passthrough: opts.passthrough ?? [],
703
801
  dryRun: opts.dryRun ?? false,
704
- required: opts.required ?? false
802
+ required: opts.required ?? false,
803
+ ...task === "lint" ? { linters: lintEntry()?.linters } : {}
705
804
  }),
706
805
  thinCallers: () => {
707
806
  const orgCtx = options.orgContext ?? {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/astromech",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "description": "The Holocron task runner — one task manifest drives `holocron run`, `holocron ci`, the CI workflows, package.json scripts, linters, and required checks.",
5
5
  "keywords": [
6
6
  "ci",
@@ -37,7 +37,7 @@
37
37
  "dist"
38
38
  ],
39
39
  "dependencies": {
40
- "@theholocron/datapad": "4.5.0"
40
+ "@theholocron/datapad": "4.6.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@theholocron/eslint-config": "^8.0.0",
@@ -53,7 +53,7 @@
53
53
  "tsdown": "^0.22.14",
54
54
  "typescript": "^5.9.3",
55
55
  "vitest": "^4.1.11",
56
- "@theholocron/rollup-plugin-transform-template": "4.5.0"
56
+ "@theholocron/rollup-plugin-transform-template": "4.6.0"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">=22"