@theholocron/astromech 4.4.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";
@@ -56,6 +67,63 @@ interface RunTaskReport {
56
67
  }
57
68
  declare function runTask(input: RunTaskInput): RunTaskReport;
58
69
  //#endregion
70
+ //#region src/super-linter.d.ts
71
+ /**
72
+ * `superLinterConfig()` — turn the resolved linter set into the exact
73
+ * super-linter `VALIDATE_*` / `FIX_*` env the CI `lint` job needs. The CLI
74
+ * serializes {@link SuperLinterConfig.env} as the `super-linter-env` input
75
+ * on each repo's generated `lint` thin caller; the reusable workflow
76
+ * expands it verbatim. This is the CI half of "lint parity" — the local
77
+ * half is the `holocron run lint` aggregate, driven by the same
78
+ * {@link resolveLinters}.
79
+ */
80
+ interface SuperLinterConfig {
81
+ /**
82
+ * Enabled `VALIDATE_*` / `FIX_*` keys → `"true"`. Only enabled keys are
83
+ * present (super-linter allow-list mode). Ready for `JSON.stringify`.
84
+ */
85
+ env: Record<string, string>;
86
+ /** Resolved linter names in execution order — for the human-readable comment. */
87
+ linters: string[];
88
+ /** Config-file inputs the resolved set honors (`eslint-config`, …). */
89
+ configInputs: Partial<Record<"eslint-config" | "prettier-config" | "yaml-config", true>>;
90
+ }
91
+ /**
92
+ * Resolve the super-linter env for a repo's `lint` task.
93
+ *
94
+ * @param opts.explicit the task's `linters` list, if any (else auto-detect)
95
+ * @param opts.rootFiles repo-root filenames (from `listDir(cwd)`)
96
+ * @param opts.includeFix emit `FIX_*` keys too (default `true`)
97
+ */
98
+ declare function superLinterConfig(opts: {
99
+ explicit?: string[];
100
+ rootFiles: string[];
101
+ includeFix?: boolean;
102
+ }): SuperLinterConfig;
103
+ /**
104
+ * The always-on baseline env — every `always` linter, no detection. This is
105
+ * what the reusable `lint.yml`'s `super-linter-env` input defaults to, so a
106
+ * repo whose thin caller has not been re-synced yet behaves exactly as before.
107
+ */
108
+ declare function baselineSuperLinterEnv(): Record<string, string>;
109
+ /**
110
+ * The `lint` thin caller's `with:` overrides + the `# linters: …` comment,
111
+ * from the resolved linter set. Shared by `createAstromech().thinCallers()`
112
+ * and the CLI's `sync` / `setup` workflow writers so the three stay in step.
113
+ *
114
+ * @param opts.explicit the `lint` task's `linters` list, if any
115
+ * @param opts.rootFiles repo-root filenames (auto-detect fallback)
116
+ * @param opts.extra per-repo `with:` overrides that win over the defaults
117
+ */
118
+ declare function lintThinCallerWith(opts: {
119
+ explicit?: string[];
120
+ rootFiles: string[];
121
+ extra?: Record<string, unknown>;
122
+ }): {
123
+ withOverrides: Record<string, unknown>;
124
+ comments: Record<string, string>;
125
+ };
126
+ //#endregion
59
127
  //#region src/thin-callers.d.ts
60
128
  /**
61
129
  * Workflow templates + thin-caller generation.
@@ -92,7 +160,13 @@ declare function generateThinCallerContent(name: string, withOverrides?: Record<
92
160
  /** Optional sink for the "could not inject `with:`" warning. */
