@techninja/clearstack 0.4.3 → 0.4.5

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/lib/check.js CHANGED
@@ -6,15 +6,41 @@
6
6
 
7
7
  import { existsSync, readdirSync } from 'node:fs';
8
8
  import { resolve } from 'node:path';
9
- import { checkFileLines, runCmd, countFiles, checkImports } from './spec-utils.js';
9
+ import { runCmd } from './spec-utils.js';
10
+ import { countFiles, checkFileLines, checkImports } from './spec-scan.js';
10
11
  import { checkI18n } from './spec-i18n.js';
11
12
  import { loadConfig, buildCmds, detectRunner } from './spec-config.js';
12
13
 
13
- export { checkFileLines, runCmd, countFiles, findFiles, elapsed, checkImports } from './spec-utils.js';
14
+ export { runCmd, elapsed } from './spec-utils.js';
15
+ export { findFiles, countFiles, checkFileLines, checkImports } from './spec-scan.js';
14
16
  export { checkI18n } from './spec-i18n.js';
15
17
  export { loadConfig, buildCmds, detectRunner } from './spec-config.js';
16
18
 
17
- /** @typedef {{ key: string, name: string, parent?: string, run: (opts?: object) => boolean }} Check */
19
+ /**
20
+ * Load project-level spec extensions from `clearstack.spec.js` in the project root.
21
+ * The file may export a default array of Check objects. Any check whose key matches
22
+ * a built-in check replaces it; new keys are appended.
23
+ * @param {string} projectDir
24
+ * @returns {Promise<Check[]>}
25
+ */
26
+ async function loadExtensions(projectDir) {
27
+ const extPath = resolve(projectDir, 'clearstack.spec.js');
28
+ if (!existsSync(extPath)) return [];
29
+ try {
30
+ const mod = await import(extPath);
31
+ const exts = mod.default ?? [];
32
+ if (!Array.isArray(exts)) {
33
+ console.warn('⚠ clearstack.spec.js default export must be an array — extensions ignored');
34
+ return [];
35
+ }
36
+ return exts;
37
+ } catch (e) {
38
+ console.warn(`⚠ Failed to load clearstack.spec.js: ${e.message}`);
39
+ return [];
40
+ }
41
+ }
42
+
43
+ /** @typedef {{ key: string, name: string, parent?: string, watchExts?: string[], run: (opts?: object) => (boolean | import('./spec-utils.js').CheckResult | Promise<import('./spec-utils.js').CheckResult>) }} Check */
18
44
 
19
45
  /** Find all jsconfig.json files — main config + subdirectories. */
20
46
  function findTypeConfigs(dir, runner) {
@@ -28,42 +54,47 @@ function findTypeConfigs(dir, runner) {
28
54
  }
29
55
  return configs.map((c) => ({
30
56
  key: c.key, name: `JSDoc types — ${c.label}`, parent: 'types',
31
- run: () => runCmd(`Types (${c.label})`, `${runner} tsc --project ${c.path}`, dir).pass,
57
+ run: (o) => runCmd(`Types (${c.label})`, `${runner} tsc --project ${c.path} --noEmit`, dir, undefined, o),
32
58
  }));
33
59
  }
34
60
 
35
61
  /**
36
62
  * Build the unified checks array. Children have a `parent` key.
37
- * @returns {Check[]}
63
+ * Extensions from clearstack.spec.js are merged in: same key = replace, new key = append.
64
+ * @returns {Promise<Check[]>}
38
65
  */
