baldart 4.40.0 → 4.41.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.
@@ -6,6 +6,8 @@ const toolAdapters = require('../utils/tool-adapters');
6
6
  const LspInstaller = require('../utils/lsp-installer');
7
7
  const lspAdapters = require('../utils/lsp-adapters');
8
8
  const GraphifyInstaller = require('../utils/graphify-installer');
9
+ const ToolchainInstaller = require('../utils/toolchain-installer');
10
+ const toolchainAdapters = require('../utils/toolchain-adapters');
9
11
 
10
12
  const CONFIG_FILE = 'baldart.config.yml';
11
13
  // The subtree pull copies the entire BALDART repo (which itself has a
@@ -459,6 +461,12 @@ function detect(cwd = process.cwd()) {
459
461
  // Drives the default for the configure prompt; the install/build side
460
462
  // effects only run in the interactive branch (see below).
461
463
  has_code_graph: new GraphifyInstaller(cwd).detect(),
464
+ // Curated toolchain recommended (default Y) when this is a JS/TS project
465
+ // AND no incumbent linter/formatter is present — we never surprise a repo
466
+ // that already has ESLint/Prettier (that path PROPOSES migration instead).
467
+ // Drives the prompt default; install side effects only run interactively.
468
+ has_toolchain: toolchainAdapters.detectAll(cwd).length > 0
469
+ && toolchainAdapters.detectIncumbents(cwd).length === 0,
462
470
  },
463
471
  tools: {
464
472
  enabled: toolAdapters.defaultEnabled(cwd)
@@ -627,6 +635,7 @@ async function interactivePrompts(merged, detected) {
627
635
  ['has_wiki_overlay', 'Project has LLM-wiki overlay (docs/wiki/)?'],
628
636
  ['has_lsp_layer', 'Enable LSP symbol-search layer? (recommended for large codebases)'],
629
637
  ['has_code_graph', 'Enable Graphify code-knowledge-graph layer? (structural queries + wiki feed; recommended for large codebases)'],
638
+ ['has_toolchain', 'Enable curated dev toolchain? (Biome format+lint+import; agents run it in the quality gates — recommended for JS/TS)'],
630
639
  ['has_e2e_review', 'Enable BLOCKING end-to-end review (Phase 2.6 of /new invokes /e2e-review — functional + visual fidelity gate)?'],
631
640
  ]) {
632
641
  const [key, question] = flag;
@@ -910,6 +919,78 @@ async function interactivePrompts(merged, detected) {
910
919
  }
911
920
  }
912
921
 