93
161
  logger?: {
94
162
  warn(obj: Record<string, unknown>, msg: string): void;
95
- }): string;
163
+ },
164
+ /**
165
+ * `with:` key → a `# comment` line rendered immediately above that entry.
166
+ * Only honored when injecting a fresh `with:` block (no template today
167
+ * ships one), which is the path `lint` takes.
168
+ */
169
+ comments?: Record<string, string>): string;
96
170
  interface PreviewConfig {
97
171
  /** Cloudflare Pages project name shared across all repos for previews. */
98
172
  project: string;
@@ -179,6 +253,8 @@ interface AstromechOptions {
179
253
  readFile?: (path: string) => string;
180
254
  fileExists?: (path: string) => boolean;
181
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;
182
258
  }
183
259
  interface RunOptions {
184
260
  /** Args after `--`, forwarded to the tool / turbo / script. */
@@ -206,6 +282,14 @@ interface Astromech {
206
282
  * when there is no config or `syncScripts: false`.
207
283
  */
208
284
  packageScripts(): Record<string, string>;
285
+ /**
286
+ * The resolved super-linter env for this repo's `lint` task — the CI half
287
+ * of lint parity. `thinCallers()` already bakes `env` into the `lint` thin
288
+ * caller's `super-linter-env` input; this method exposes the full result
289
+ * for `holocron doctor` / diagnostics. Driven by the `lint` entry's
290
+ * `linters` list, else auto-detection from the repo's config files.
291
+ */
292
+ superLinterConfig(): SuperLinterConfig;
209
293
  }
210
294
  declare function createAstromech(options: AstromechOptions): Astromech;
211
295
  //#endregion
@@ -324,44 +408,4 @@ declare const TASKS: Record<string, TaskDef>;
324
408
  /** Every task name the registry knows. */
325
409
  declare const KNOWN_TASKS: Set<string>;
326
410
  //#endregion
327
- //#region src/super-linter.d.ts
328
- /**
329
- * `superLinterConfig()` — turn the resolved linter set into the exact
330
- * super-linter `VALIDATE_*` / `FIX_*` env the CI `lint` job needs. The CLI
331
- * serializes {@link SuperLinterConfig.env} as the `super-linter-env` input
332
- * on each repo's generated `lint` thin caller; the reusable workflow
333
- * expands it verbatim. This is the CI half of "lint parity" — the local
334
- * half is the `holocron run lint` aggregate, driven by the same
335
- * {@link resolveLinters}.
336
- */
337
- interface SuperLinterConfig {
338
- /**
339
- * Enabled `VALIDATE_*` / `FIX_*` keys → `"true"`. Only enabled keys are
340
- * present (super-linter allow-list mode). Ready for `JSON.stringify`.
341
- */
342
- env: Record<string, string>;
343
- /** Resolved linter names in execution order — for the human-readable comment. */
344
- linters: string[];
345
- /** Config-file inputs the resolved set honors (`eslint-config`, …). */
346
- configInputs: Partial<Record<"eslint-config" | "prettier-config" | "yaml-config", true>>;
347
- }
348
- /**
349
- * Resolve the super-linter env for a repo's `lint` task.
350
- *
351
- * @param opts.explicit the task's `linters` list, if any (else auto-detect)
352
- * @param opts.rootFiles repo-root filenames (from `listDir(cwd)`)
353
- * @param opts.includeFix emit `FIX_*` keys too (default `true`)
354
- */
355
- declare function superLinterConfig(opts: {
356
- explicit?: string[];
357
- rootFiles: string[];
358
- includeFix?: boolean;
359
- }): SuperLinterConfig;
360
- /**
361
- * The always-on baseline env — every `always` linter, no detection. This is
362
- * what the reusable `lint.yml`'s `super-linter-env` input defaults to, so a
363
- * repo whose thin caller has not been re-synced yet behaves exactly as before.
364
- */
365
- declare function baselineSuperLinterEnv(): Record<string, string>;
366
- //#endregion
367
- export { type Astromech, type AstromechOptions, type ExecFn, KNOWN_TASKS, KNOWN_WORKFLOWS, LINTERS, LINTER_NAMES, type LinterDef, type LocalRunner, type OrgContext, type PreviewConfig, type RunLogger, type RunOptions, type RunTaskInput, type RunTaskReport, type SuperLinterConfig, TASKS, type TaskDef, WORKFLOW_CHECK_CONTEXTS, WORKFLOW_TEMPLATES, baselineSuperLinterEnv, createAstromech, deriveDeployPaths, extractPreviewConfig, generateCombinedDeployContent, generateThinCallerContent, normalizeWorkflowWith, resolveLinters, runTask, superLinterConfig };
411
+ export { type Astromech, type AstromechOptions, type ExecFn, KNOWN_TASKS, KNOWN_WORKFLOWS, LINTERS, LINTER_NAMES, type LinterDef, type LocalRunner, type OrgContext, type PreviewConfig, type RunLogger, type RunOptions, type RunTaskInput, type RunTaskReport, type SuperLinterConfig, TASKS, type TaskDef, WORKFLOW_CHECK_CONTEXTS, WORKFLOW_TEMPLATES, baselineSuperLinterEnv, createAstromech, deriveDeployPaths, extractPreviewConfig, generateCombinedDeployContent, generateThinCallerContent, lintThinCallerWith, normalizeWorkflowWith, resolveLinters, runTask, superLinterConfig };
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,6 +423,74 @@ function packageJsonScript(cwd, task, readFile, fileExists) {
203
423
  }
204
424
  }
205
425
  //#endregion
426
+ //#region src/super-linter.ts
427
+ /**
428
+ * `superLinterConfig()` — turn the resolved linter set into the exact
429
+ * super-linter `VALIDATE_*` / `FIX_*` env the CI `lint` job needs. The CLI
430
+ * serializes {@link SuperLinterConfig.env} as the `super-linter-env` input
431
+ * on each repo's generated `lint` thin caller; the reusable workflow
432
+ * expands it verbatim. This is the CI half of "lint parity" — the local
433
+ * half is the `holocron run lint` aggregate, driven by the same
434
+ * {@link resolveLinters}.
435
+ */
436
+ /**
437
+ * Resolve the super-linter env for a repo's `lint` task.
438
+ *
439
+ * @param opts.explicit the task's `linters` list, if any (else auto-detect)
440
+ * @param opts.rootFiles repo-root filenames (from `listDir(cwd)`)
441
+ * @param opts.includeFix emit `FIX_*` keys too (default `true`)
442
+ */
443
+ function superLinterConfig(opts) {
444
+ const includeFix = opts.includeFix ?? true;
445
+ const resolved = resolveLinters({
446
+ explicit: opts.explicit,
447
+ rootFiles: opts.rootFiles
448
+ });
449
+ const env = {};
450
+ const configInputs = {};
451
+ for (const { def } of resolved) {
452
+ for (const key of def.validate) env[key] = "true";
453
+ if (includeFix) for (const key of def.fix ?? []) env[key] = "true";
454
+ if (def.configInput) configInputs[def.configInput] = true;
455
+ }
456
+ return {
457
+ env,
458
+ linters: resolved.map((r) => r.name),
459
+ configInputs
460
+ };
461
+ }
462
+ /**
463
+ * The always-on baseline env — every `always` linter, no detection. This is
464
+ * what the reusable `lint.yml`'s `super-linter-env` input defaults to, so a
465
+ * repo whose thin caller has not been re-synced yet behaves exactly as before.
466
+ */
467
+ function baselineSuperLinterEnv() {
468
+ return superLinterConfig({ rootFiles: [] }).env;
469
+ }
470
+ /**
471
+ * The `lint` thin caller's `with:` overrides + the `# linters: …` comment,
472
+ * from the resolved linter set. Shared by `createAstromech().thinCallers()`
473
+ * and the CLI's `sync` / `setup` workflow writers so the three stay in step.
474
+ *
475
+ * @param opts.explicit the `lint` task's `linters` list, if any
476
+ * @param opts.rootFiles repo-root filenames (auto-detect fallback)
477
+ * @param opts.extra per-repo `with:` overrides that win over the defaults
478
+ */
479
+ function lintThinCallerWith(opts) {
480
+ const sl = superLinterConfig({
481
+ explicit: opts.explicit,
482
+ rootFiles: opts.rootFiles
483
+ });
484
+ return {
485
+ withOverrides: {
486
+ "enable-auto-commit": true,
487
+ "super-linter-env": JSON.stringify(sl.env),
488
+ ...opts.extra ?? {}
489
+ },
490
+ comments: { "super-linter-env": `linters: ${sl.linters.join(", ")}` }
491
+ };
492
+ }
493
+ //#endregion
206
494
  //#region src/thin-callers.ts
207
495
  /**
208
496
  * Workflow templates + thin-caller generation.
@@ -255,7 +543,7 @@ const WORKFLOW_CHECK_CONTEXTS = {
255
543
  * If neither pattern matches the template, a warning is emitted and the
256
544
  * base template is returned unchanged.
257
545
  */
258
- function generateThinCallerContent(name, withOverrides, additionalPaths, logger) {
546
+ function generateThinCallerContent(name, withOverrides, additionalPaths, logger, comments) {
259
547
  const base = WORKFLOW_TEMPLATES[name];
260
548
  if (!base) return "";
261
549
  const yamlScalar = (v) => {
@@ -264,7 +552,10 @@ function generateThinCallerContent(name, withOverrides, additionalPaths, logger)
264
552
  const s = String(v);
265
553
  return s.startsWith("[") || s.startsWith("{") ? `'${s}'` : s;
266
554
  };
267
- const fmt = (k, v) => ` ${k}: ${yamlScalar(v)}`;
555
+ const fmt = (k, v) => {
556
+ const line = ` ${k}: ${yamlScalar(v)}`;
557
+ return comments?.[k] ? ` # ${comments[k]}\n${line}` : line;
558
+ };
268
559
  let result = base;
269
560
  if (additionalPaths && additionalPaths.length > 0) {
270
561
  const pathsBlockRe = /( {4}paths:\n)((?:[ ]{6}- [^\n]+\n)+)/;
@@ -282,7 +573,7 @@ function generateThinCallerContent(name, withOverrides, additionalPaths, logger)
282
573
  const withBlockRe = /( {4}with:\n)((?:[ ]{6}[^\n]+\n)*)/;
283
574
  const existingMatch = result.match(withBlockRe);
284
575
  if (existingMatch) {
285
- const existingEntries = new Map(existingMatch[2].split("\n").filter(Boolean).map((line) => {
576
+ const existingEntries = new Map(existingMatch[2].split("\n").filter(Boolean).filter((line) => !line.trim().startsWith("#")).map((line) => {
286
577
  const m = line.match(/^ {6}([^:]+):\s*(.*)/);
287
578
  return m ? [m[1].trim(), m[2].trim()] : null;
288
579
  }).filter((e) => e !== null));
@@ -475,6 +766,13 @@ const realExec = (cmd, args, opts) => {
475
766
  stdio: "inherit"
476
767
  }).status ?? -1 };
477
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
+ };
478
776
  function createAstromech(options) {
479
777
  const deps = {
480
778
  print: options.print ?? ((line) => console.log(line)),
@@ -482,9 +780,18 @@ function createAstromech(options) {
482
780
  exec: options.exec ?? realExec,
483
781
  readFile: options.readFile ?? ((path) => readFileSync(path, "utf8")),
484
782
  fileExists: options.fileExists ?? ((path) => existsSync(path)),
485
- listDir: options.listDir ?? ((path) => readdirSync(path))
783
+ listDir: options.listDir ?? ((path) => readdirSync(path)),
784
+ lookPath: options.lookPath ?? realLookPath
486
785
  };
487
786
  const items = () => (options.config?.tasks ?? []).map((i) => normalizeTaskEntry(i));
787
+ const rootFiles = () => {
788
+ try {
789
+ return deps.listDir(options.cwd);
790
+ } catch {
791
+ return [];
792
+ }
793
+ };
794
+ const lintEntry = () => items().filter((e) => e.name === "lint").at(-1);
488
795
  return {
489
796
  run: (task, opts = {}) => runTask({
490
797
  ...deps,
@@ -492,7 +799,8 @@ function createAstromech(options) {
492
799
  cwd: options.cwd,
493
800
  passthrough: opts.passthrough ?? [],
494
801
  dryRun: opts.dryRun ?? false,
495
- required: opts.required ?? false
802
+ required: opts.required ?? false,
803
+ ...task === "lint" ? { linters: lintEntry()?.linters } : {}
496
804
  }),
497
805
  thinCallers: () => {
498
806
  const orgCtx = options.orgContext ?? {};
@@ -501,10 +809,17 @@ function createAstromech(options) {
501
809
  if (entry.ci === false || !KNOWN_WORKFLOWS.has(entry.name)) continue;
502
810
  const rawWith = entry.with;
503
811
  const normalized = rawWith ? normalizeWorkflowWith(rawWith) : void 0;
504
- const withOverrides = entry.name === "lint" ? {
505
- "enable-auto-commit": true,
506
- ...normalized ?? {}
507
- } : normalized;
812
+ let withOverrides = normalized;
813
+ let comments;
814
+ if (entry.name === "lint") {
815
+ const lint = lintThinCallerWith({
816
+ explicit: entry.linters,
817
+ rootFiles: rootFiles(),
818
+ extra: normalized
819
+ });
820
+ withOverrides = lint.withOverrides;
821
+ comments = lint.comments;
822
+ }
508
823
  const additionalPaths = entry.paths ?? (entry.name === "deploy" && rawWith ? deriveDeployPaths(rawWith) : void 0);
509
824
  if (entry.name === "deploy" && rawWith) {
510
825
  const preview = extractPreviewConfig(rawWith, orgCtx);
@@ -515,7 +830,7 @@ function createAstromech(options) {
515
830
  continue;
516
831
  }
517
832
  }
518
- out.set(`${entry.name}.yml`, generateThinCallerContent(entry.name, withOverrides, additionalPaths, deps.logger));
833
+ out.set(`${entry.name}.yml`, generateThinCallerContent(entry.name, withOverrides, additionalPaths, deps.logger, comments));
519
834
  }
520
835
  return out;
521
836
  },
@@ -528,183 +843,12 @@ function createAstromech(options) {
528
843
  out[entry.name] = `holocron run ${entry.name}`;
529
844
  }
530
845
  return out;
531
- }
532
- };
533
- }
534
- //#endregion
535
- //#region src/linters.ts
536
- /**
537
- * Known linters, in execution order. `always` entries are the current
538
- * hard-coded super-linter baseline; `prettier` is always-on because the org
539
- * applies it universally (super-linter only lints files that exist).
540
- */
541
- const LINTERS = {
542
- eslint: {
543
- validate: ["VALIDATE_JAVASCRIPT_ES", "VALIDATE_TYPESCRIPT_ES"],
544
- localBin: "eslint",
545
- localArgs: ["."],
546
- detect: [
547
- "eslint.config.ts",
548
- "eslint.config.js",
549
- "eslint.config.mjs",
550
- "eslint.config.cjs",
551
- ".eslintrc",
552
- ".eslintrc.json",
553
- ".eslintrc.yml",
554
- ".eslintrc.yaml",
555
- ".eslintrc.cjs"
556
- ],
557
- configInput: "eslint-config"
558
- },
559
- prettier: {
560
- validate: [
561
- "VALIDATE_JAVASCRIPT_PRETTIER",
562
- "VALIDATE_JSX_PRETTIER",
563
- "VALIDATE_TYPESCRIPT_PRETTIER",
564
- "VALIDATE_TSX",
565
- "VALIDATE_MARKDOWN_PRETTIER"
566
- ],
567
- fix: [
568
- "FIX_JAVASCRIPT_PRETTIER",
569
- "FIX_JSX_PRETTIER",
570
- "FIX_TYPESCRIPT_PRETTIER",
571
- "FIX_TSX",
572
- "FIX_MARKDOWN_PRETTIER"
573
- ],
574
- always: true,
575
- localBin: "prettier",
576
- localArgs: ["--check", "."],
577
- configInput: "prettier-config"
578
- },
579
- yamllint: {
580
- validate: ["VALIDATE_YAML"],
581
- always: true,
582
- localBin: "yamllint",
583
- localArgs: ["."],
584
- installHint: "brew install yamllint",
585
- configInput: "yaml-config"
586
- },
587
- actionlint: {
588
- validate: ["VALIDATE_GITHUB_ACTIONS"],
589
- always: true,
590
- localBin: "actionlint",
591
- localArgs: [],
592
- installHint: "brew install actionlint"
593
- },
594
- gitleaks: {
595
- validate: ["VALIDATE_GITLEAKS"],
596
- always: true,
597
- localBin: "gitleaks",
598
- localArgs: ["dir", "--no-banner"],
599
- installHint: "brew install gitleaks"
600
- },
601
- editorconfig: {
602
- validate: ["VALIDATE_EDITORCONFIG"],
603
- always: true,
604
- localBin: "editorconfig-checker",
605
- localArgs: [],
606
- installHint: "brew install editorconfig-checker"
607
- },
608
- commitlint: {
609
- validate: ["VALIDATE_GIT_COMMITLINT"],
610
- always: true,
611
- localBin: "commitlint",
612
- localArgs: ["--last"]
613
- },
614
- "git-merge-conflict-markers": {
615
- validate: ["VALIDATE_GIT_MERGE_CONFLICT_MARKERS"],
616
- always: true
617
- },
618
- markdownlint: {
619
- validate: ["VALIDATE_MARKDOWN"],
620
- localBin: "markdownlint-cli2",
621
- localArgs: ["**/*.md"],
622
- detect: [
623
- ".markdownlint.json",
624
- ".markdownlint.jsonc",
625
- ".markdownlint.yaml",
626
- ".markdownlint.yml",
627
- ".markdownlint-cli2.jsonc",
628
- ".markdownlint-cli2.yaml",
629
- ".markdownlint-cli2.mjs"
630
- ]
631
- }
632
- };
633
- /** Every linter name the registry knows. */
634
- const LINTER_NAMES = new Set(Object.keys(LINTERS));
635
- /**
636
- * Resolve the linter set for a repo. An `explicit` list (from
637
- * `config.tasks`) wins verbatim; otherwise every `always` linter plus every
638
- * linter whose `detect` filenames are present at the repo root. Result is
639
- * ordered by {@link LINTERS} declaration order.
640
- *
641
- * @throws when an `explicit` name is not in the registry — a typo is a
642
- * config bug, not a linter to silently skip.
643
- */
644
- function resolveLinters(opts) {
645
- const order = Object.keys(LINTERS);
646
- if (opts.explicit && opts.explicit.length > 0) {
647
- const unknown = opts.explicit.filter((n) => !LINTER_NAMES.has(n));
648
- if (unknown.length > 0) throw new Error(`unknown linter${unknown.length > 1 ? "s" : ""} ${unknown.map((n) => `"${n}"`).join(", ")} — known: ${order.join(", ")}`);
649
- const wanted = new Set(opts.explicit);
650
- return order.filter((n) => wanted.has(n)).map((name) => ({
651
- name,
652
- def: LINTERS[name]
653
- }));
654
- }
655
- const present = new Set(opts.rootFiles);
656
- return order.filter((name) => {
657
- const def = LINTERS[name];
658
- return def.always === true || def.detect.some((f) => present.has(f));
659
- }).map((name) => ({
660
- name,
661
- def: LINTERS[name]
662
- }));
663
- }
664
- //#endregion
665
- //#region src/super-linter.ts
666
- /**
667
- * `superLinterConfig()` — turn the resolved linter set into the exact
668
- * super-linter `VALIDATE_*` / `FIX_*` env the CI `lint` job needs. The CLI
669
- * serializes {@link SuperLinterConfig.env} as the `super-linter-env` input
670
- * on each repo's generated `lint` thin caller; the reusable workflow
671
- * expands it verbatim. This is the CI half of "lint parity" — the local
672
- * half is the `holocron run lint` aggregate, driven by the same
673
- * {@link resolveLinters}.
674
- */
675
- /**
676
- * Resolve the super-linter env for a repo's `lint` task.
677
- *
678
- * @param opts.explicit the task's `linters` list, if any (else auto-detect)
679
- * @param opts.rootFiles repo-root filenames (from `listDir(cwd)`)
680
- * @param opts.includeFix emit `FIX_*` keys too (default `true`)
681
- */
682
- function superLinterConfig(opts) {
683
- const includeFix = opts.includeFix ?? true;
684
- const resolved = resolveLinters({
685
- explicit: opts.explicit,
686
- rootFiles: opts.rootFiles
687
- });
688
- const env = {};
689
- const configInputs = {};
690
- for (const { def } of resolved) {
691
- for (const key of def.validate) env[key] = "true";
692
- if (includeFix) for (const key of def.fix ?? []) env[key] = "true";
693
- if (def.configInput) configInputs[def.configInput] = true;
694
- }
695
- return {
696
- env,
697
- linters: resolved.map((r) => r.name),
698
- configInputs
846
+ },
847
+ superLinterConfig: () => superLinterConfig({
848
+ explicit: lintEntry()?.linters,
849
+ rootFiles: rootFiles()
850
+ })
699
851
  };
700
852
  }
701
- /**
702
- * The always-on baseline env — every `always` linter, no detection. This is
703
- * what the reusable `lint.yml`'s `super-linter-env` input defaults to, so a
704
- * repo whose thin caller has not been re-synced yet behaves exactly as before.
705
- */
706
- function baselineSuperLinterEnv() {
707
- return superLinterConfig({ rootFiles: [] }).env;
708
- }
709
853
  //#endregion
710
- export { KNOWN_TASKS, KNOWN_WORKFLOWS, LINTERS, LINTER_NAMES, TASKS, WORKFLOW_CHECK_CONTEXTS, WORKFLOW_TEMPLATES, baselineSuperLinterEnv, createAstromech, deriveDeployPaths, extractPreviewConfig, generateCombinedDeployContent, generateThinCallerContent, normalizeWorkflowWith, resolveLinters, runTask, superLinterConfig };
854
+ export { KNOWN_TASKS, KNOWN_WORKFLOWS, LINTERS, LINTER_NAMES, TASKS, WORKFLOW_CHECK_CONTEXTS, WORKFLOW_TEMPLATES, baselineSuperLinterEnv, createAstromech, deriveDeployPaths, extractPreviewConfig, generateCombinedDeployContent, generateThinCallerContent, lintThinCallerWith, normalizeWorkflowWith, resolveLinters, runTask, superLinterConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/astromech",
3
- "version": "4.4.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.4.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.4.0"
56
+ "@theholocron/rollup-plugin-transform-template": "4.6.0"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">=22"