baldart 4.54.1 → 4.55.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/VERSION +1 -1
- package/framework/agents/i18n-protocol.md +16 -0
- package/package.json +1 -1
- package/src/commands/configure.js +12 -2
- package/src/commands/doctor.js +42 -2
- package/src/utils/toolchain-adapters/lefthook.js +27 -4
- package/src/utils/toolchain-installer.js +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ All notable changes to BALDART will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [4.55.1] - 2026-06-19
|
|
9
|
+
|
|
10
|
+
**`baldart doctor`'s toolchain fresh-write paths now also wire the i18n pre-commit command (v4.55.0 follow-up).** v4.55.0 wired the guarded `i18n` lefthook command only through `configure`. The doctor backfill actions that write a FRESH `lefthook.yml` — `toolchain-install` (when lefthook resolves after a devDep install) and `toolchain-init-config` (restore a missing default config) — did not pass `i18nCommand`, so a `lefthook.yml` born from a doctor backfill on a `has_toolchain` + `has_i18n` project would be Biome-only. Both now compute the resolved (trailing-` .`-stripped) i18n command from `state.i18nEnabled` and pass it through, exactly like `configure`. Fresh-write only (`initConfig` still skips an existing file); the `i18n-precommit-snippet` WARN still covers an already-present Biome-only `lefthook.yml` (no duplication — the snippet check requires the file to exist).
|
|
11
|
+
|
|
12
|
+
**PATCH** (closes a fresh-write gap in doctor; no behaviour change for projects without `has_i18n`; no new agent/skill/command/key, no install/layout change).
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **`src/commands/doctor.js`** — `toolchain-install` + `toolchain-init-config` actions pass `i18nCommand` to `ToolchainInstaller.install` / `initConfigs` when `has_i18n`.
|
|
17
|
+
|
|
18
|
+
## [4.55.0] - 2026-06-19
|
|
19
|
+
|
|
20
|
+
**The i18n anti-hardcoded gate now also runs as a guarded lefthook pre-commit command — closing the direct-commit hole for projects that have BOTH the toolchain and i18n enabled.** Until now the gate ran only inside the BALDART workflows (`/new`, `/new2`, `/qa`, qa-sentinel, routine), so a *direct* `git commit` (a human, or an agent editing outside `/new`) bypassed it. When `features.has_toolchain` AND `features.has_i18n` are both true, a fresh `lefthook.yml` now gains an `i18n` pre-commit command that runs the standalone anti-hardcoded gate over `{staged_files}`. It is **guarded** (`sh -c 'if [ -f eslint.i18n.config.mjs ] || …; then exec <cmd> "$@"; fi'`): the gate's non-zero exit propagates (a hardcoded JSX string fails the commit), but when the gate config is absent it is a clean no-op (exit 0) — never a spurious block. Bypassable with `git commit --no-verify` (a strong default, not a wall); covers JSX text + allowlisted attributes (`jsx-only`); the non-JSX backstop stays with `code-reviewer`; consumers without the toolchain keep the `/new`/qa-sentinel gates as enforcement.
|
|
21
|
+
|
|
22
|
+
**Scope discipline (adversarial-reviewed).** The original idea also included a deterministic *registry-membership* pre-commit check (every `t('key')` must be in the registry). A 3-reviewer adversarial pass **rejected it** and it is NOT shipped: `I18N_REGISTRY_DRIFT` is already triple-covered (coder STEP 9 + code-reviewer + doc-reviewer); the `extract_command` activation gate was a fake proxy (the enumerator is a regex, and `extract_command` is auto-populated only for i18next/lingui/react-intl → off for the mainstream next-intl/vue/custom); the static-key regex would false-block correct commits (`useTranslation('ns')` + `t('key')`, overlay-overridable conventions); and it would break inside `/new` git worktrees (`.claude/skills/` is untracked there). The pre-commit hook was likewise scoped down — it rides the **existing** toolchain lefthook (no new installer, no new config key, no husky branch, no installing lefthook on a non-toolchain project, no js-yaml round-trip of user files).
|
|
23
|
+
|
|
24
|
+
**MINOR** (extends the toolchain lefthook's fresh-config behaviour, gated on existing flags; no new agent/skill/command, no new `baldart.config.yml` key, no install/layout change).
|
|
25
|
+
|
|
26
|
+
### Added / Changed
|
|
27
|
+
|
|
28
|
+
- **`src/utils/toolchain-adapters/lefthook.js`** — `initConfig(cwd, opts)` accepts `opts.i18nCommand`; a fresh `lefthook.yml` gains the guarded `i18n` pre-commit command alongside `biome`. Non-destructive behaviour unchanged (writes only when absent).
|
|
29
|
+
- **`src/utils/toolchain-installer.js`** — `install()` / `initConfigs()` forward `opts.i18nCommand` to the adapters.
|
|
30
|
+
- **`src/commands/configure.js`** — the toolchain block resolves `i18nCommand` from `features.has_i18n` (the diff-scoped gate command, trailing ` .` stripped) and passes it through.
|
|
31
|
+
- **`src/commands/doctor.js`** — WARN-only backfill: when `has_toolchain` + `has_i18n` and `lefthook.yml` has no i18n command (toolchain predating v4.55.0), surface the exact snippet to paste. doctor never mutates a user-owned `lefthook.yml`.
|
|
32
|
+
- **`framework/agents/i18n-protocol.md`** — documents the pre-commit layer (gated on `has_toolchain`, `--no-verify` caveat) and records why the registry-membership check was rejected.
|
|
33
|
+
|
|
8
34
|
## [4.54.1] - 2026-06-19
|
|
9
35
|
|
|
10
36
|
**Two bugs in the Codex relay of the `/new` review workflows — found live as a ~20-minute "freeze".** During a real `/new FEAT-0037` the Codex step of `new-card-review` appeared stuck; on-disk diagnosis (`/tmp/codexreview-wave-FEAT-0037*.md` + `ps`) showed **Codex had actually finished 9 minutes earlier** (full review, zero actionable findings) but the relay couldn't detect it, because of two independent, compounding bugs:
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.55.1
|
|
@@ -96,6 +96,22 @@ Translations are NOT stored here — they live in the native locale files.
|
|
|
96
96
|
routine — run whole-repo (`.`) by design, because they reconcile the entire
|
|
97
97
|
codebase, not a single change. A diff-scoped gate failure is therefore always a
|
|
98
98
|
string the change itself introduced — never baseline noise to triage.
|
|
99
|
+
|
|
100
|
+
**Pre-commit layer (since v4.55.0) — only when `features.has_toolchain` is also
|
|
101
|
+
on.** The gates above run inside the BALDART workflows; a **direct** `git commit`
|
|
102
|
+
(a human, or an agent editing outside `/new`) bypasses them. So when BOTH
|
|
103
|
+
`features.has_i18n` and `features.has_toolchain` are true, the curated lefthook
|
|
104
|
+
pre-commit gains a **guarded `i18n` command** that runs the anti-hardcoded gate
|
|
105
|
+
over `{staged_files}` on every commit. It is wired automatically into a fresh
|
|
106
|
+
`lefthook.yml` by `baldart configure`/`add`; for a toolchain installed before
|
|
107
|
+
v4.55.0, `baldart doctor` surfaces the snippet to paste (it never mutates a
|
|
108
|
+
user-owned `lefthook.yml`). The command is guarded (no-op when the gate config is
|
|
109
|
+
absent) and **bypassable with `git commit --no-verify`** — a strong default, not
|
|
110
|
+
a wall. Consumers WITHOUT the toolchain are not covered at direct-commit time;
|
|
111
|
+
the `/new` + `qa-sentinel` gates remain their enforcement. (A deterministic
|
|
112
|
+
registry-membership pre-commit check was evaluated and rejected: it is already
|
|
113
|
+
triple-covered by the review owners below, its key-enumeration is fragile across
|
|
114
|
+
stacks, and it breaks inside `/new` git worktrees.)
|
|
99
115
|
2. **Semantic backstop (review)** — non-JSX imperative strings are OUTSIDE the
|
|
100
116
|
linter's deterministic reach, so `code-reviewer` flags them as **HIGH**,
|
|
101
117
|
**syntactically scoped**:
|
package/package.json
CHANGED
|
@@ -981,6 +981,16 @@ async function interactivePrompts(merged, detected) {
|
|
|
981
981
|
const migs = ti.migrations(); // curated tools blocked by an incumbent
|
|
982
982
|
const toInstall = [];
|
|
983
983
|
|
|
984
|
+
// i18n pre-commit gate (since v4.55.0) — when has_i18n, a FRESH lefthook.yml
|
|
985
|
+
// also gets a guarded i18n command (the anti-hardcoded gate over staged
|
|
986
|
+
// files), so direct commits are caught too. Resolve + strip the trailing
|
|
987
|
+
// full-sweep ` .` (consistent with the diff-scoped gates). null when i18n off
|
|
988
|
+
// → lefthook stays Biome-only. Existing lefthook.yml is never mutated here
|
|
989
|
+
// (doctor WARNs with the snippet instead).
|
|
990
|
+
const i18nCmd = merged.features.has_i18n
|
|
991
|
+
? I18nGate.commandForCwd(process.cwd()).replace(/\s+\.$/, '')
|
|
992
|
+
: null;
|
|
993
|
+
|
|
984
994
|
// Clean path — preselect (default Y).
|
|
985
995
|
if (recommended.length) {
|
|
986
996
|
const want = await UI.confirm(`Install the curated toolchain (${recommended.join(', ')}) as devDependencies now?`, true);
|
|
@@ -1001,7 +1011,7 @@ async function interactivePrompts(merged, detected) {
|
|
|
1001
1011
|
}
|
|
1002
1012
|
|
|
1003
1013
|
if (toInstall.length) {
|
|
1004
|
-
const res = ti.install({ tools: toInstall });
|
|
1014
|
+
const res = ti.install({ tools: toInstall, i18nCommand: i18nCmd });
|
|
1005
1015
|
for (const t of res.installed) UI.success(`Installed ${t}.`);
|
|
1006
1016
|
for (const f of res.failed) UI.warning(`Install failed for ${f.tool}: ${f.error}`);
|
|
1007
1017
|
for (const s of res.skipped) UI.info(`Skipped ${s.tool}: ${s.reason}`);
|
|
@@ -1010,7 +1020,7 @@ async function interactivePrompts(merged, detected) {
|
|
|
1010
1020
|
// Resolve the active tool set (every in-scope tool usable now) → configs + commands.
|
|
1011
1021
|
const active = ti.activeTools();
|
|
1012
1022
|
if (active.length) {
|
|
1013
|
-
const cfgRes = ti.initConfigs({ tools: active });
|
|
1023
|
+
const cfgRes = ti.initConfigs({ tools: active, i18nCommand: i18nCmd });
|
|
1014
1024
|
for (const c of cfgRes) {
|
|
1015
1025
|
if (c.status === 'written') UI.success(`Wrote default ${c.tool} config.`);
|
|
1016
1026
|
else if (c.status === 'skipped' && c.reason === 'exists') UI.info(`Kept existing ${c.tool} config (not overwritten).`);
|
package/src/commands/doctor.js
CHANGED
|
@@ -446,6 +446,21 @@ async function detectState(cwd, opts = {}) {
|
|
|
446
446
|
&& (!I18nGate.isConfigPresent(cwd) || !I18nGate.depsInstalled(cwd))) {
|
|
447
447
|
state.i18nGateMissing = true;
|
|
448
448
|
}
|
|
449
|
+
// i18n pre-commit gate wired into lefthook? (since v4.55.0) — only when
|
|
450
|
+
// the toolchain (lefthook) is in play. WARN-only: a fresh lefthook.yml
|
|
451
|
+
// gets the guarded i18n command at configure time, but a consumer whose
|
|
452
|
+
// toolchain was installed before v4.55.0 has a Biome-only one. doctor
|
|
453
|
+
// NEVER mutates a user lefthook.yml — it surfaces the snippet to add.
|
|
454
|
+
state.i18nPrecommitSnippetNeeded = false;
|
|
455
|
+
if (config.features.has_toolchain === true) {
|
|
456
|
+
const lhPath = path.join(cwd, 'lefthook.yml');
|
|
457
|
+
if (fs.existsSync(lhPath)) {
|
|
458
|
+
const lh = fs.readFileSync(lhPath, 'utf8');
|
|
459
|
+
if (!/eslint\.i18n\.config\.mjs/.test(lh) && !/\.eslintrc\.i18n\.json/.test(lh)) {
|
|
460
|
+
state.i18nPrecommitSnippetNeeded = true;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
449
464
|
}
|
|
450
465
|
} catch (_) { /* never block doctor on i18n probe */ }
|
|
451
466
|
|
|
@@ -885,7 +900,10 @@ function planActions(state) {
|
|
|
885
900
|
autoOk: false, // installs devDependencies; let the user confirm
|
|
886
901
|
run: async () => {
|
|
887
902
|
const ti = new ToolchainInstaller(state.cwd);
|
|
888
|
-
|
|
903
|
+
// When has_i18n, a fresh lefthook.yml written here also gets the guarded
|
|
904
|
+
// i18n pre-commit command (v4.55.1) — same as configure. Fresh-write only.
|
|
905
|
+
const i18nCmd = state.i18nEnabled ? I18nGate.commandForCwd(state.cwd).replace(/\s+\.$/, '') : null;
|
|
906
|
+
const res = ti.install({ tools: state.toolchainMissingTools, i18nCommand: i18nCmd });
|
|
889
907
|
for (const t of res.installed) UI.success(`Installed ${t}.`);
|
|
890
908
|
for (const f of res.failed) UI.warning(`Install failed for ${f.tool}: ${f.error}`);
|
|
891
909
|
for (const s of res.skipped) UI.info(`Skipped ${s.tool}: ${s.reason}`);
|
|
@@ -900,7 +918,9 @@ function planActions(state) {
|
|
|
900
918
|
autoOk: true, // only writes an absent default config
|
|
901
919
|
run: async () => {
|
|
902
920
|
const ti = new ToolchainInstaller(state.cwd);
|
|
903
|
-
|
|
921
|
+
// Fresh-write path: include the guarded i18n command when has_i18n (v4.55.1).
|
|
922
|
+
const i18nCmd = state.i18nEnabled ? I18nGate.commandForCwd(state.cwd).replace(/\s+\.$/, '') : null;
|
|
923
|
+
const cfgRes = ti.initConfigs({ tools: state.toolchainMissingConfigs, i18nCommand: i18nCmd });
|
|
904
924
|
for (const c of cfgRes) {
|
|
905
925
|
if (c.status === 'written') UI.success(`Wrote default ${c.tool} config.`);
|
|
906
926
|
else UI.info(`${c.tool}: ${c.reason || c.status}`);
|
|
@@ -942,6 +962,26 @@ function planActions(state) {
|
|
|
942
962
|
},
|
|
943
963
|
});
|
|
944
964
|
}
|
|
965
|
+
if (state.i18nEnabled && state.i18nPrecommitSnippetNeeded) {
|
|
966
|
+
const i18nCmd = I18nGate.commandForCwd(state.cwd).replace(/\s+\.$/, '');
|
|
967
|
+
const snippet = [
|
|
968
|
+
' commands:',
|
|
969
|
+
' i18n:',
|
|
970
|
+
' glob: "*.{js,jsx,ts,tsx,mjs,cjs}"',
|
|
971
|
+
` run: sh -c 'if [ -f eslint.i18n.config.mjs ] || [ -f .eslintrc.i18n.json ]; then exec ${i18nCmd} "$@"; fi' _ {staged_files}`,
|
|
972
|
+
].join('\n');
|
|
973
|
+
actions.push({
|
|
974
|
+
key: 'i18n-precommit-snippet',
|
|
975
|
+
label: 'Add the i18n anti-hardcoded gate to your lefthook pre-commit (manual — snippet shown)',
|
|
976
|
+
why: 'features.has_i18n + the toolchain are both on, but lefthook.yml has no i18n command, so a DIRECT `git commit` (outside /new) is not gated for hardcoded user-facing strings. A fresh install wires this automatically; your toolchain predates v4.55.0. doctor will NOT edit your lefthook.yml — it shows the block to paste under pre-commit.',
|
|
977
|
+
autoOk: false, // never mutates a user-owned lefthook.yml — surfaces the snippet only
|
|
978
|
+
run: async () => {
|
|
979
|
+
UI.info('Add this under your `pre-commit:` block in lefthook.yml (merge into the existing `commands:` map):');
|
|
980
|
+
UI.info(snippet);
|
|
981
|
+
UI.info('The gate is guarded (no-op if the eslint.i18n config is absent) and bypassable with `git commit --no-verify`. The /new + qa-sentinel gates remain regardless.');
|
|
982
|
+
},
|
|
983
|
+
});
|
|
984
|
+
}
|
|
945
985
|
|
|
946
986
|
// External-tool version currency (since v4.38.0). One non-blocking upgrade
|
|
947
987
|
// action per managed tool confirmed behind upstream — the tool-dependency
|
|
@@ -37,11 +37,22 @@ class LefthookAdapter {
|
|
|
37
37
|
/**
|
|
38
38
|
* Write a minimal lefthook.yml (pre-commit → Biome on staged files) only when
|
|
39
39
|
* absent. Never overwrites. Returns `{ status, reason?, path }`. Never throws.
|
|
40
|
+
*
|
|
41
|
+
* `opts.i18nCommand` (since v4.55.0) — when set (only on a `features.has_i18n`
|
|
42
|
+
* project, resolved + trailing-` .`-stripped by the caller), the fresh config
|
|
43
|
+
* ALSO gains an `i18n` pre-commit command that runs the standalone
|
|
44
|
+
* anti-hardcoded gate over `{staged_files}`. It is **guarded**: it runs the gate
|
|
45
|
+
* only if the gate's config file exists on disk and `exec`s it so the gate's
|
|
46
|
+
* exit code propagates (a hardcoded JSX string fails the commit); when the
|
|
47
|
+
* config is absent (e.g. the user declined gate setup, or it runs before the
|
|
48
|
+
* gate is written in the same `configure` pass) it is a clean no-op (exit 0),
|
|
49
|
+
* never a spurious block. Closes the direct-commit hole for toolchain+i18n
|
|
50
|
+
* consumers — see agents/i18n-protocol.md.
|
|
40
51
|
*/
|
|
41
|
-
initConfig(cwd = this.cwd) {
|
|
52
|
+
initConfig(cwd = this.cwd, opts = {}) {
|
|
42
53
|
const target = path.join(cwd, this.configFile);
|
|
43
54
|
if (fs.existsSync(target)) return { status: 'skipped', reason: 'exists', path: target };
|
|
44
|
-
const
|
|
55
|
+
const lines = [
|
|
45
56
|
'# Managed by BALDART toolchain layer — customize freely.',
|
|
46
57
|
'# Runs Biome over staged files before each commit. Edit/remove as needed.',
|
|
47
58
|
'pre-commit:',
|
|
@@ -50,8 +61,20 @@ class LefthookAdapter {
|
|
|
50
61
|
' biome:',
|
|
51
62
|
' glob: "*.{js,ts,jsx,tsx,json,jsonc,css}"',
|
|
52
63
|
' run: npx biome check --no-errors-on-unmatched {staged_files}',
|
|
53
|
-
|
|
54
|
-
|
|
64
|
+
];
|
|
65
|
+
if (opts.i18nCommand) {
|
|
66
|
+
// Guarded: skip when the gate config is absent (exit 0), else exec so the
|
|
67
|
+
// gate's non-zero exit blocks the commit. Single-quoted sh -c is a plain
|
|
68
|
+
// YAML scalar (no `: `/`#`) — verified to round-trip exactly.
|
|
69
|
+
const guard = `sh -c 'if [ -f eslint.i18n.config.mjs ] || [ -f .eslintrc.i18n.json ]; then exec ${opts.i18nCommand} "$@"; fi' _ {staged_files}`;
|
|
70
|
+
lines.push(
|
|
71
|
+
' i18n:',
|
|
72
|
+
' glob: "*.{js,jsx,ts,tsx,mjs,cjs}"',
|
|
73
|
+
` run: ${guard}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
lines.push('');
|
|
77
|
+
const yml = lines.join('\n');
|
|
55
78
|
try {
|
|
56
79
|
fs.writeFileSync(target, yml, 'utf8');
|
|
57
80
|
return { status: 'written', path: target };
|
|
@@ -93,7 +93,7 @@ class ToolchainInstaller {
|
|
|
93
93
|
* `{ ok, installed:[], failed:[{tool,error}], skipped:[{tool,reason}] }`.
|
|
94
94
|
* Never throws. NOT run in CI / `--non-interactive` by the command layer.
|
|
95
95
|
*/
|
|
96
|
-
install({ tools = this.recommend(), spinner = true } = {}) {
|
|
96
|
+
install({ tools = this.recommend(), spinner = true, i18nCommand = null } = {}) {
|
|
97
97
|
if (!this.hasPackageJson()) {
|
|
98
98
|
return { ok: false, installed: [], failed: [], skipped: tools.map((t) => ({ tool: t, reason: 'no package.json' })) };
|
|
99
99
|
}
|
|
@@ -113,7 +113,7 @@ class ToolchainInstaller {
|
|
|
113
113
|
// commented-out example, then register no hooks) sees OURS — a biome
|
|
114
114
|
// pre-commit — and registers it. Non-destructive (skips if present).
|
|
115
115
|
if (typeof adapter.initConfig === 'function') {
|
|
116
|
-
try { adapter.initConfig(this.cwd); } catch (_) { /* non-fatal */ }
|
|
116
|
+
try { adapter.initConfig(this.cwd, { i18nCommand }); } catch (_) { /* non-fatal */ }
|
|
117
117
|
}
|
|
118
118
|
execSync(adapter.installCommand(), { cwd: this.cwd, stdio: 'pipe', timeout: 300000 });
|
|
119
119
|
if (this._toolUsable(name)) {
|
|
@@ -144,14 +144,14 @@ class ToolchainInstaller {
|
|
|
144
144
|
* Write each tool's default config when absent (non-destructive). Returns
|
|
145
145
|
* `[{ tool, status:'written'|'skipped'|'noop', reason?, path? }]`. Never throws.
|
|
146
146
|
*/
|
|
147
|
-
initConfigs({ tools = this.inScope() } = {}) {
|
|
147
|
+
initConfigs({ tools = this.inScope(), i18nCommand = null } = {}) {
|
|
148
148
|
const out = [];
|
|
149
149
|
for (const name of tools) {
|
|
150
150
|
let adapter;
|
|
151
151
|
try { adapter = adapters.getAdapter(name, this.cwd); }
|
|
152
152
|
catch { out.push({ tool: name, status: 'noop', reason: 'unknown adapter' }); continue; }
|
|
153
153
|
if (typeof adapter.initConfig !== 'function') { out.push({ tool: name, status: 'noop' }); continue; }
|
|
154
|
-
try { out.push({ tool: name, ...adapter.initConfig(this.cwd) }); }
|
|
154
|
+
try { out.push({ tool: name, ...adapter.initConfig(this.cwd, { i18nCommand }) }); }
|
|
155
155
|
catch (err) { out.push({ tool: name, status: 'noop', reason: (err.message || '').split('\n')[0] }); }
|
|
156
156
|
}
|
|
157
157
|
return out;
|