922
+ // ---- Curated dev toolchain (since v4.40.0) ----------------------------
923
+ // Opinionated-but-askable: on a JS/TS project with no incumbent, configure
924
+ // PRESELECTS Biome and installs it (the user opts out). If ESLint/Prettier
925
+ // are present, it NEVER auto-installs — it PROPOSES a (manual) migration.
926
+ // Install side effects run only here (interactive); CI / `--yes` writes the
927
+ // flag and `baldart doctor` backfills. Non-destructive throughout: configs
928
+ // are written only when absent; incumbents are never touched.
929
+ merged.toolchain = merged.toolchain || {
930
+ installed_tools: [],
931
+ commands: { lint: '', format: '', typecheck: '', test: '', test_related: '', build: '', audit: '' },
932
+ auto_verify: true,
933
+ };
934
+ if (merged.features.has_toolchain === true) {
935
+ UI.section('Curated dev toolchain (Biome — format + lint + import organizer)');
936
+ const ti = new ToolchainInstaller(process.cwd());
937
+ if (!ti.hasPackageJson()) {
938
+ UI.info('No package.json found — the JS/TS toolchain is not applicable here. Skipping (the flag stays on; re-run configure after adding package.json, or run /toolchain-bootstrap).');
939
+ } else {
940
+ const recommended = ti.recommend(); // clean installs (no incumbent conflict)
941
+ const migs = ti.migrations(); // curated tools blocked by an incumbent
942
+ const toInstall = [];
943
+
944
+ // Clean path — preselect (default Y).
945
+ if (recommended.length) {
946
+ const want = await UI.confirm(`Install the curated toolchain (${recommended.join(', ')}) as devDependencies now?`, true);
947
+ if (want) toInstall.push(...recommended);
948
+ else UI.info('Skipping install. Run `/toolchain-bootstrap` later — `baldart doctor` will flag the missing tools.');
949
+ }
950
+
951
+ // Migration path — never automatic; propose per blocked tool (default N).
952
+ for (const m of migs) {
953
+ UI.warning(`Detected ${m.replaces.join(' + ')} — ${m.tool} would replace ${m.replaces.length > 1 ? 'them' : 'it'} with a single, faster tool.`);
954
+ const hint = m.tool === 'biome'
955
+ ? ' (preview the change first: `npx biome migrate eslint --write` / `npx biome migrate prettier --write`)'
956
+ : (m.replaces.includes('husky') ? ' (your .husky/ hooks are NOT touched — hand the hooks over to lefthook yourself)' : '');
957
+ UI.info(`Your existing ${m.replaces.join('/')} config is NOT touched.${hint}`);
958
+ const migrate = await UI.confirm(`Migrate to ${m.tool} now?`, false);
959
+ if (migrate) toInstall.push(m.tool);
960
+ else UI.info(`Keeping ${m.replaces.join('/')}. Toolchain commands fall back to the project default for this gate.`);
961
+ }
962
+
963
+ if (toInstall.length) {
964
+ const res = ti.install({ tools: toInstall });
965
+ for (const t of res.installed) UI.success(`Installed ${t}.`);
966
+ for (const f of res.failed) UI.warning(`Install failed for ${f.tool}: ${f.error}`);
967
+ for (const s of res.skipped) UI.info(`Skipped ${s.tool}: ${s.reason}`);
968
+ }
969
+
970
+ // Resolve the active tool set (every in-scope tool usable now) → configs + commands.
971
+ const active = ti.activeTools();
972
+ if (active.length) {
973
+ const cfgRes = ti.initConfigs({ tools: active });
974
+ for (const c of cfgRes) {
975
+ if (c.status === 'written') UI.success(`Wrote default ${c.tool} config.`);
976
+ else if (c.status === 'skipped' && c.reason === 'exists') UI.info(`Kept existing ${c.tool} config (not overwritten).`);
977
+ }
978
+ merged.toolchain.installed_tools = active;
979
+ const cmds = ti.commandsFor(active);
980
+ for (const k of Object.keys(cmds)) {
981
+ if (cmds[k]) merged.toolchain.commands[k] = cmds[k];
982
+ }
983
+ const cert = ti.certify({ tools: active });
984
+ if (cert.ok) UI.success(`Toolchain CERTIFIED: ${active.join(', ')} resolve via npx; gate commands written to toolchain.commands.*`);
985
+ else UI.warning(`Toolchain NOT fully certified — ${cert.perTool.filter((t) => !t.devDep).map((t) => t.tool).join(', ')} did not resolve. Run \`baldart doctor\` or \`/toolchain-bootstrap\`.`);
986
+ }
987
+ }
988
+ } else {
989
+ // Feature disabled — keep installed_tools honest (we never uninstall, but we
990
+ // do not advertise tools the consumer opted out of).
991
+ merged.toolchain.installed_tools = [];
992
+ }
993
+
913
994
  UI.section('Stack (autodetected from package.json — confirm or override)');
914
995
  const charting = merged.stack.charting;
915
996
  const chartingCanonical = await promptForKey(
@@ -33,6 +33,7 @@ const Hooks = require('../utils/hooks');
33
33
  const GitHooks = require('../utils/githooks');
34
34
  const LspInstaller = require('../utils/lsp-installer');
35
35
  const GraphifyInstaller = require('../utils/graphify-installer');
36
+ const ToolchainInstaller = require('../utils/toolchain-installer');
36
37
  const ToolCurrency = require('../utils/tool-currency');
37
38
  const CodexOrphans = require('../utils/codex-orphans');
38
39
  const UpdateNotifier = require('../utils/update-notifier');
@@ -391,6 +392,39 @@ async function detectState(cwd, opts = {}) {
391
392
  }
392
393
  } catch (_) { /* never block doctor on graph probe */ }
393
394
 