39
- export function buildChecks(dir, cfg, cmds) {
66
+ export async function buildChecks(dir, cfg, cmds) {
40
67
  const js = () => countFiles(dir, ['.js'], cfg.ignore);
41
68
  const css = () => countFiles(dir, ['.css'], cfg.ignore);
42
69
  const md = () => countFiles(dir, ['.md'], cfg.ignore);
43
70
  const runner = detectRunner(dir);
44
- return [
71
+ const builtin = [
45
72
  { key: 'es', name: 'ESLint', parent: 'lint',
46
- run: () => runCmd('ESLint', cmds.lint, dir, `${js()} files`).pass },
73
+ run: (o) => runCmd('ESLint', cmds.lint, dir, `${js()} files`, o) },
47
74
  { key: 'css', name: 'Stylelint', parent: 'lint',
48
- run: () => runCmd('Stylelint', cmds.stylelint, dir, `${css()} files`).pass },
75
+ run: (o) => runCmd('Stylelint', cmds.stylelint, dir, `${css()} files`, o) },
49
76
  { key: 'md', name: 'Markdown lint', parent: 'lint',
50
- run: () => runCmd('Markdown', cmds.mdlint, dir, `${md()} files`).pass },
77
+ run: (o) => runCmd('Markdown', cmds.mdlint, dir, `${md()} files`, o) },
51
78
  { key: 'prettier', name: 'Prettier', parent: 'format',
52
- run: () => runCmd('Prettier', cmds.prettier, dir, `${js()} files`).pass },
79
+ run: (o) => runCmd('Prettier', cmds.prettier, dir, `${js()} files`, o) },
53
80
  { key: 'lines', name: `Code (max ${cfg.codeMax} lines)`, parent: 'code',
54
- run: () => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`, { exclude: cfg.testPattern }).pass },
81
+ run: (o) => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`, { exclude: cfg.testPattern, ...o }) },
55
82
  { key: 'i18n', name: 'i18n readiness', parent: 'code',
56
- run: (runOpts) => checkI18n(dir, cfg.ignore, 'i18n readiness', runOpts).pass },
83
+ run: (o) => checkI18n(dir, cfg.ignore, 'i18n readiness', o) },
57
84
  { key: 'tests', name: `Tests (max ${cfg.testMax} lines)`,
58
- run: () => checkFileLines(dir, cfg.codeExt, cfg.testMax, cfg.ignore, `Tests (max ${cfg.testMax} lines)`, { include: cfg.testPattern }).pass },
85
+ run: (o) => checkFileLines(dir, cfg.codeExt, cfg.testMax, cfg.ignore, `Tests (max ${cfg.testMax} lines)`, { include: cfg.testPattern, ...o }) },
59
86
  { key: 'docs', name: `Docs (max ${cfg.docsMax} lines)`,
60
- run: () => checkFileLines(dir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`).pass },
87
+ run: (o) => checkFileLines(dir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`, o) },
61
88
  { key: 'imports', name: 'Import map aliases (no ../ imports)',
62
- run: () => checkImports(dir, cfg.ignore, 'Import map aliases (no ../ imports)').pass },
89
+ run: (o) => checkImports(dir, cfg.ignore, 'Import map aliases (no ../ imports)', o) },
63
90
  ...findTypeConfigs(dir, runner),
64
91
  { key: 'audit', name: 'Security audit',
65
- run: () => runCmd('Security audit', cmds.audit, dir).pass },
92
+ run: (o) => runCmd('Security audit', cmds.audit, dir, undefined, o) },
66
93
  ];
94
+ const extensions = await loadExtensions(dir);
95
+ if (!extensions.length) return builtin;
96
+ const extKeys = new Set(extensions.map((e) => e.key));
97
+ return [...builtin.filter((c) => !extKeys.has(c.key)), ...extensions];
67
98
  }
68
99
 
69
100
  /** Resolve a scope like 'lint', 'lint es', or 'code' to runnable check(s). */
