baldart 4.54.0 → 4.55.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/CHANGELOG.md +34 -0
- package/VERSION +1 -1
- package/framework/.claude/workflows/new-card-review.js +16 -6
- package/framework/.claude/workflows/new-final-review.js +16 -6
- 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 +35 -0
- package/src/utils/toolchain-adapters/lefthook.js +27 -4
- package/src/utils/toolchain-installer.js +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,40 @@ 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.0] - 2026-06-19
|
|
9
|
+
|
|
10
|
+
**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.
|
|
11
|
+
|
|
12
|
+
**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).
|
|
13
|
+
|
|
14
|
+
**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).
|
|
15
|
+
|
|
16
|
+
### Added / Changed
|
|
17
|
+
|
|
18
|
+
- **`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).
|
|
19
|
+
- **`src/utils/toolchain-installer.js`** — `install()` / `initConfigs()` forward `opts.i18nCommand` to the adapters.
|
|
20
|
+
- **`src/commands/configure.js`** — the toolchain block resolves `i18nCommand` from `features.has_i18n` (the diff-scoped gate command, trailing ` .` stripped) and passes it through.
|
|
21
|
+
- **`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`.
|
|
22
|
+
- **`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.
|
|
23
|
+
|
|
24
|
+
## [4.54.1] - 2026-06-19
|
|
25
|
+
|
|
26
|
+
**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:
|
|
27
|
+
|
|
28
|
+
1. **`$$` placeholder in the `/tmp` filenames.** The workflows built `/tmp/codextask-${cardId}-$$.txt` / `/tmp/codexreview-wave-${cardId}-$$.md`. In a JS template string `$$` is the literal two characters — the `Write` tool keeps it verbatim, but **bash expands `$$` to the PID of each subshell**, so the launch, the poll, and the extract Bash calls each referenced a *different* path that never agreed. Observed: the first launch `cat`-ed a PID-named task file that didn't exist → Codex ran with an empty task ("Provide a prompt…", 68 bytes); the relay retried with clean names (Codex then ran for real), but the poll loop kept `grep`-ing a `-$$.md` file with a fresh PID each iteration → never terminal.
|
|
29
|
+
2. **Codex emits prose, not the sentinel block.** Even with the right file, Codex (GPT-5.x via the companion) wrote its findings as a prose report (`**Findings** No actionable… **Cleared Concerns** …`) instead of between `<<<FINDINGS_JSON>>>`/`<<<END_FINDINGS_JSON>>>`, so the relay's only terminal condition (`grep END_FINDINGS_JSON`) never matched → it spun to the 10-minute window before falling back to `code-reviewer`.
|
|
30
|
+
|
|
31
|
+
Nothing broke downstream (the fallback is wired), but every occurrence cost ~12 wasted minutes + a redundant full `code-reviewer` re-run. Fix:
|
|
32
|
+
- **Stable per-wave/batch filenames (no `$$`)** — `cardId`/`firstCardId` is already unique per wave/batch and the files are `/tmp` scratch truncated by `>` on launch, so the launch/poll/extract calls now agree on one literal path.
|
|
33
|
+
- **Robust terminal detection** — the relay also polls for the companion's reliable `Turn completed.` marker. New return logic: a non-empty awk extraction ⇒ `codexAvailable:true` + parsed findings; `Turn completed.`/`CODEX_NOT_FOUND` seen **but** the awk output empty (Codex finished with prose, no machine-readable block) ⇒ `codexAvailable:false` + **exit immediately** (route to the `code-reviewer` fallback — never parse findings out of prose, which would risk dropping a BLOCKER); only a full 10-minute window with no marker ⇒ false. So a prose-only completion now falls back at Codex-done time, not +10 min.
|
|
34
|
+
- **Task-prompt hardening (best-effort)** — Codex is told its FINAL message must be ONLY the sentinel block (no `Findings`/`Cleared Concerns`/`Validation` prose; cleared concerns go as `requires_action:false` entries inside the JSON), reducing how often the prose-fallback path is hit.
|
|
35
|
+
|
|
36
|
+
Verified by parsing both workflows (`node -c`), confirming zero `$$` remain, and running the exact `awk` + marker logic against the real incident fixture (prose-only → empty awk + `Turn completed.` → `false`/fallback) and a synthetic sentinel file (→ valid JSON, `true`). Scope is the two review workflows only; `new2.js`'s Codex usage is a different shape (a `$FILE` var, no sentinel-poll dependency; a synchronous `--wait` per-card call) and inherits the fix at the Final via its delegation to `new-final-review`. **PATCH** (relay bugfix; no new agent/skill/command, no `baldart.config.yml` key, no install/layout change).
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **`framework/.claude/workflows/new-card-review.js`** + **`new-final-review.js`** — Codex relay: (1) dropped the `$$` placeholder from the `/tmp` task/review filenames (stable per-wave/batch names); (2) added `Turn completed.` as a poll terminal + a deterministic 3-way return rule (non-empty JSON ⇒ available; finished-without-block ⇒ unavailable, immediate fallback, never prose-parse; full-window-no-marker ⇒ unavailable); (3) task prompt now demands the FINAL message be ONLY the sentinel block.
|
|
41
|
+
|
|
8
42
|
## [4.54.0] - 2026-06-19
|
|
9
43
|
|
|
10
44
|
**The i18n anti-hardcoded gate is now DIFF-SCOPED in every per-change context, and the two UI-authoring surfaces (`ui-expert`, `ui-design`) finally honor the i18n layer.** Surfaced by a real `/new FEAT-0037` run on a consumer (`mayo`) that is *partially* i18n-adopted: the coder had to manually triage 25 eslint-i18n errors + 4 parity failures and discovered they were **pre-existing baseline debt in files the card never touched** (`products/edit`, `products/new`, `sw-register`, …). Root cause: the i18n gate ran **whole-repo** (`npx eslint --config eslint.i18n.config.mjs .`) in every per-change consumer, while the *normal* lint gate was already diff-scoped — so on any mid-adoption project every card failed the i18n gate on unrelated baseline debt. The coder did the right thing (distrusted its own report, verified independently) but should never have had to. (The parity failure and the `Cannot find module '@/lib/i18n'` tsc note in that run were project-side, not framework — handled soundly by the coder.)
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.55.0
|
|
@@ -229,13 +229,17 @@ const codexReviewTask =
|
|
|
229
229
|
`Run a deep code review over this wave's committed diff. The code is already written and committed — find bugs, regressions, security issues, and quality problems, per the protocol in ${protocolRef} (Phase 3.7).\n\n` +
|
|
230
230
|
`${waveBrief}\n\n${baselineBrief}\n\n` +
|
|
231
231
|
`Run the mandatory false-positive check on every finding and suppress the unconvincing ones. Actionability: set requires_action:false on a VERIFIED observation that needs NO change (a cleared concern — MED/LOW only; a BLOCKER/HIGH is always requires_action:true).\n\n` +
|
|
232
|
-
`
|
|
232
|
+
`Your FINAL message MUST be ONLY the sentinel block below — NOTHING else. Do NOT write a prose report, do NOT add "Findings" / "Cleared Concerns" / "Validation" headings before or after it. Put every cleared concern as a \`requires_action:false\` entry INSIDE the JSON array. Emit EXACTLY:\n` +
|
|
233
233
|
`<<<FINDINGS_JSON>>>\n` +
|
|
234
234
|
`[{"finding_id":"<CARD-ID>-F###","title":"...","severity":"BLOCKER|HIGH|MEDIUM|LOW","confidence":<0-100>,"evidence":"exact file:line + code quote","minimal_fix_direction":"...","domain":"doc|security|migration|code|perf|test","requires_action":true}]\n` +
|
|
235
235
|
`<<<END_FINDINGS_JSON>>>\n` +
|
|
236
236
|
`Use an empty array [] between the sentinels if there are no real bugs.`
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
// Stable per-wave names — NO `$$`: in a JS template string `$$` is the literal two chars, which the
|
|
238
|
+
// Write tool keeps verbatim but bash expands to a per-subshell PID → the launch/poll/extract Bash calls
|
|
239
|
+
// each saw a DIFFERENT path and never agreed (observed live: poll grepped a nonexistent PID-named file
|
|
240
|
+
// forever). cardId is already unique per wave; the file is /tmp scratch truncated by `>` on launch.
|
|
241
|
+
const codexTaskFile = `/tmp/codextask-${cards[0].cardId}.txt`
|
|
242
|
+
const codexReviewFile = `/tmp/codexreview-wave-${cards[0].cardId}.md`
|
|
239
243
|
const codexPrompt =
|
|
240
244
|
`You are a THIN RELAY around the Codex companion — do NOT review or investigate code yourself, do NOT grep/sed/Read source to "confirm" findings (Codex's findings are authoritative and already FP-validated; re-verifying them is wasted work). Your ONLY job: launch Codex, wait, extract its JSON, return it.\n\n` +
|
|
241
245
|
`The companion script is ALREADY CONFIRMED PRESENT at:\n ${codexScriptPath}\n\n` +
|
|
@@ -243,11 +247,17 @@ const codexPrompt =
|
|
|
243
247
|
` 1. Write the task text (everything after "TASK PROMPT:" at the end of this message) to ${codexTaskFile} using the Write tool — do NOT inline it into the shell command (avoids quote breakage).\n` +
|
|
244
248
|
` 2. Launch Codex in the BACKGROUND (run_in_background:true — a sync run hits the Bash timeout):\n` +
|
|
245
249
|
` node "${codexScriptPath}" task "$(cat ${codexTaskFile})" --cwd "${a.worktreePath || '.'}" > ${codexReviewFile} 2>&1\n` +
|
|
246
|
-
` 3. POLL ${codexReviewFile} (BashOutput / repeated reads) until it contains
|
|
250
|
+
` 3. POLL ${codexReviewFile} (BashOutput / repeated reads) until it contains ANY of these — whichever appears FIRST ends the poll (do NOT keep waiting the full window once one is present):\n` +
|
|
251
|
+
` • "<<<END_FINDINGS_JSON>>>" → Codex emitted the JSON block (success path)\n` +
|
|
252
|
+
` • "Turn completed." → the companion's reliable end-of-run marker — Codex FINISHED even if it wrote prose instead of the block\n` +
|
|
253
|
+
` • "CODEX_NOT_FOUND" → companion missing\n` +
|
|
254
|
+
` • the full 10-minute window has elapsed\n` +
|
|
247
255
|
` 4. Extract the findings with this EXACT command (deterministic — skips the [codex] trace + truncated echo, takes the LAST complete sentinel pair):\n` +
|
|
248
256
|
` awk '/<<<FINDINGS_JSON>>>/{c=1;b="";next}/<<<END_FINDINGS_JSON>>>/{c=0;last=b;next}c{b=b $0 ORS}END{printf "%s",last}' ${codexReviewFile}\n` +
|
|
249
|
-
`
|
|
250
|
-
`
|
|
257
|
+
` 5. Decide the return — do NOT re-verify, re-grep, or Read any source file:\n` +
|
|
258
|
+
` • awk output is a NON-EMPTY JSON array → parse it and return it verbatim as \`findings\`, codexAvailable:true (its keys already match the schema).\n` +
|
|
259
|
+
` • awk output is EMPTY but the file has "Turn completed." (or "CODEX_NOT_FOUND") → Codex finished WITHOUT the sentinel block (e.g. it wrote a prose report). Return codexAvailable:false, findings:[] and EXIT NOW — do NOT wait the rest of the window, and do NOT parse findings out of the prose (a prose-only completion routes to the code-reviewer fallback; never risk dropping a real finding by guessing).\n` +
|
|
260
|
+
` • none of the markers ever appeared after the FULL 10-minute window → codexAvailable:false. NEVER set false because a single poll returned slowly.\n\n` +
|
|
251
261
|
`TASK PROMPT:\n${codexReviewTask}`
|
|
252
262
|
|
|
253
263
|
const tcGateLines = [
|
|
@@ -185,13 +185,17 @@ const codexReviewTask =
|
|
|
185
185
|
`Run a deep code review over this batch diff, per the /codexreview protocol in ${protocolRef} (Step F.3). The code is already written and committed — find bugs, regressions, security issues, and quality problems.\n\n` +
|
|
186
186
|
`${scopeBrief}\n\n${baselineBrief}\n\n` +
|
|
187
187
|
`Run the mandatory false-positive check on every finding and suppress the unconvincing ones. Actionability: set requires_action:false on a VERIFIED observation that needs NO change (a cleared concern — MED/LOW only; a BLOCKER/HIGH is always requires_action:true).\n\n` +
|
|
188
|
-
`
|
|
188
|
+
`Your FINAL message MUST be ONLY the sentinel block below — NOTHING else. Do NOT write a prose report, do NOT add "Findings" / "Cleared Concerns" / "Validation" headings before or after it. Put every cleared concern as a \`requires_action:false\` entry INSIDE the JSON array. Emit EXACTLY:\n` +
|
|
189
189
|
`<<<FINDINGS_JSON>>>\n` +
|
|
190
190
|
`[{"finding_id":"<CARD-ID>-F###","title":"...","severity":"BLOCKER|HIGH|MEDIUM|LOW","confidence":<0-100>,"evidence":"exact file:line + code quote","minimal_fix_direction":"...","domain":"doc|security|migration|code|perf|test","requires_action":true}]\n` +
|
|
191
191
|
`<<<END_FINDINGS_JSON>>>\n` +
|
|
192
192
|
`Use an empty array [] between the sentinels if there are no real bugs.`
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
// Stable per-batch names — NO `$$`: in a JS template string `$$` is the literal two chars, which the
|
|
194
|
+
// Write tool keeps verbatim but bash expands to a per-subshell PID → the launch/poll/extract Bash calls
|
|
195
|
+
// each saw a DIFFERENT path and never agreed (observed live: poll grepped a nonexistent PID-named file
|
|
196
|
+
// forever). firstCardId is unique per batch; the file is /tmp scratch truncated by `>` on launch.
|
|
197
|
+
const codexTaskFile = `/tmp/codextask-batch-${a.firstCardId || 'batch'}.txt`
|
|
198
|
+
const codexReviewFile = `/tmp/codexreview-batch-${a.firstCardId || 'batch'}.md`
|
|
195
199
|
const codexPrompt =
|
|
196
200
|
`You are a THIN RELAY around the Codex companion — do NOT review or investigate code yourself, do NOT grep/sed/Read source to "confirm" findings (Codex's findings are authoritative and already FP-validated). Your ONLY job: launch Codex, wait, extract its JSON, return it.\n\n` +
|
|
197
201
|
`The companion script is ALREADY CONFIRMED PRESENT at:\n ${codexScriptPath}\n\n` +
|
|
@@ -199,11 +203,17 @@ const codexPrompt =
|
|
|
199
203
|
` 1. Write the task text (everything after "TASK PROMPT:" at the end of this message) to ${codexTaskFile} using the Write tool — do NOT inline it into the shell command (avoids quote breakage).\n` +
|
|
200
204
|
` 2. Launch Codex in the BACKGROUND (run_in_background:true — a sync run hits the Bash timeout):\n` +
|
|
201
205
|
` node "${codexScriptPath}" task "$(cat ${codexTaskFile})" --cwd "${a.worktreePath || '.'}" > ${codexReviewFile} 2>&1\n` +
|
|
202
|
-
` 3. POLL ${codexReviewFile} (BashOutput / repeated reads) until it contains
|
|
206
|
+
` 3. POLL ${codexReviewFile} (BashOutput / repeated reads) until it contains ANY of these — whichever appears FIRST ends the poll (do NOT keep waiting the full window once one is present):\n` +
|
|
207
|
+
` • "<<<END_FINDINGS_JSON>>>" → Codex emitted the JSON block (success path)\n` +
|
|
208
|
+
` • "Turn completed." → the companion's reliable end-of-run marker — Codex FINISHED even if it wrote prose instead of the block\n` +
|
|
209
|
+
` • "CODEX_NOT_FOUND" → companion missing\n` +
|
|
210
|
+
` • the full 10-minute window has elapsed\n` +
|
|
203
211
|
` 4. Extract the findings with this EXACT command (deterministic — skips the [codex] trace + truncated echo, takes the LAST complete sentinel pair):\n` +
|
|
204
212
|
` awk '/<<<FINDINGS_JSON>>>/{c=1;b="";next}/<<<END_FINDINGS_JSON>>>/{c=0;last=b;next}c{b=b $0 ORS}END{printf "%s",last}' ${codexReviewFile}\n` +
|
|
205
|
-
`
|
|
206
|
-
`
|
|
213
|
+
` 5. Decide the return — do NOT re-verify, re-grep, or Read any source file:\n` +
|
|
214
|
+
` • awk output is a NON-EMPTY JSON array → parse it and return it verbatim as \`findings\`, codexAvailable:true (its keys already match the schema).\n` +
|
|
215
|
+
` • awk output is EMPTY but the file has "Turn completed." (or "CODEX_NOT_FOUND") → Codex finished WITHOUT the sentinel block (e.g. it wrote a prose report). Return codexAvailable:false, findings:[] and EXIT NOW — do NOT wait the rest of the window, and do NOT parse findings out of the prose (a prose-only completion routes to the code-reviewer fallback; never risk dropping a real finding by guessing).\n` +
|
|
216
|
+
` • none of the markers ever appeared after the FULL 10-minute window → codexAvailable:false. NEVER set false because a single poll returned slowly.\n\n` +
|
|
207
217
|
`TASK PROMPT:\n${codexReviewTask}`
|
|
208
218
|
|
|
209
219
|
const docPrompt =
|
|
@@ -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
|
|
|
@@ -942,6 +957,26 @@ function planActions(state) {
|
|
|
942
957
|
},
|
|
943
958
|
});
|
|
944
959
|
}
|
|
960
|
+
if (state.i18nEnabled && state.i18nPrecommitSnippetNeeded) {
|
|
961
|
+
const i18nCmd = I18nGate.commandForCwd(state.cwd).replace(/\s+\.$/, '');
|
|
962
|
+
const snippet = [
|
|
963
|
+
' commands:',
|
|
964
|
+
' i18n:',
|
|
965
|
+
' glob: "*.{js,jsx,ts,tsx,mjs,cjs}"',
|
|
966
|
+
` run: sh -c 'if [ -f eslint.i18n.config.mjs ] || [ -f .eslintrc.i18n.json ]; then exec ${i18nCmd} "$@"; fi' _ {staged_files}`,
|
|
967
|
+
].join('\n');
|
|
968
|
+
actions.push({
|
|
969
|
+
key: 'i18n-precommit-snippet',
|
|
970
|
+
label: 'Add the i18n anti-hardcoded gate to your lefthook pre-commit (manual — snippet shown)',
|
|
971
|
+
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.',
|
|
972
|
+
autoOk: false, // never mutates a user-owned lefthook.yml — surfaces the snippet only
|
|
973
|
+
run: async () => {
|
|
974
|
+
UI.info('Add this under your `pre-commit:` block in lefthook.yml (merge into the existing `commands:` map):');
|
|
975
|
+
UI.info(snippet);
|
|
976
|
+
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.');
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
}
|
|
945
980
|
|
|
946
981
|
// External-tool version currency (since v4.38.0). One non-blocking upgrade
|
|
947
982
|
// 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;
|