baldart 4.50.0 → 4.52.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 +37 -0
- package/README.md +10 -3
- package/VERSION +1 -1
- package/framework/.claude/agents/REGISTRY.md +1 -0
- package/framework/.claude/agents/code-reviewer.md +16 -0
- package/framework/.claude/agents/coder.md +6 -0
- package/framework/.claude/agents/doc-reviewer.md +24 -0
- package/framework/.claude/agents/i18n-translator.md +56 -0
- package/framework/.claude/agents/qa-sentinel.md +10 -0
- package/framework/.claude/output-styles/terse-ultra.md +39 -0
- package/framework/.claude/skills/i18n/SKILL.md +109 -0
- package/framework/.claude/skills/i18n-adopt/SKILL.md +105 -0
- package/framework/.claude/skills/new/references/implement.md +14 -1
- package/framework/.claude/skills/prd/SKILL.md +12 -2
- package/framework/.claude/workflows/new-final-review.js +1 -1
- package/framework/.claude/workflows/new2.js +3 -0
- package/framework/AGENTS.md +4 -0
- package/framework/agents/i18n-protocol.md +120 -0
- package/framework/agents/index.md +2 -0
- package/framework/docs/I18N-LAYER.md +89 -0
- package/framework/routines/i18n-align.routine.yml +61 -0
- package/framework/routines/index.yml +11 -0
- package/framework/templates/baldart.config.template.yml +59 -0
- package/package.json +1 -1
- package/src/commands/configure.js +103 -0
- package/src/commands/doctor.js +58 -0
- package/src/commands/update.js +6 -0
- package/src/utils/i18n-gate.js +195 -0
package/src/commands/doctor.js
CHANGED
|
@@ -34,6 +34,7 @@ const GitHooks = require('../utils/githooks');
|
|
|
34
34
|
const LspInstaller = require('../utils/lsp-installer');
|
|
35
35
|
const GraphifyInstaller = require('../utils/graphify-installer');
|
|
36
36
|
const ToolchainInstaller = require('../utils/toolchain-installer');
|
|
37
|
+
const I18nGate = require('../utils/i18n-gate');
|
|
37
38
|
const ToolCurrency = require('../utils/tool-currency');
|
|
38
39
|
const CodexOrphans = require('../utils/codex-orphans');
|
|
39
40
|
const UpdateNotifier = require('../utils/update-notifier');
|
|
@@ -425,6 +426,29 @@ async function detectState(cwd, opts = {}) {
|
|
|
425
426
|
}
|
|
426
427
|
} catch (_) { /* never block doctor on toolchain probe */ }
|
|
427
428
|
|
|
429
|
+
// ---- i18n layer (since v4.52.0) ------------------------------------
|
|
430
|
+
// Detection-only backfill: the i18n framework is the consumer's, so doctor
|
|
431
|
+
// never installs it — it flags a missing context registry and a missing
|
|
432
|
+
// anti-hardcoded lint plugin (the only thing BALDART offers to install).
|
|
433
|
+
state.i18nEnabled = false;
|
|
434
|
+
state.i18nRegistryMissing = false;
|
|
435
|
+
state.i18nGateMissing = false;
|
|
436
|
+
try {
|
|
437
|
+
if (config && !config.__malformed && config.features && config.features.has_i18n === true) {
|
|
438
|
+
state.i18nEnabled = true;
|
|
439
|
+
const regPath = (config.paths && config.paths.i18n_registry) || '';
|
|
440
|
+
if (regPath && !fs.existsSync(path.join(cwd, regPath))) {
|
|
441
|
+
state.i18nRegistryMissing = true;
|
|
442
|
+
}
|
|
443
|
+
// Deterministic anti-hardcoded gate present? (standalone config + deps).
|
|
444
|
+
// Only meaningful for a JS/TS project — skip the gate check otherwise.
|
|
445
|
+
if (I18nGate.hasPackageJson(cwd)
|
|
446
|
+
&& (!I18nGate.isConfigPresent(cwd) || !I18nGate.depsInstalled(cwd))) {
|
|
447
|
+
state.i18nGateMissing = true;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
} catch (_) { /* never block doctor on i18n probe */ }
|
|
451
|
+
|
|
428
452
|
// ---- External-tool version currency (since v4.38.0) ----------------
|
|
429
453
|
// BALDART pins none of the external tools it installs (graphifyy via pipx,
|
|
430
454
|
// language servers via npm/system) — pipx/npm never auto-upgrade, so a
|
|
@@ -885,6 +909,40 @@ function planActions(state) {
|
|
|
885
909
|
});
|
|
886
910
|
}
|
|
887
911
|
|
|
912
|
+
// ---- i18n layer backfill (since v4.52.0) ----------------------------
|
|
913
|
+
// Detection-only: BALDART never installs the consumer's i18n framework. It
|
|
914
|
+
// offers to install the anti-hardcoded lint plugin and flags a missing
|
|
915
|
+
// context registry (which `/i18n` or the consumer scaffolds).
|
|
916
|
+
if (state.i18nEnabled && state.i18nGateMissing) {
|
|
917
|
+
const gateDeps = I18nGate.depsFor(state.cwd);
|
|
918
|
+
const gateFile = I18nGate.configFilename(state.cwd);
|
|
919
|
+
actions.push({
|
|
920
|
+
key: 'i18n-gate-setup',
|
|
921
|
+
label: `Set up the anti-hardcoded gate (${gateDeps.join(', ')} + ${gateFile})`,
|
|
922
|
+
why: `features.has_i18n is true but the deterministic anti-hardcoded gate is not fully set up (missing devDeps or ${gateFile}), so hardcoded user-facing strings are not blocked. Installs the gate dependencies and writes a STANDALONE ESLint config (era-matched to your ESLint; works on Biome projects; never touches your main lint config).`,
|
|
923
|
+
autoOk: false, // installs devDependencies; confirm
|
|
924
|
+
run: async () => {
|
|
925
|
+
const res = I18nGate.setup(state.cwd);
|
|
926
|
+
if (res.error) { UI.warning(`Gate setup incomplete: ${res.error}.`); return; }
|
|
927
|
+
if (res.installed) UI.success(`Installed ${gateDeps.join(', ')}.`);
|
|
928
|
+
if (res.config && res.config.status === 'written') UI.success(`Wrote ${res.config.file} (${res.era} ESLint config).`);
|
|
929
|
+
UI.success(`Anti-hardcoded gate ready: \`${I18nGate.commandForCwd(state.cwd)}\`.`);
|
|
930
|
+
UI.info('If i18n.lint_command is unset in baldart.config.yml, the gate runs by convention via the config file; `baldart configure` records it explicitly.');
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
if (state.i18nEnabled && state.i18nRegistryMissing) {
|
|
935
|
+
actions.push({
|
|
936
|
+
key: 'i18n-registry-missing',
|
|
937
|
+
label: 'i18n context registry not found — scaffold it with /i18n',
|
|
938
|
+
why: `features.has_i18n is true but the context registry at paths.i18n_registry does not exist yet. It is the SSOT for per-key translation context. Run the /i18n skill (or create the YAML) to start populating it; coder STEP 9 fills it as keys are added.`,
|
|
939
|
+
autoOk: false, // informational — no safe auto-create without project context
|
|
940
|
+
run: async () => {
|
|
941
|
+
UI.info('Run `/i18n` in your editor to audit + scaffold the registry, or create the YAML at the configured path. See framework/docs/I18N-LAYER.md.');
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
888
946
|
// External-tool version currency (since v4.38.0). One non-blocking upgrade
|
|
889
947
|
// action per managed tool confirmed behind upstream — the tool-dependency
|
|
890
948
|
// analogue of the `baldart` CLI's own UpdateNotifier. `unknown`/`current`
|
package/src/commands/update.js
CHANGED
|
@@ -1312,6 +1312,11 @@ async function update(options = {}, unknownArgs = []) {
|
|
|
1312
1312
|
const missingToolchainCmds = Object.keys((tpl.toolchain && tpl.toolchain.commands) || {})
|
|
1313
1313
|
.filter((k) => !(k in ((cur2.toolchain && cur2.toolchain.commands) || {})))
|
|
1314
1314
|
.map((k) => `toolchain.commands.${k}`);
|
|
1315
|
+
// `i18n:` (since v4.52.0) — same nested-block contract as `graph:` /
|
|
1316
|
+
// `toolchain:`. Diff its own scalar keys so a new option surfaces.
|
|
1317
|
+
const missingI18n = Object.keys(tpl.i18n || {})
|
|
1318
|
+
.filter((k) => !(k in (cur2.i18n || {})))
|
|
1319
|
+
.map((k) => `i18n.${k}`);
|
|
1315
1320
|
const allMissing = [
|
|
1316
1321
|
...missingPaths,
|
|
1317
1322
|
...missingFeatures,
|
|
@@ -1320,6 +1325,7 @@ async function update(options = {}, unknownArgs = []) {
|
|
|
1320
1325
|
...missingGraph.map((k) => `graph.${k}`),
|
|
1321
1326
|
...missingToolchain,
|
|
1322
1327
|
...missingToolchainCmds,
|
|
1328
|
+
...missingI18n,
|
|
1323
1329
|
];
|
|
1324
1330
|
if (allMissing.length) {
|
|
1325
1331
|
UI.newline();
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* i18n anti-hardcoded gate — BALDART-owned standalone ESLint config (since v4.52.0).
|
|
7
|
+
*
|
|
8
|
+
* The "no hardcoded user-facing strings" rule must be enforced DETERMINISTICALLY,
|
|
9
|
+
* regardless of the consumer's lint setup. The most-adopted rule
|
|
10
|
+
* (`eslint-plugin-i18next` `no-literal-string`) is framework-agnostic (it flags
|
|
11
|
+
* literal JSX text/attributes for next-intl / i18next / vue / any stack) BUT it is
|
|
12
|
+
* an ESLint plugin — a Biome-only toolchain (BALDART's default) does not run it,
|
|
13
|
+
* and wiring it into the user's own ESLint config is fragile.
|
|
14
|
+
*
|
|
15
|
+
* So BALDART writes a SEPARATE, self-contained config it owns and runs ESLint
|
|
16
|
+
* against it with an explicit `--config`. This never touches the user's main lint
|
|
17
|
+
* config, runs the i18n rule deterministically, and works on Biome projects (a
|
|
18
|
+
* dedicated ESLint invocation just for this gate).
|
|
19
|
+
*
|
|
20
|
+
* ESLint era matters: ESLint 9 uses FLAT config (`eslint.i18n.config.mjs`); ESLint
|
|
21
|
+
* 8 uses LEGACY rc config (`.eslintrc.i18n.json` + `--no-eslintrc`). We detect the
|
|
22
|
+
* consumer's installed/declared ESLint major and emit the matching pair so the
|
|
23
|
+
* gate command actually runs instead of erroring on every file. `mode: 'jsx-only'`
|
|
24
|
+
* is the default — it flags JSX text AND user-facing attributes (title /
|
|
25
|
+
* placeholder / alt / aria-label), matching the prose contract; non-JSX
|
|
26
|
+
* imperative strings (e.g. `toast("Saved")`) remain the code-reviewer backstop's
|
|
27
|
+
* domain. Verified against ESLint 9 + eslint-plugin-i18next: Bad.tsx (3 findings)
|
|
28
|
+
* fails, Good.tsx (t()) passes.
|
|
29
|
+
*/
|
|
30
|
+
const FLAT_CONFIG = 'eslint.i18n.config.mjs';
|
|
31
|
+
const LEGACY_CONFIG = '.eslintrc.i18n.json';
|
|
32
|
+
const MODE = 'jsx-only';
|
|
33
|
+
|
|
34
|
+
const FLAT_COMMAND = `npx eslint --config ${FLAT_CONFIG} .`;
|
|
35
|
+
const LEGACY_COMMAND = `npx eslint --no-eslintrc -c ${LEGACY_CONFIG} --ext .js,.jsx,.ts,.tsx .`;
|
|
36
|
+
// Default advertised command (flat / ESLint 9). `resolveLintCommand` picks the
|
|
37
|
+
// real one based on which config file is on disk.
|
|
38
|
+
const LINT_COMMAND = FLAT_COMMAND;
|
|
39
|
+
|
|
40
|
+
const FLAT_DEPS = ['eslint', 'eslint-plugin-i18next', 'typescript-eslint'];
|
|
41
|
+
const LEGACY_DEPS = ['eslint', 'eslint-plugin-i18next', '@typescript-eslint/parser'];
|
|
42
|
+
// Advertised set (flat). `setup` installs the era-correct one.
|
|
43
|
+
const DEV_DEPS = FLAT_DEPS;
|
|
44
|
+
|
|
45
|
+
const FLAT_BODY = `// BALDART-owned i18n anti-hardcoded gate (generated by \`baldart configure\` /
|
|
46
|
+
// \`baldart doctor\`, since v4.52.0). STANDALONE — this file does NOT affect your
|
|
47
|
+
// main ESLint setup. ESLint 9 flat config. Run via: ${FLAT_COMMAND}
|
|
48
|
+
//
|
|
49
|
+
// Fails when a user-facing string is hardcoded instead of routed through your i18n
|
|
50
|
+
// translation function. \`mode: '${MODE}'\` flags JSX text AND user-facing
|
|
51
|
+
// attributes (title/placeholder/alt/aria-label). Broaden to 'all' for non-JSX
|
|
52
|
+
// strings too, or add to \`ignores\` / the rule allowlist — BALDART writes this file
|
|
53
|
+
// only when absent and never overwrites your edits.
|
|
54
|
+
import i18next from 'eslint-plugin-i18next';
|
|
55
|
+
import tseslint from 'typescript-eslint';
|
|
56
|
+
|
|
57
|
+
export default [
|
|
58
|
+
{
|
|
59
|
+
files: ['**/*.{js,jsx,ts,tsx,mjs,cjs}'],
|
|
60
|
+
ignores: [
|
|
61
|
+
'**/node_modules/**', '**/dist/**', '**/build/**', '**/.next/**',
|
|
62
|
+
'**/out/**', '**/coverage/**', '**/*.test.*', '**/*.spec.*',
|
|
63
|
+
],
|
|
64
|
+
languageOptions: {
|
|
65
|
+
parser: tseslint.parser,
|
|
66
|
+
parserOptions: { ecmaFeatures: { jsx: true }, sourceType: 'module' },
|
|
67
|
+
},
|
|
68
|
+
plugins: { i18next },
|
|
69
|
+
rules: {
|
|
70
|
+
'i18next/no-literal-string': ['error', { mode: '${MODE}' }],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
const LEGACY_BODY = JSON.stringify({
|
|
77
|
+
root: true,
|
|
78
|
+
parser: '@typescript-eslint/parser',
|
|
79
|
+
parserOptions: { ecmaFeatures: { jsx: true }, sourceType: 'module' },
|
|
80
|
+
plugins: ['i18next'],
|
|
81
|
+
rules: { 'i18next/no-literal-string': ['error', { mode: MODE }] },
|
|
82
|
+
ignorePatterns: ['node_modules/', 'dist/', 'build/', '.next/', 'out/', 'coverage/', '*.test.*', '*.spec.*'],
|
|
83
|
+
}, null, 2) + '\n';
|
|
84
|
+
|
|
85
|
+
function hasPackageJson(cwd = process.cwd()) {
|
|
86
|
+
return fs.existsSync(path.join(cwd, 'package.json'));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readPkgDeps(cwd = process.cwd()) {
|
|
90
|
+
try {
|
|
91
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
92
|
+
return { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
93
|
+
} catch { return {}; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Detect the consumer's ESLint major. Prefers the installed version, then the
|
|
98
|
+
* declared range, then null (absent → a fresh install gets ESLint 9 → flat).
|
|
99
|
+
*/
|
|
100
|
+
function eslintMajor(cwd = process.cwd()) {
|
|
101
|
+
try {
|
|
102
|
+
const p = JSON.parse(fs.readFileSync(path.join(cwd, 'node_modules', 'eslint', 'package.json'), 'utf8'));
|
|
103
|
+
const m = parseInt(String(p.version).split('.')[0], 10);
|
|
104
|
+
if (!Number.isNaN(m)) return m;
|
|
105
|
+
} catch { /* not installed */ }
|
|
106
|
+
const deps = readPkgDeps(cwd);
|
|
107
|
+
if (deps.eslint) {
|
|
108
|
+
const m = String(deps.eslint).match(/(\d+)/);
|
|
109
|
+
if (m) return parseInt(m[1], 10);
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Legacy when an EXISTING ESLint 8 is detected; flat otherwise (incl. fresh). */
|
|
115
|
+
function useLegacy(cwd = process.cwd()) {
|
|
116
|
+
return eslintMajor(cwd) === 8;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function configFilename(cwd = process.cwd()) {
|
|
120
|
+
return useLegacy(cwd) ? LEGACY_CONFIG : FLAT_CONFIG;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function depsFor(cwd = process.cwd()) {
|
|
124
|
+
return useLegacy(cwd) ? LEGACY_DEPS : FLAT_DEPS;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** True when EITHER era's config file is on disk. */
|
|
128
|
+
function isConfigPresent(cwd = process.cwd()) {
|
|
129
|
+
return fs.existsSync(path.join(cwd, FLAT_CONFIG)) || fs.existsSync(path.join(cwd, LEGACY_CONFIG));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Write the era-correct standalone config — only when absent. Never overwrites. */
|
|
133
|
+
function writeConfig(cwd = process.cwd()) {
|
|
134
|
+
const legacy = useLegacy(cwd);
|
|
135
|
+
const file = legacy ? LEGACY_CONFIG : FLAT_CONFIG;
|
|
136
|
+
const p = path.join(cwd, file);
|
|
137
|
+
if (isConfigPresent(cwd)) return { status: 'exists', path: p, file };
|
|
138
|
+
fs.writeFileSync(p, legacy ? LEGACY_BODY : FLAT_BODY, 'utf8');
|
|
139
|
+
return { status: 'written', path: p, file };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** True when every era-correct gate dependency is declared in package.json. */
|
|
143
|
+
function depsInstalled(cwd = process.cwd()) {
|
|
144
|
+
const deps = readPkgDeps(cwd);
|
|
145
|
+
return depsFor(cwd).every((d) => d in deps);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Install the era-correct gate dev dependencies. Throws on failure. */
|
|
149
|
+
function installDeps(cwd = process.cwd()) {
|
|
150
|
+
execSync(`npm install -D ${depsFor(cwd).join(' ')}`, { cwd, stdio: 'ignore' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Full setup: install deps (if a package.json exists) + write the era-correct
|
|
155
|
+
* config. Returns a structured result; never throws (errors are reported).
|
|
156
|
+
*/
|
|
157
|
+
function setup(cwd = process.cwd()) {
|
|
158
|
+
const result = { installed: false, config: null, error: null, era: useLegacy(cwd) ? 'legacy' : 'flat' };
|
|
159
|
+
try {
|
|
160
|
+
if (hasPackageJson(cwd) && !depsInstalled(cwd)) {
|
|
161
|
+
installDeps(cwd);
|
|
162
|
+
result.installed = true;
|
|
163
|
+
}
|
|
164
|
+
result.config = writeConfig(cwd);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
result.error = e.message;
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve the command a gate runner should execute. Order: explicit
|
|
173
|
+
* `i18n.lint_command` from config → the on-disk standalone config (flat or
|
|
174
|
+
* legacy) by convention → null (gate SKIPs, never a silent pass).
|
|
175
|
+
*/
|
|
176
|
+
function resolveLintCommand(cwd = process.cwd(), config = null) {
|
|
177
|
+
const explicit = config && config.i18n && config.i18n.lint_command;
|
|
178
|
+
if (explicit) return explicit;
|
|
179
|
+
if (fs.existsSync(path.join(cwd, FLAT_CONFIG))) return FLAT_COMMAND;
|
|
180
|
+
if (fs.existsSync(path.join(cwd, LEGACY_CONFIG))) return LEGACY_COMMAND;
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The command configure should record for this project's ESLint era. */
|
|
185
|
+
function commandForCwd(cwd = process.cwd()) {
|
|
186
|
+
return useLegacy(cwd) ? LEGACY_COMMAND : FLAT_COMMAND;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
FLAT_CONFIG, LEGACY_CONFIG, MODE, FLAT_COMMAND, LEGACY_COMMAND, LINT_COMMAND,
|
|
191
|
+
FLAT_DEPS, LEGACY_DEPS, DEV_DEPS,
|
|
192
|
+
hasPackageJson, eslintMajor, useLegacy, configFilename, depsFor,
|
|
193
|
+
isConfigPresent, writeConfig, depsInstalled, installDeps, setup,
|
|
194
|
+
resolveLintCommand, commandForCwd,
|
|
195
|
+
};
|