@@ -88,7 +119,7 @@ export function parentKeys(checks) {
88
119
  export async function check(projectDir, scope, opts) {
89
120
  const cfg = loadConfig(projectDir);
90
121
  const cmds = buildCmds(projectDir);
91
- const checks = buildChecks(projectDir, cfg, cmds);
122
+ const checks = await buildChecks(projectDir, cfg, cmds);
92
123
 
93
124
  if (scope && scope !== 'all') {
94
125
  const matched = resolveChecks(checks, scope);
@@ -98,14 +129,16 @@ export async function check(projectDir, scope, opts) {
98
129
  console.log(`Available: ${[...tops, ...parentKeys(checks)].join(', ')}`);
99
130
  process.exit(1);
100
131
  }
101
- const ok = matched.every((c) => c.run(opts));
132
+ const results = await Promise.all(matched.map((c) => c.run(opts)));
133
+ const ok = results.every((r) => typeof r === 'boolean' ? r : r.pass);
102
134
  if (!ok) process.exit(1);
103
135
  return;
104
136
  }
105
137
 
106
138
  console.log('🔍 Clearstack compliance checking now... 💙\n');
107
- const results = checks.map((c) => c.run(opts));
108
- const passed = results.filter(Boolean).length;
139
+ const results = await Promise.all(checks.map((c) => c.run(opts)));
140
+ const passes = results.map((r) => typeof r === 'boolean' ? r : r.pass);
141
+ const passed = passes.filter(Boolean).length;
109
142
  console.log(`\n${'='.repeat(40)}`);
110
143
  console.log(`${passed}/${results.length} checks passed.`);
111
144
  if (passed < results.length) process.exit(1);
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Dev server child process manager for spec watch dashboard.
3
+ * Spawns the server, captures output, and maintains a status row
4
+ * that the dashboard can render without any extra logic.
5
+ * @module lib/server-proc
6
+ */
7
+
8
+ import { spawn, execSync } from 'node:child_process';
9
+ import { existsSync } from 'node:fs';
10
+ import { resolve } from 'node:path';
11
+
12
+ /**
13
+ * Detect the dev server command. Prefers SPEC_SERVER_CMD from env,
14
+ * then probes common entry points. Returns null for static projects.
15
+ * @param {string} dir @param {object} cfg @returns {string|null}
16
+ */
17
+ export function detectServerCmd(dir, cfg) {
18
+ if (cfg.serverCmd) return cfg.serverCmd;
19
+ for (const entry of ['src/server.js', 'server.js', 'index.js']) {
20
+ if (existsSync(resolve(dir, entry))) return `node --watch ${entry}`;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /**
26
+ * Kill whatever process is listening on a given port.
27
+ * No-ops silently if nothing is found.
28
+ * @param {number} port
29
+ */
30
+ export function killPort(port) {
31
+ try {
32
+ const pid = execSync(`lsof -ti tcp:${port}`, { encoding: 'utf-8' }).trim();
33
+ if (pid) execSync(`kill ${pid}`);
34
+ } catch { /* nothing listening, or kill already gone */ }
35
+ }
36
+
37
+ /** @typedef {'starting'|'running'|'restarting'|'crashed'|'disabled'} ServerStatus */
38
+
39
+ /**
40
+ * @typedef {object} ServerRow
41
+ * @property {'server'} key
42
+ * @property {string} label
43
+ * @property {ServerStatus} status
44
+ * @property {boolean|null} pass
45
+ * @property {string} detail
46
+ * @property {string} [_errLine]
47
+ * @property {() => void} kill
48
+ */
49
+
50
+ /** Lines from Node --watch we don't want to surface as the detail. */
51
+ const NOISE = /^Debugger|^Waiting for|^\s*$/;
52
+
53
+ /**
54
+ * Spawn the dev server and return a live ServerRow.
55
+ * The row object is mutated in place as output arrives — the dashboard
56
+ * just reads it on each render cycle.
57
+ *
58
+ * @param {string} cmd e.g. 'node --watch src/server.js'
59
+ * @param {string} cwd project root
60
+ * @param {object} env merged into process.env for the child
61
+ * @param {() => void} onUpdate called whenever status/detail changes
62
+ * @returns {ServerRow}
63
+ */
64
+ export function spawnServer(cmd, cwd, env, onUpdate) {
65
+ /** @type {ServerRow} */
66
+ const row = {
67
+ key: 'server',
68
+ label: 'server',
69
+ status: 'starting',
70
+ pass: null,
71
+ detail: 'starting…',
72
+ kill: () => {},
73
+ };
74
+
75
+ const [bin, ...args] = cmd.split(/\s+/);
76
+ const child = spawn(bin, args, {
77
+ cwd,
78
+ env: { ...process.env, ...env, FORCE_COLOR: '0' },
79
+ stdio: ['ignore', 'pipe', 'pipe'],
80
+ });
81
+
82
+ row.kill = () => child.kill('SIGTERM');
83
+
84
+ /**
85
+ *
86
+ */
87
+ function onLine(line, isStderr) {
88
+ line = line.trim();
89
+ if (!line || NOISE.test(line)) return;
90
+
91
+ if (/restarting/i.test(line)) {
92
+ row.status = 'restarting';
93
+ row.pass = null;
94
+ row.detail = 'restarting…';
95
+ onUpdate();
96
+ return;
97
+ }
98
+ // Errors on stderr always override — don't let a stale URL persist
99
+ if (isStderr || /error|EADDR|EACCES|ENOENT/i.test(line)) {
100
+ row.status = 'crashed';
101
+ row.pass = false;
102
+ // Prefer 'Error: ...' lines; only upgrade, never downgrade
103
+ const isRich = /^Error:/i.test(line);
104
+ if (!row._errLine || (isRich && !/^Error:/i.test(row._errLine))) {
105
+ row._errLine = line.slice(0, 60);
106
+ row.detail = row._errLine;
107
+ onUpdate();
108
+ }
109
+ return;
110
+ }
111
+ if (/https?:\/\//i.test(line) || /listening|started|ready/i.test(line)) {
112
+ row.status = 'running';
113
+ row.pass = true;
114
+ row.detail = line;
115
+ onUpdate();
116
+ return;
117
+ }
118
+ if (row.status !== 'running') {
119
+ row.detail = line.slice(0, 60);
120
+ onUpdate();
121
+ }
122
+ }
123
+
124
+ child.stdout?.setEncoding('utf-8');
125
+ child.stderr?.setEncoding('utf-8');
126
+ /** @type {Array<[import('stream').Readable|null, boolean]>} */
127
+ const streams = [[child.stdout, false], [child.stderr, true]];
128
+ for (const [stream, isStderr] of streams) {
129
+ if (!stream) continue;
130
+ let buf = '';
131
+ stream.on('data', (chunk) => {
132
+ buf += chunk;
133
+ const lines = buf.split('\n');
134
+ buf = lines.pop();
135
+ for (const l of lines) onLine(l, isStderr);
136
+ });
137
+ }
138
+
139
+ child.on('close', (code, signal) => {
140
+ if (signal === 'SIGTERM') return;
141
+ row.status = 'crashed';
142
+ row.pass = false;
143
+ if (!row.detail || row.detail === 'starting…' || row.detail === 'restarting…') {
144
+ row.detail = `exited (code ${code ?? signal})`;
145
+ }
146
+ onUpdate();
147
+ });
148
+
149
+ return row;
150
+ }
@@ -35,6 +35,8 @@ export function loadConfig(projectDir) {
35
35
  const local = existsSync(localPath) ? parseEnv(readFileSync(localPath, 'utf-8')) : {};
36
36
  const env = { ...base, ...local };
37
37
  return {
38
+ serverCmd: env.SPEC_SERVER_CMD || null,
39
+ rawEnv: env,
38
40
  codeMax: parseInt(env.SPEC_CODE_MAX_LINES) || 150,
39
41
  testMax: parseInt(env.SPEC_TEST_MAX_LINES) || 300,
40
42
  docsMax: parseInt(env.SPEC_DOCS_MAX_LINES) || 500,
@@ -45,14 +47,56 @@ export function loadConfig(projectDir) {
45
47
  };
46
48
  }
47
49
 
48
- /** Build check commands for the detected package manager. */
49
- export function buildCmds(projectDir) {
50
+ /**
51
+ * Default ext→check-key mapping for built-ins.
52
+ * Extensions override this by declaring a `watchExts` array on their check object.
53
+ * @type {Record<string, string[]>}
54
+ */
55
+ const EXT_DEFAULTS = {
56
+ '.js': ['lines', 'es', 'prettier', 'frontend', 'imports', 'i18n'],
57
+ '.mjs': ['lines', 'es', 'frontend', 'imports'],
58
+ '.css': ['lines', 'css'],
59
+ '.md': ['docs', 'md'],
60
+ '.json': ['i18n'],
61
+ '.php': [],
62
+ };
63
+
64
+ /** Checks that run on pure file reads — no subprocess, sub-millisecond. */
65
+ export const FAST_CHECKS = new Set(['lines', 'docs', 'imports', 'i18n', 'tests']);
66
+
67
+ /**
68
+ * Build ext→keys map, merging EXT_DEFAULTS with `watchExts` declared on extension checks.
69
+ * @param {object[]} checks
70
+ * @returns {(ext: string) => string[]}
71
+ */
72
+ export function makeExtMap(checks) {
73
+ const map = new Map(Object.entries(EXT_DEFAULTS).map(([k, v]) => [k, new Set(v)]));
74
+ for (const c of checks) {
75
+ if (!c.watchExts) continue;
76
+ for (const ext of c.watchExts) {
77
+ if (!map.has(ext)) map.set(ext, new Set());
78
+ map.get(ext).add(c.key);
79
+ }
80
+ }
81
+ return (ext) => [...(map.get(ext) ?? [])];
82
+ }
83
+
84
+ /**
85
+ * @param {string} projectDir
86
+ * @param {{ watch?: boolean }} [opts]
87
+ */
88
+ export function buildCmds(projectDir, opts) {
50
89
  const runner = detectRunner(projectDir);
51
- // pnpm 9.x audit hits a retired npm endpoint (410). Use --ignore-registry-errors
52
- // to avoid failing on registry issues outside the user's control.
53
90
  const audit = runner === 'pnpm exec'
54
91
  ? 'pnpm audit --prod --ignore-registry-errors'
55
92
  : 'npm audit --omit=dev';
93
+ if (opts?.watch) return {
94
+ lint: `${runner} eslint --config .configs/eslint.config.js .`,
95
+ stylelint: `${runner} stylelint --config .configs/.stylelintrc.json "src/**/*.css"`,
96
+ prettier: `${runner} prettier --config .configs/.prettierrc --check src scripts`,
97
+ mdlint: `${runner} markdownlint-cli2 --config .configs/.markdownlint.jsonc "docs/**/*.md" "*.md"`,
98
+ audit,
99
+ };
56
100
  return {
57
101
  lint: `${runner} eslint --config .configs/eslint.config.js . --fix`,
58
102
  stylelint: `${runner} stylelint --config .configs/.stylelintrc.json "src/**/*.css" --fix`,
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Locale file parsing helpers for i18n spec check.
3
+ * @module lib/spec-i18n-locales
4
+ */
5
+
6
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ import { resolve } from 'node:path';
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ /**
14
+ * Try to run `npx hybrids extract ./src` and parse the JSON output.
15
+ * @param {string} root
16
+ * @returns {Promise<Record<string, object>|null>}
17
+ */
18
+ export async function tryHybridsExtract(root) {
19
+ const srcDir = resolve(root, 'src');
20
+ if (!existsSync(srcDir)) return null;
21
+ try {
22
+ const { stdout } = await execFileAsync('npx', ['hybrids', 'extract', './src'], {
23
+ cwd: root,
24
+ encoding: 'utf-8',
25
+ timeout: 30000,
26
+ });
27
+ return JSON.parse(stdout);
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Parse locale keys from a .js file that calls localize(lang, {...}).
35
+ * Extracts top-level string keys from the object literal.
36
+ * @param {string} filePath
37
+ * @returns {Set<string>}
38
+ */
39
+ export function parseLocaleJs(filePath) {
40
+ const src = readFileSync(filePath, 'utf-8');
41
+ const keys = new Set();
42
+ const re = /^\s*(['"])(.*?)\1\s*:/gm;
43
+ let m;
44
+ while ((m = re.exec(src)) !== null) {
45
+ const key = m[2].replace(/\\'/g, "'").replace(/\\"/g, '"');
46
+ keys.add(key);
47
+ }
48
+ return keys;
49
+ }
50
+
51
+ /**
52
+ * Parse locale keys from a .json file.
53
+ * @param {string} filePath
54
+ * @returns {Set<string>}
55
+ */
56
+ export function parseLocaleJson(filePath) {
57
+ try {
58
+ const data = JSON.parse(readFileSync(filePath, 'utf-8'));
59
+ return new Set(Object.keys(data));
60
+ } catch {
61
+ return new Set();
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Find locale files and return { lang, keys } for each.
67
+ * @param {string} localesDir
68
+ * @returns {Array<{lang: string, keys: Set<string>, file: string}>}
69
+ */
70
+ export function loadLocales(localesDir) {
71
+ if (!localesDir) return [];
72
+ const files = readdirSync(localesDir);
73
+ const locales = [];
74
+ for (const f of files) {
75
+ if (f === 'overrides.json' || f === 'en.json') continue;
76
+ const full = resolve(localesDir, f);
77
+ if (f.endsWith('.json')) {
78
+ const lang = f.replace(/\.json$/, '');
79
+ locales.push({ lang, keys: parseLocaleJson(full), file: f });
80
+ } else if (f.endsWith('.js') && !f.includes('init')) {
81
+ const lang = f.replace(/\.js$/, '');
82
+ locales.push({ lang, keys: parseLocaleJs(full), file: f });
83
+ }
84
+ }
85
+ return locales;
86
+ }
package/lib/spec-i18n.js CHANGED
@@ -1,24 +1,42 @@
1
1
  /**
2
- * i18n readiness check — detects patterns, locale files, hardcoded prose density.
2
+ * i18n readiness check — uses Hybrids extract when available, falls back to regex.
3
3
  * Always passes (informational). Surfaces coverage gaps without blocking the build.
4
4
  * @module lib/spec-i18n
5
5
  */
6
6
 
7
- import { readFileSync, readdirSync, existsSync } from 'node:fs';
7
+ import { readFileSync, existsSync } from 'node:fs';
8
8
  import { resolve } from 'node:path';
9
- import { findFiles, elapsed } from './spec-utils.js';
9
+ import { elapsed } from './spec-utils.js';
10
+ import { findFiles } from './spec-scan.js';
11
+ import { tryHybridsExtract, loadLocales, parseLocaleJson } from './spec-i18n-locales.js';
10
12
 
11
13
  /** @typedef {import('./spec-utils.js').CheckResult} CheckResult */
12
14
 
13
- const PROSE_RE = />\s*[A-Z][a-z]{2,}[^<$`\\]{3,}\s*[<$]/g;
14
15
  const T_RE = /\bt\s*\(/;
15
16
  const MSG_RE = /\bmsg`/;
16
17
  const LOC_RE = /\blocalize\s*\(/;
17
- const INIT_RE = /loadLocale|localize\s*\(/;
18
- const TPL_RE = /html`[\s\S]*?`/g;
19
18
 
20
- /** Strip leading > and trailing <$ from a prose match, trim. */
21
- const cleanHit = (h) => h.replace(/^>\s*/, '').replace(/\s*[<$]$/, '').trim();
19
+ /**
20
+ * Extract keys from the DEFAULTS object in a t()-based i18n.js file.
21
+ * @param {string} root
22
+ * @returns {string[]|null}
23
+ */
24
+ function extractTDefaults(root) {
25
+ const candidates = ['src/utils/i18n.js', 'src/i18n.js', 'utils/i18n.js'];
26
+ for (const rel of candidates) {
27
+ const p = resolve(root, rel);
28
+ if (!existsSync(p)) continue;
29
+ const src = readFileSync(p, 'utf-8');
30
+ const block = src.match(/const DEFAULTS\s*=\s*\{([\s\S]*?)\};/);
31
+ if (!block) continue;
32
+ const keys = [];
33
+ const re = /['"]([\.\w]+)['"]\s*:/g;
34
+ let m;
35
+ while ((m = re.exec(block[1])) !== null) keys.push(m[1]);
36
+ if (keys.length) return keys;
37
+ }
38
+ return null;
39
+ }
22
40
 
23
41
  /**
24
42
  * Check i18n readiness — informational, always passes.
@@ -26,10 +44,11 @@ const cleanHit = (h) => h.replace(/^>\s*/, '').replace(/\s*[<$]$/, '').trim();
26
44
  * @param {string[]} ignoreDirs
27
45
  * @param {string} label
28
46
  * @param {{ quiet?: boolean, verbose?: boolean }} [opts]
29
- * @returns {CheckResult}
47
+ * @returns {Promise<CheckResult>}
30
48
  */
31
- export function checkI18n(root, ignoreDirs, label, opts) {
49
+ export async function checkI18n(root, ignoreDirs, label, opts) {
32
50
  const start = performance.now();
51
+
33
52
  const srcFiles = findFiles(root, ['.js'], ignoreDirs, root).filter(
34
53
  (f) =>
35
54
  f.startsWith('src/') &&
@@ -38,39 +57,12 @@ export function checkI18n(root, ignoreDirs, label, opts) {
38
57
  !f.endsWith('.test.js'),
39
58
  );
40
59
 
41
- let usesT = 0,
42
- usesMsg = 0,
43
- usesLocalize = 0,
44
- hasInit = false,
45
- hardcodedHits = 0,
46
- templateFiles = 0;
47
- /** @type {Array<{file: string, text: string, line: number}>} */
48
- const verboseHits = [];
49
-
60
+ let usesT = 0, usesMsg = 0, usesLocalize = 0;
50
61
  for (const file of srcFiles) {
51
62
  const src = readFileSync(resolve(root, file), 'utf-8');
52
63
  if (T_RE.test(src)) usesT++;
53
64
  if (MSG_RE.test(src)) usesMsg++;
54
65
  if (LOC_RE.test(src)) usesLocalize++;
55
- if (/router\/index|app.*init|main\.js/.test(file) && INIT_RE.test(src)) hasInit = true;
56
- const templates = src.match(TPL_RE);
57
- if (templates) {
58
- templateFiles++;
59
- for (const tpl of templates) {
60
- const hits = tpl.match(PROSE_RE);
61
- if (hits) {
62
- hardcodedHits += hits.length;
63
- if (opts?.verbose) {
64
- for (const h of hits) {
65
- const text = cleanHit(h);
66
- const idx = src.indexOf(text);
67
- const line = idx >= 0 ? src.slice(0, idx).split('\n').length : 0;
68
- verboseHits.push({ file, text, line });
69
- }
70
- }
71
- }
72
- }
73
- }
74
66
  }
75
67
 
76
68
  const localesDir = existsSync(resolve(root, 'src/locales'))
@@ -79,11 +71,10 @@ export function checkI18n(root, ignoreDirs, label, opts) {
79
71
  ? resolve(root, 'locales')
80
72
  : null;
81
73
 
82
- const localeFiles = localesDir
83
- ? readdirSync(localesDir).filter((f) => f.endsWith('.json') && f !== 'overrides.json')
84
- : [];
85
- const languages = localeFiles.map((f) => f.replace(/\.json$/, '').replace(/^overrides\./, ''));
86
74
  const hasOverrides = localesDir ? existsSync(resolve(localesDir, 'overrides.json')) : false;
75
+ const extracted = await tryHybridsExtract(root);
76
+ const extractedKeys = extracted ? Object.keys(extracted) : null;
77
+ const locales = loadLocales(localesDir);
87
78
 
88
79
  const patternParts = [
89
80
  usesT ? `t() \u00d7${usesT}` : '',
@@ -92,28 +83,35 @@ export function checkI18n(root, ignoreDirs, label, opts) {
92
83
  ].filter(Boolean);
93
84
 
94
85
  const time = elapsed(start);
95
- if (!opts?.quiet) {
96
- const icon = patternParts.length ? '\u2705' : '\u26a0\ufe0f ';
97
- const pattern = patternParts.length ? patternParts.join(', ') : 'none detected';
98
- const langs = languages.length ? languages.join(', ') : 'en only';
99
- const init = `${hasInit ? 'yes' : 'not detected'} \u00b7 Overrides: ${hasOverrides ? 'yes' : 'no'} \u00b7 Languages: ${langs}`;
100
- const unwrapped = hardcodedHits > 0 ? `\n Unwrapped: ~${hardcodedHits} prose strings across ${templateFiles} template files` : '';
101
- console.log(` ${icon} ${label} (${time})\n Pattern: ${pattern}\n Init: ${init}${unwrapped}`);
102
- if (opts?.verbose && verboseHits.length) {
103
- console.log('');
104
- const byFile = {};
105
- for (const v of verboseHits) {
106
- (byFile[v.file] ||= []).push(v);
107
- }
108
- for (const [file, entries] of Object.entries(byFile)) {
109
- console.log(` ${file}`);
110
- for (const e of entries) {
111
- const truncated = e.text.length > 60 ? e.text.slice(0, 60) + '\u2026' : e.text;
112
- console.log(` L${e.line || '?'} ${truncated}`);
113
- }
86
+ const coverage = [];
87
+ if (localesDir) {
88
+ // Key source priority: t() DEFAULTS + overrides → hybrids extract → overrides.json alone
89
+ const tKeys = extractTDefaults(root);
90
+ const overrideKeys = hasOverrides
91
+ ? [...parseLocaleJson(resolve(localesDir, 'overrides.json'))]
92
+ : [];
93
+ const masterKeys = tKeys
94
+ ? [...new Set([...tKeys, ...overrideKeys])]
95
+ : (extractedKeys ?? (overrideKeys.length ? overrideKeys : null));
96
+ if (masterKeys?.length && locales.length) {
97
+ for (const loc of locales) {
98
+ const translated = masterKeys.filter((k) => loc.keys.has(k)).length;
99
+ const pct = Math.round((translated / masterKeys.length) * 100);
100
+ const missing = masterKeys.length - translated;
101
+ coverage.push({ lang: loc.lang, pct, missing, total: masterKeys.length });
114
102
  }
115
103
  }
116
104
  }
117
105
 
118
- return { pass: true, label, time };
106
+ const coverageDetail = coverage.length
107
+ ? coverage.map((c) => `${c.lang} ${c.pct}%${c.missing ? ` (${c.missing} missing)` : ' ✓'}`).join(' · ')
108
+ : (locales.length === 0 ? 'en only' : '');
109
+
110
+ if (!opts?.quiet) {
111
+ const icon = patternParts.length ? '✅' : '⚠️ ';
112
+ const detail = coverageDetail || (patternParts.length ? patternParts.join(', ') : 'no i18n patterns detected');
113
+ console.log(` ${icon} ${label} — ${detail} (${time})`);
114
+ }
115
+
116
+ return { pass: true, label, time, detail: coverageDetail };
119
117
  }