395
+ // ---- Curated dev toolchain (since v4.41.0) -------------------------
396
+ // configure's install side effects run only interactively (a devDep install
397
+ // is never run silently in CI). So doctor is the backfill: it installs the
398
+ // missing tools and restores any default config that went missing — all
399
+ // gated on features.has_toolchain, all non-destructive.
400
+ state.toolchainEnabled = false;
401
+ state.toolchainMissingTools = [];
402
+ state.toolchainMissingConfigs = [];
403
+ try {
404
+ // Flag-gated (like the graph layer): enabling the feature means "I want
405
+ // this layer", so doctor backfills the install even when CI / `--yes`
406
+ // wrote the flag with an empty installed_tools (it never installs in CI).
407
+ if (config && !config.__malformed && config.features && config.features.has_toolchain === true) {
408
+ state.toolchainEnabled = true;
409
+ const ti = new ToolchainInstaller(cwd);
410
+ if (ti.hasPackageJson()) {
411
+ const declared = (config.toolchain && Array.isArray(config.toolchain.installed_tools))
412
+ ? config.toolchain.installed_tools : [];
413
+ if (declared.length) {
414
+ // Declared set: re-verify it resolves + its config is present.
415
+ const cert = ti.certify({ tools: declared });
416
+ state.toolchainMissingTools = cert.perTool.filter((t) => !t.devDep).map((t) => t.tool);
417
+ if (config.toolchain && config.toolchain.auto_verify !== false) {
418
+ state.toolchainMissingConfigs = cert.perTool.filter((t) => !t.config).map((t) => t.tool);
419
+ }
420
+ } else {
421
+ // Nothing recorded yet (CI backfill case) — offer the clean recommendation.
422
+ state.toolchainMissingTools = ti.recommend();
423
+ }
424
+ }
425
+ }
426
+ } catch (_) { /* never block doctor on toolchain probe */ }
427
+
394
428
  // ---- External-tool version currency (since v4.38.0) ----------------
395
429
  // BALDART pins none of the external tools it installs (graphifyy via pipx,
396
430
  // language servers via npm/system) — pipx/npm never auto-upgrade, so a
@@ -818,6 +852,39 @@ function planActions(state) {
818
852
  });
819
853
  }
820
854
 
855
+ // ---- Curated dev toolchain backfill (since v4.41.0) ------------------
856
+ if (state.toolchainEnabled && state.toolchainMissingTools.length) {
857
+ actions.push({
858
+ key: 'toolchain-install',
859
+ label: `Install curated toolchain tools: ${state.toolchainMissingTools.join(', ')}`,
860
+ why: `features.has_toolchain is true but ${state.toolchainMissingTools.join(', ')} ${state.toolchainMissingTools.length > 1 ? 'do' : 'does'} not resolve via npx (not yet installed, or a CI/--yes configure wrote the flag without installing). The quality gates fall back to the project defaults until installed as devDependencies.`,
861
+ autoOk: false, // installs devDependencies; let the user confirm
862
+ run: async () => {
863
+ const ti = new ToolchainInstaller(state.cwd);
864
+ const res = ti.install({ tools: state.toolchainMissingTools });
865
+ for (const t of res.installed) UI.success(`Installed ${t}.`);
866
+ for (const f of res.failed) UI.warning(`Install failed for ${f.tool}: ${f.error}`);
867
+ for (const s of res.skipped) UI.info(`Skipped ${s.tool}: ${s.reason}`);
868
+ },
869
+ });
870
+ }
871
+ if (state.toolchainEnabled && state.toolchainMissingConfigs.length) {
872
+ actions.push({
873
+ key: 'toolchain-init-config',
874
+ label: `Restore missing toolchain config: ${state.toolchainMissingConfigs.join(', ')}`,
875
+ why: `toolchain.auto_verify is on and the default config for ${state.toolchainMissingConfigs.join(', ')} is missing. Restore it (written only when absent — never overwrites your own).`,
876
+ autoOk: true, // only writes an absent default config
877
+ run: async () => {
878
+ const ti = new ToolchainInstaller(state.cwd);
879
+ const cfgRes = ti.initConfigs({ tools: state.toolchainMissingConfigs });
880
+ for (const c of cfgRes) {
881
+ if (c.status === 'written') UI.success(`Wrote default ${c.tool} config.`);
882
+ else UI.info(`${c.tool}: ${c.reason || c.status}`);
883
+ }
884
+ },
885
+ });
886
+ }
887
+
821
888
  // External-tool version currency (since v4.38.0). One non-blocking upgrade
822
889
  // action per managed tool confirmed behind upstream — the tool-dependency
823
890
  // analogue of the `baldart` CLI's own UpdateNotifier. `unknown`/`current`
@@ -1297,12 +1297,24 @@ async function update(options = {}, unknownArgs = []) {
1297
1297
  // diff the block's own scalar keys so a new sub-key surfaces too.
1298
1298
  const missingGraph = Object.keys(tpl.graph || {})
1299
1299
  .filter((k) => !(k in (cur2.graph || {})));
1300
+ // `toolchain:` (since v4.41.0) — same nested-block contract as
1301
+ // `graph:`. Diff its own scalar keys + the nested `commands.*` map so
1302
+ // a new gate command (added in a future release) surfaces too.
1303
+ const missingToolchain = Object.keys(tpl.toolchain || {})
1304
+ .filter((k) => k !== 'commands')
1305
+ .filter((k) => !(k in (cur2.toolchain || {})))
1306
+ .map((k) => `toolchain.${k}`);
1307
+ const missingToolchainCmds = Object.keys((tpl.toolchain && tpl.toolchain.commands) || {})
1308
+ .filter((k) => !(k in ((cur2.toolchain && cur2.toolchain.commands) || {})))
1309
+ .map((k) => `toolchain.commands.${k}`);
1300
1310
  const allMissing = [
1301
1311
  ...missingPaths,
1302
1312
  ...missingFeatures,
1303
1313
  ...missingGit.map((k) => `git.${k}`),
1304
1314
  ...missingStack.map((k) => `stack.${k}`),
1305
1315
  ...missingGraph.map((k) => `graph.${k}`),
1316
+ ...missingToolchain,
1317
+ ...missingToolchainCmds,
1306
1318
  ];
1307
1319
  if (allMissing.length) {
1308
1320
  UI.newline();
@@ -1,7 +1,10 @@
1
1
  const { execSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
2
4
  const { cmpVersions } = require('./semver-lite');
3
5
  const GraphifyInstaller = require('./graphify-installer');
4
6
  const { REGISTRY } = require('./lsp-adapters');
7
+ const { REGISTRY: TOOLCHAIN_REGISTRY } = require('./toolchain-adapters');
5
8
 
6
9
  /**
7
10
  * External-tool currency layer (since v4.38.0).
@@ -123,6 +126,52 @@ function _lspRecords(cwd, config, lspInstalled, timeoutMs) {
123
126
  return records;
124
127
  }
125
128
 
129
+ /** Installed version of an npm package from the consumer's node_modules, or null. */
130
+ function _npmInstalledVersion(cwd, pkg) {
131
+ try {
132
+ const pj = path.join(cwd, 'node_modules', ...pkg.split('/'), 'package.json');
133
+ if (!fs.existsSync(pj)) return null;
134
+ return JSON.parse(fs.readFileSync(pj, 'utf8')).version || null;
135
+ } catch { return null; }
136
+ }
137
+
138
+ /**
139
+ * Build curated-toolchain currency records (Biome, Vitest, tsc, Lefthook). These
140
+ * are project devDependencies: the installed version is read from node_modules
141
+ * (not a global binary), and the upgrade touches package.json — so the record is
142
+ * `autoUpgradable: false` (the user runs the pinned `@latest` install on their
143
+ * terms). Gated on features.has_toolchain + toolchain.installed_tools.
144
+ */
145
+ function _toolchainRecords(cwd, config, timeoutMs) {
146
+ if (!(config && config.features && config.features.has_toolchain === true)) return [];
147
+ const names = (config.toolchain && Array.isArray(config.toolchain.installed_tools))
148
+ ? config.toolchain.installed_tools : [];
149
+ const records = [];
150
+ for (const name of names) {
151
+ const Cls = TOOLCHAIN_REGISTRY[name];
152
+ if (!Cls) continue;
153
+ let adapter;
154
+ try { adapter = new Cls(cwd); } catch { continue; }
155
+ const pkg = adapter.npmPackage || adapter.binary;
156
+ const installed = _npmInstalledVersion(cwd, pkg);
157
+ if (!installed) continue; // unresolved devDep is the toolchain-install action's job
158
+ const latest = _npmLatest(pkg, timeoutMs);
159
+ const outdated = !!(latest && cmpVersions(installed, latest) < 0);
160
+ records.push({
161
+ tool: `toolchain:${name}`,
162
+ label: `${adapter.label} (${pkg})`,
163
+ installed,
164
+ latest,
165
+ status: outdated ? 'outdated' : (latest ? 'current' : 'unknown'),
166
+ // devDep upgrades mutate package.json — surfaced as a command, not auto-run.
167
+ upgradeCommand: `npm install --save-dev${name === 'biome' ? ' --save-exact' : ''} ${pkg}@latest`,
168
+ autoUpgradable: false,
169
+ source: `npm:${pkg}`,
170
+ });
171
+ }
172
+ return records;
173
+ }
174
+
126
175
  /**
127
176
  * Probe every external tool BALDART manages for version currency.
128
177
  * Returns an array of records (possibly empty). Async (network). Never throws.
@@ -137,6 +186,9 @@ async function probeAll({ cwd = process.cwd(), config = null, lspInstalled = [],
137
186
  try {
138
187
  records.push(..._lspRecords(cwd, config, lspInstalled, timeoutMs));
139
188
  } catch { /* best-effort */ }
189
+ try {
190
+ records.push(..._toolchainRecords(cwd, config, timeoutMs));
191
+ } catch { /* best-effort */ }
140
192
  return records;
141
193
  }
142
194
 
@@ -0,0 +1,92 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Biome toolchain adapter — format + lint + import organizer in one binary.
6
+ *
7
+ * Install mode: `npm-dev` (project devDependency, pinned `-E`). UNLIKE the LSP
8
+ * servers (which go global because Claude Code spawns them by name from `$PATH`),
9
+ * Biome is a project tool invoked via `npx --no-install biome …` — it must land in
10
+ * `node_modules/.bin`, not on the global `$PATH`. There is no global install case
11
+ * for the JS toolchain.
12
+ *
13
+ * Detection signal (in-scope): the project is JS/TS — a `package.json` exists, or a
14
+ * `tsconfig.json`/`jsconfig.json`, or loose `.ts`/`.js` sources. Biome installs as a
15
+ * devDep, so a `package.json` is required for the actual install (the installer
16
+ * guards on that separately).
17
+ *
18
+ * `replaces` lists the incumbent tools Biome supersedes — drives the migration
19
+ * proposal in `configure` (never an automatic replacement).
20
+ */
21
+ class BiomeAdapter {
22
+ constructor(cwd = process.cwd()) { this.cwd = cwd; }
23
+
24
+ get name() { return 'biome'; }
25
+ get label() { return 'Biome (format + lint + import organizer)'; }
26
+ get binary() { return 'biome'; }
27
+ get installMode() { return 'npm-dev'; }
28
+ get npmPackage() { return '@biomejs/biome'; }
29
+
30
+ /** devDep install, pinned exact (`-E`) so the toolchain is reproducible. */
31
+ installCommand() { return 'npm install --save-dev --save-exact @biomejs/biome'; }
32
+
33
+ /** Functional probe: resolves the devDep the SAME way agents invoke it (npx). */
34
+ verifyCommand() { return 'npx --no-install biome --version'; }
35
+
36
+ /** Config file Biome reads; `initConfig` writes it only when absent. */
37
+ get configFile() { return 'biome.json'; }
38
+
39
+ /**
40
+ * The literal commands agents run (read from toolchain.commands.* in the
41
+ * consumer's baldart.config.yml). Keys map onto the config block; Biome owns
42
+ * `lint` + `format` (it is the linter, formatter, AND import organizer).
43
+ */
44
+ commands() {
45
+ return {
46
+ lint: 'npx biome check .',
47
+ format: 'npx biome format --write .',
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Write a default biome.json ONLY when none exists (never overwrite — the
53
+ * non-destructive invariant). Returns `{ status: 'written'|'skipped', reason?, path }`.
54
+ * Never throws.
55
+ */
56
+ initConfig(cwd = this.cwd) {
57
+ const target = path.join(cwd, this.configFile);
58
+ if (fs.existsSync(target)) {
59
+ return { status: 'skipped', reason: 'exists', path: target };
60
+ }
61
+ const defaultConfig = {
62
+ $schema: 'https://biomejs.dev/schemas/2.0.0/schema.json',
63
+ vcs: { enabled: true, clientKind: 'git', useIgnoreFile: true },
64
+ files: { ignoreUnknown: true },
65
+ formatter: { enabled: true, indentStyle: 'space', indentWidth: 2 },
66
+ linter: { enabled: true, rules: { recommended: true } },
67
+ assist: { actions: { source: { organizeImports: 'on' } } },
68
+ };
69
+ try {
70
+ fs.writeFileSync(target, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf8');
71
+ return { status: 'written', path: target };
72
+ } catch (err) {
73
+ return { status: 'skipped', reason: (err.message || '').split('\n')[0], path: target };
74
+ }
75
+ }
76
+
77
+ /** Incumbent tools Biome supersedes — drives the migration proposal. */
78
+ static get replaces() { return ['eslint', 'prettier']; }
79
+
80
+ /** Autodetect whether the JS/TS toolchain is in scope for the project. */
81
+ static detect(cwd = process.cwd()) {
82
+ const markers = ['package.json', 'tsconfig.json', 'jsconfig.json'];
83
+ for (const m of markers) {
84
+ if (fs.existsSync(path.join(cwd, m))) return true;
85
+ }
86
+ try {
87
+ return fs.readdirSync(cwd).some((e) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(e));
88
+ } catch { return false; }
89
+ }
90
+ }
91
+
92
+ module.exports = BiomeAdapter;
@@ -0,0 +1,39 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * ESLint INCUMBENT detector — detection only, no install.
6
+ *
7
+ * BALDART never installs or removes ESLint; this adapter exists so `configure`
8
+ * can detect an existing ESLint setup and offer a (manual, never automatic)
9
+ * migration to Biome — never clobbering the user's config. `replacedBy` names
10
+ * the curated tool that would supersede it.
11
+ */
12
+ class EslintDetector {
13
+ constructor(cwd = process.cwd()) { this.cwd = cwd; }
14
+
15
+ get name() { return 'eslint'; }
16
+ get label() { return 'ESLint'; }
17
+ get replacedBy() { return 'biome'; }
18
+
19
+ static detect(cwd = process.cwd()) {
20
+ const markers = [
21
+ '.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json',
22
+ '.eslintrc.yml', '.eslintrc.yaml', 'eslint.config.js', 'eslint.config.mjs',
23
+ 'eslint.config.cjs', 'eslint.config.ts',
24
+ ];
25
+ for (const m of markers) {
26
+ if (fs.existsSync(path.join(cwd, m))) return true;
27
+ }
28
+ try {
29
+ const pkgPath = path.join(cwd, 'package.json');
30
+ if (!fs.existsSync(pkgPath)) return false;
31
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
32
+ if (pkg.eslintConfig) return true;
33
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
34
+ return Boolean(allDeps.eslint);
35
+ } catch { return false; }
36
+ }
37
+ }
38
+
39
+ module.exports = EslintDetector;
@@ -0,0 +1,30 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * husky INCUMBENT detector — detection only, no install. When present, the
6
+ * Lefthook migration stays MANUAL: BALDART never overwrites `.husky/` or runs
7
+ * `lefthook install` over an existing husky setup (the hook-handoff is the
8
+ * user's call). Drives the migration proposal in `configure`.
9
+ */
10
+ class HuskyDetector {
11
+ constructor(cwd = process.cwd()) { this.cwd = cwd; }
12
+
13
+ get name() { return 'husky'; }
14
+ get label() { return 'husky'; }
15
+ get replacedBy() { return 'lefthook'; }
16
+
17
+ static detect(cwd = process.cwd()) {
18
+ if (fs.existsSync(path.join(cwd, '.husky'))) return true;
19
+ try {
20
+ const pkgPath = path.join(cwd, 'package.json');
21
+ if (!fs.existsSync(pkgPath)) return false;
22
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
23
+ if (pkg.husky) return true;
24
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
25
+ return Boolean(allDeps.husky);
26
+ } catch { return false; }
27
+ }
28
+ }
29
+
30
+ module.exports = HuskyDetector;
@@ -0,0 +1,83 @@
1
+ const BiomeAdapter = require('./biome');
2
+ const VitestAdapter = require('./vitest');
3
+ const TscAdapter = require('./tsc');
4
+ const LefthookAdapter = require('./lefthook');
5
+ const EslintDetector = require('./eslint');
6
+ const PrettierDetector = require('./prettier');
7
+ const JestDetector = require('./jest');
8
+ const HuskyDetector = require('./husky');
9
+
10
+ /**
11
+ * Toolchain adapter registry.
12
+ *
13
+ * Two registries, two questions:
14
+ * - REGISTRY — curated tools BALDART can INSTALL (Biome, …; later Vitest,
15
+ * tsc, Lefthook, and cross-language Ruff/gofmt). Each exports a
16
+ * class shaped like BiomeAdapter (name, label, binary, installMode,
17
+ * npmPackage, installCommand, verifyCommand, configFile, commands(),
18
+ * initConfig(cwd), static replaces, static detect(cwd)).
19
+ * - INCUMBENTS — tools BALDART only DETECTS (ESLint, Prettier, …; later Jest,
20
+ * husky). Detection-only — never installed or removed. Drives the
21
+ * non-destructive migration proposal in `configure`.
22
+ *
23
+ * Adding a new curated tool:
24
+ * 1. Create `src/utils/toolchain-adapters/<name>.js` (BiomeAdapter shape).
25
+ * 2. Add it to REGISTRY below.
26
+ * Adding a new incumbent detector: same, into INCUMBENTS.
27
+ *
28
+ * Adapters describe HOW to install/verify a tool; they do NOT run the gates
29
+ * themselves. Agents read the resolved commands from `toolchain.commands.*` in
30
+ * baldart.config.yml at runtime (see framework/agents/toolchain-protocol.md).
31
+ */
32
+ const REGISTRY = {
33
+ biome: BiomeAdapter,
34
+ vitest: VitestAdapter,
35
+ tsc: TscAdapter,
36
+ lefthook: LefthookAdapter,
37
+ };
38
+
39
+ const INCUMBENTS = {
40
+ eslint: EslintDetector,
41
+ prettier: PrettierDetector,
42
+ jest: JestDetector,
43
+ husky: HuskyDetector,
44
+ };
45
+
46
+ function listAdapters() { return Object.keys(REGISTRY); }
47
+
48
+ function getAdapter(name, cwd) {
49
+ const Cls = REGISTRY[name];
50
+ if (!Cls) throw new Error(`Unknown toolchain adapter: ${name}. Available: ${listAdapters().join(', ')}`);
51
+ return new Cls(cwd);
52
+ }
53
+
54
+ /**
55
+ * Probe the cwd for every curated tool and return the names of adapters whose
56
+ * static detect() fires (i.e. the tool is in scope for this project — a JS/TS
57
+ * project for Biome). Pure: no install side effects.
58
+ */
59
+ function detectAll(cwd = process.cwd()) {
60
+ return Object.entries(REGISTRY)
61
+ .filter(([, Cls]) => {
62
+ try { return Cls.detect(cwd); } catch { return false; }
63
+ })
64
+ .map(([name]) => name);
65
+ }
66
+
67
+ /**
68
+ * Probe the cwd for incumbent tools already in use. Returns
69
+ * `[{ name, label, replacedBy }]` so `configure` can propose a migration.
70
+ * Pure: no side effects, never throws.
71
+ */
72
+ function detectIncumbents(cwd = process.cwd()) {
73
+ return Object.entries(INCUMBENTS)
74
+ .filter(([, Cls]) => {
75
+ try { return Cls.detect(cwd); } catch { return false; }
76
+ })
77
+ .map(([name, Cls]) => {
78
+ const inst = new Cls(cwd);
79
+ return { name, label: inst.label, replacedBy: inst.replacedBy };
80
+ });
81
+ }
82
+
83
+ module.exports = { REGISTRY, INCUMBENTS, listAdapters, getAdapter, detectAll, detectIncumbents };
@@ -0,0 +1,34 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Jest INCUMBENT detector — detection only, no install. Lets `configure`
6
+ * propose a (manual) migration to Vitest without ever touching the Jest setup.
7
+ */
8
+ class JestDetector {
9
+ constructor(cwd = process.cwd()) { this.cwd = cwd; }
10
+
11
+ get name() { return 'jest'; }
12
+ get label() { return 'Jest'; }
13
+ get replacedBy() { return 'vitest'; }
14
+
15
+ static detect(cwd = process.cwd()) {
16
+ const markers = [
17
+ 'jest.config.js', 'jest.config.cjs', 'jest.config.mjs',
18
+ 'jest.config.ts', 'jest.config.json',
19
+ ];
20
+ for (const m of markers) {
21
+ if (fs.existsSync(path.join(cwd, m))) return true;
22
+ }
23
+ try {
24
+ const pkgPath = path.join(cwd, 'package.json');
25
+ if (!fs.existsSync(pkgPath)) return false;
26
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
27
+ if (pkg.jest) return true;
28
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
29
+ return Boolean(allDeps.jest);
30
+ } catch { return false; }
31
+ }
32
+ }
33
+
34
+ module.exports = JestDetector;
@@ -0,0 +1,84 @@
1
+ const { execSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ /**
6
+ * Lefthook toolchain adapter — the curated pre-commit hook runner.
7
+ *
8
+ * `npm-dev` install. Owns the `pre-commit` hook (Graphify owns `post-commit`, so
9
+ * no collision). `replaces` = ['husky'] so a husky project routes to the manual
10
+ * migration proposal — BALDART NEVER overwrites `.husky/`.
11
+ *
12
+ * `initConfig` writes a minimal `lefthook.yml` only when absent (non-destructive).
13
+ * `postInstall` registers the git hooks via `npx lefthook install` — run by the
14
+ * installer AFTER the devDep + config land, and only on the clean-install path
15
+ * (never when husky is the incumbent).
16
+ *
17
+ * Contributes no `commands()` gate — it is plumbing, not a quality gate the
18
+ * agents invoke directly.
19
+ */
20
+ class LefthookAdapter {
21
+ constructor(cwd = process.cwd()) { this.cwd = cwd; }
22
+
23
+ get name() { return 'lefthook'; }
24
+ get label() { return 'Lefthook (pre-commit hooks)'; }
25
+ get binary() { return 'lefthook'; }
26
+ get installMode() { return 'npm-dev'; }
27
+ get npmPackage() { return 'lefthook'; }
28
+
29
+ installCommand() { return 'npm install --save-dev lefthook'; }
30
+ verifyCommand() { return 'npx --no-install lefthook version'; }
31
+
32
+ get configFile() { return 'lefthook.yml'; }
33
+
34
+ /** Plumbing, not a gate — contributes no toolchain.commands.* entry. */
35
+ commands() { return {}; }
36
+
37
+ /**
38
+ * Write a minimal lefthook.yml (pre-commit → Biome on staged files) only when
39
+ * absent. Never overwrites. Returns `{ status, reason?, path }`. Never throws.
40
+ */
41
+ initConfig(cwd = this.cwd) {
42
+ const target = path.join(cwd, this.configFile);
43
+ if (fs.existsSync(target)) return { status: 'skipped', reason: 'exists', path: target };
44
+ const yml = [
45
+ '# Managed by BALDART toolchain layer — customize freely.',
46
+ '# Runs Biome over staged files before each commit. Edit/remove as needed.',
47
+ 'pre-commit:',
48
+ ' parallel: true',
49
+ ' commands:',
50
+ ' biome:',
51
+ ' glob: "*.{js,ts,jsx,tsx,json,jsonc,css}"',
52
+ ' run: npx biome check --no-errors-on-unmatched {staged_files}',
53
+ '',
54
+ ].join('\n');
55
+ try {
56
+ fs.writeFileSync(target, yml, 'utf8');
57
+ return { status: 'written', path: target };
58
+ } catch (err) {
59
+ return { status: 'skipped', reason: (err.message || '').split('\n')[0], path: target };
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Register the git hooks. Run by the installer after install+config, only on
65
+ * the clean-install path. Returns `{ ok, error? }`. Never throws.
66
+ */
67
+ postInstall(cwd = this.cwd) {
68
+ try {
69
+ execSync('npx --no-install lefthook install', { cwd, stdio: 'pipe', timeout: 60000 });
70
+ return { ok: true };
71
+ } catch (err) {
72
+ return { ok: false, error: (err.message || '').split('\n')[0] };
73
+ }
74
+ }
75
+
76
+ static get replaces() { return ['husky']; }
77
+
78
+ /** In scope for any JS/TS project with git (a project benefits from hooks). */
79
+ static detect(cwd = process.cwd()) {
80
+ return fs.existsSync(path.join(cwd, 'package.json'));
81
+ }
82
+ }
83
+
84
+ module.exports = LefthookAdapter;