i18nsmith 0.2.1 → 0.3.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/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/backup.d.ts.map +1 -1
- package/dist/commands/check.d.ts +25 -0
- package/dist/commands/check.d.ts.map +1 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/debug-patterns.d.ts.map +1 -1
- package/dist/commands/diagnose.d.ts.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/preflight.d.ts.map +1 -1
- package/dist/commands/rename.d.ts.map +1 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/scaffold-adapter.d.ts.map +1 -1
- package/dist/commands/scan.d.ts.map +1 -1
- package/dist/commands/sync.d.ts +1 -1
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/transform.d.ts.map +1 -1
- package/dist/commands/translate/index.d.ts.map +1 -1
- package/dist/index.js +2536 -107783
- package/dist/rename-suspicious.test.d.ts +2 -0
- package/dist/rename-suspicious.test.d.ts.map +1 -0
- package/dist/utils/diff-utils.d.ts +5 -0
- package/dist/utils/diff-utils.d.ts.map +1 -1
- package/dist/utils/errors.d.ts +8 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/locale-audit.d.ts +39 -0
- package/dist/utils/locale-audit.d.ts.map +1 -0
- package/dist/utils/preview.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/commands/audit.ts +18 -209
- package/src/commands/backup.ts +67 -63
- package/src/commands/check.ts +119 -68
- package/src/commands/config.ts +117 -95
- package/src/commands/debug-patterns.ts +25 -22
- package/src/commands/diagnose.ts +29 -26
- package/src/commands/init.ts +84 -79
- package/src/commands/install-hooks.ts +18 -15
- package/src/commands/preflight.ts +21 -13
- package/src/commands/rename.ts +86 -81
- package/src/commands/review.ts +81 -78
- package/src/commands/scaffold-adapter.ts +8 -4
- package/src/commands/scan.ts +61 -58
- package/src/commands/sync.ts +640 -203
- package/src/commands/transform.ts +46 -18
- package/src/commands/translate/index.ts +7 -4
- package/src/e2e.test.ts +34 -14
- package/src/integration.test.ts +86 -0
- package/src/rename-suspicious.test.ts +124 -0
- package/src/utils/diff-utils.ts +6 -0
- package/src/utils/errors.ts +34 -0
- package/src/utils/locale-audit.ts +219 -0
- package/src/utils/preview.test.ts +43 -0
- package/src/utils/preview.ts +2 -8
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import {
|
|
4
|
+
LocaleStore,
|
|
5
|
+
KeyValidator,
|
|
6
|
+
LocaleValidator,
|
|
7
|
+
SUSPICIOUS_KEY_REASON_DESCRIPTIONS,
|
|
8
|
+
type I18nConfig,
|
|
9
|
+
} from '@i18nsmith/core';
|
|
10
|
+
|
|
11
|
+
export interface AuditIssue {
|
|
12
|
+
key: string;
|
|
13
|
+
value: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
description: string;
|
|
16
|
+
suggestion?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface QualityIssue {
|
|
20
|
+
type: 'duplicate-value' | 'inconsistent-key' | 'orphaned-namespace';
|
|
21
|
+
description: string;
|
|
22
|
+
keys?: string[];
|
|
23
|
+
suggestion?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LocaleAuditResult {
|
|
27
|
+
locale: string;
|
|
28
|
+
totalKeys: number;
|
|
29
|
+
issues: AuditIssue[];
|
|
30
|
+
qualityIssues: QualityIssue[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LocaleAuditSummary {
|
|
34
|
+
results: LocaleAuditResult[];
|
|
35
|
+
totalIssues: number;
|
|
36
|
+
totalQualityIssues: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LocaleAuditContext {
|
|
40
|
+
config: I18nConfig;
|
|
41
|
+
projectRoot: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LocaleAuditOptions {
|
|
45
|
+
locales?: string[];
|
|
46
|
+
checkDuplicates?: boolean;
|
|
47
|
+
checkInconsistent?: boolean;
|
|
48
|
+
checkOrphaned?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runLocaleAudit(
|
|
52
|
+
context: LocaleAuditContext,
|
|
53
|
+
options: LocaleAuditOptions = {}
|
|
54
|
+
): Promise<LocaleAuditSummary> {
|
|
55
|
+
const { config, projectRoot } = context;
|
|
56
|
+
const localesDir = path.resolve(projectRoot, config.localesDir ?? 'locales');
|
|
57
|
+
const localeStore = new LocaleStore(localesDir, {
|
|
58
|
+
sortKeys: config.locales?.sortKeys ?? 'alphabetical',
|
|
59
|
+
});
|
|
60
|
+
const keyValidator = new KeyValidator(config.sync?.suspiciousKeyPolicy ?? 'skip');
|
|
61
|
+
const localeValidator = new LocaleValidator({
|
|
62
|
+
delimiter: config.locales?.delimiter ?? '.',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
let localesToAudit = options.locales?.filter(Boolean) ?? [];
|
|
66
|
+
if (localesToAudit.length === 0) {
|
|
67
|
+
const source = config.sourceLanguage ?? 'en';
|
|
68
|
+
const targets = config.targetLanguages ?? [];
|
|
69
|
+
localesToAudit = [source, ...targets];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Ensure unique locales while preserving order
|
|
73
|
+
localesToAudit = localesToAudit.filter((locale, index) => localesToAudit.indexOf(locale) === index);
|
|
74
|
+
|
|
75
|
+
const runQualityChecks =
|
|
76
|
+
Boolean(options.checkDuplicates) || Boolean(options.checkInconsistent) || Boolean(options.checkOrphaned);
|
|
77
|
+
const checkDuplicates =
|
|
78
|
+
typeof options.checkDuplicates === 'boolean' ? options.checkDuplicates : !runQualityChecks;
|
|
79
|
+
const checkInconsistent = Boolean(options.checkInconsistent);
|
|
80
|
+
const checkOrphaned = Boolean(options.checkOrphaned);
|
|
81
|
+
|
|
82
|
+
const results: LocaleAuditResult[] = [];
|
|
83
|
+
const allKeys = new Set<string>();
|
|
84
|
+
|
|
85
|
+
// First pass: collect all keys
|
|
86
|
+
for (const locale of localesToAudit) {
|
|
87
|
+
const data = await localeStore.get(locale);
|
|
88
|
+
for (const key of Object.keys(data)) {
|
|
89
|
+
allKeys.add(key);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const locale of localesToAudit) {
|
|
94
|
+
const data = await localeStore.get(locale);
|
|
95
|
+
const keys = Object.keys(data);
|
|
96
|
+
const issues: AuditIssue[] = [];
|
|
97
|
+
const qualityIssues: QualityIssue[] = [];
|
|
98
|
+
|
|
99
|
+
for (const key of keys) {
|
|
100
|
+
const value = data[key];
|
|
101
|
+
const analysis = keyValidator.analyzeWithValue(key, value);
|
|
102
|
+
if (analysis.suspicious && analysis.reason) {
|
|
103
|
+
issues.push({
|
|
104
|
+
key,
|
|
105
|
+
value: value.length > 50 ? `${value.slice(0, 47)}...` : value,
|
|
106
|
+
reason: analysis.reason,
|
|
107
|
+
description:
|
|
108
|
+
SUSPICIOUS_KEY_REASON_DESCRIPTIONS[analysis.reason] ?? 'Unknown issue',
|
|
109
|
+
suggestion: keyValidator.suggestFix(key, analysis.reason),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (checkDuplicates) {
|
|
115
|
+
const duplicates = localeValidator.detectDuplicateValues(locale, data);
|
|
116
|
+
for (const dup of duplicates) {
|
|
117
|
+
const preview = dup.value.slice(0, 40);
|
|
118
|
+
const label = dup.value.length > 40 ? `${preview}...` : preview;
|
|
119
|
+
qualityIssues.push({
|
|
120
|
+
type: 'duplicate-value',
|
|
121
|
+
description: `Value "${label}" used by ${dup.keys.length} keys`,
|
|
122
|
+
keys: dup.keys,
|
|
123
|
+
suggestion: 'Consider consolidating to a single key',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (checkInconsistent && locale === localesToAudit[0]) {
|
|
129
|
+
const inconsistent = localeValidator.detectInconsistentKeys(Array.from(allKeys));
|
|
130
|
+
for (const inc of inconsistent) {
|
|
131
|
+
qualityIssues.push({
|
|
132
|
+
type: 'inconsistent-key',
|
|
133
|
+
description: inc.pattern,
|
|
134
|
+
keys: inc.variants,
|
|
135
|
+
suggestion: inc.suggestion,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (checkOrphaned && locale === localesToAudit[0]) {
|
|
141
|
+
const orphaned = localeValidator.detectOrphanedNamespaces(Array.from(allKeys));
|
|
142
|
+
for (const orph of orphaned) {
|
|
143
|
+
qualityIssues.push({
|
|
144
|
+
type: 'orphaned-namespace',
|
|
145
|
+
description: `Namespace "${orph.namespace}" has only ${orph.keyCount} key(s)`,
|
|
146
|
+
keys: orph.keys,
|
|
147
|
+
suggestion: 'Consider merging into a related namespace',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
results.push({
|
|
153
|
+
locale,
|
|
154
|
+
totalKeys: keys.length,
|
|
155
|
+
issues,
|
|
156
|
+
qualityIssues,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const totalIssues = results.reduce((sum, r) => sum + r.issues.length, 0);
|
|
161
|
+
const totalQualityIssues = results.reduce((sum, r) => sum + r.qualityIssues.length, 0);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
results,
|
|
165
|
+
totalIssues,
|
|
166
|
+
totalQualityIssues,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function printLocaleAuditResults(summary: LocaleAuditSummary) {
|
|
171
|
+
for (const result of summary.results) {
|
|
172
|
+
const hasIssues = result.issues.length > 0 || result.qualityIssues.length > 0;
|
|
173
|
+
if (!hasIssues) {
|
|
174
|
+
console.log(chalk.green(`✓ ${result.locale}.json: ${result.totalKeys} keys, no issues`));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.log(chalk.yellow(`⚠ ${result.locale}.json: ${result.totalKeys} keys`));
|
|
179
|
+
|
|
180
|
+
if (result.issues.length > 0) {
|
|
181
|
+
console.log(chalk.yellow(` Suspicious keys: ${result.issues.length}`));
|
|
182
|
+
for (const issue of result.issues) {
|
|
183
|
+
console.log(chalk.dim(` - "${issue.key}"`));
|
|
184
|
+
console.log(chalk.dim(` ${issue.description}`));
|
|
185
|
+
if (issue.suggestion) {
|
|
186
|
+
console.log(chalk.dim(` Suggestion: ${issue.suggestion}`));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (result.qualityIssues.length > 0) {
|
|
192
|
+
console.log(chalk.cyan(` Quality checks: ${result.qualityIssues.length}`));
|
|
193
|
+
for (const issue of result.qualityIssues) {
|
|
194
|
+
const typeLabel =
|
|
195
|
+
issue.type === 'duplicate-value' ? '📋' : issue.type === 'inconsistent-key' ? '🔀' : '📦';
|
|
196
|
+
console.log(chalk.dim(` ${typeLabel} ${issue.description}`));
|
|
197
|
+
if (issue.suggestion) {
|
|
198
|
+
console.log(chalk.dim(` ${issue.suggestion}`));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log();
|
|
205
|
+
if (summary.totalIssues === 0 && summary.totalQualityIssues === 0) {
|
|
206
|
+
console.log(chalk.green('✓ No issues found in locale files'));
|
|
207
|
+
} else {
|
|
208
|
+
if (summary.totalIssues > 0) {
|
|
209
|
+
console.log(chalk.yellow(`Found ${summary.totalIssues} suspicious key(s)`));
|
|
210
|
+
}
|
|
211
|
+
if (summary.totalQualityIssues > 0) {
|
|
212
|
+
console.log(chalk.cyan(`Found ${summary.totalQualityIssues} quality issue(s)`));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function hasAuditFindings(summary: LocaleAuditSummary): boolean {
|
|
218
|
+
return summary.totalIssues > 0 || summary.totalQualityIssues > 0;
|
|
219
|
+
}
|
|
@@ -91,4 +91,47 @@ describe('applyPreviewFile', () => {
|
|
|
91
91
|
const [, ...forwardedArgs] = spawnedArgs as string[];
|
|
92
92
|
expect(forwardedArgs).toEqual(['sync', '--write', '--json']);
|
|
93
93
|
});
|
|
94
|
+
|
|
95
|
+
it('handles previews recorded without the command token', async () => {
|
|
96
|
+
const previewPath = await writePreviewFixture(['--target', 'src/pages/**']);
|
|
97
|
+
const { applyPreviewFile } = await import('./preview.js');
|
|
98
|
+
|
|
99
|
+
await applyPreviewFile('sync', previewPath);
|
|
100
|
+
|
|
101
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
102
|
+
const [, spawnedArgs] = mockSpawn.mock.calls[0];
|
|
103
|
+
const [, ...forwardedArgs] = spawnedArgs as string[];
|
|
104
|
+
expect(forwardedArgs).toEqual(['sync', '--target', 'src/pages/**', '--write']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('strips inline --preview-output and dedupes subcommand', async () => {
|
|
108
|
+
const previewPath = await writePreviewFixture([
|
|
109
|
+
'sync',
|
|
110
|
+
'--preview-output=tmp.json',
|
|
111
|
+
'--diff',
|
|
112
|
+
]);
|
|
113
|
+
const { applyPreviewFile } = await import('./preview.js');
|
|
114
|
+
|
|
115
|
+
await applyPreviewFile('sync', previewPath, ['--prune']);
|
|
116
|
+
|
|
117
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
118
|
+
const [, spawnedArgs] = mockSpawn.mock.calls[0];
|
|
119
|
+
const [, ...forwardedArgs] = spawnedArgs as string[];
|
|
120
|
+
expect(forwardedArgs).toEqual(['sync', '--diff', '--write', '--prune']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('merges extra args without duplicating write or prune', async () => {
|
|
124
|
+
const previewPath = await writePreviewFixture([
|
|
125
|
+
'--target', 'apps/web/**',
|
|
126
|
+
'--prune',
|
|
127
|
+
]);
|
|
128
|
+
const { applyPreviewFile } = await import('./preview.js');
|
|
129
|
+
|
|
130
|
+
await applyPreviewFile('sync', previewPath, ['--prune', '--write']);
|
|
131
|
+
|
|
132
|
+
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
|
133
|
+
const [, spawnedArgs] = mockSpawn.mock.calls[0];
|
|
134
|
+
const [, ...forwardedArgs] = spawnedArgs as string[];
|
|
135
|
+
expect(forwardedArgs).toEqual(['sync', '--target', 'apps/web/**', '--prune', '--write']);
|
|
136
|
+
});
|
|
94
137
|
});
|
package/src/utils/preview.ts
CHANGED
|
@@ -56,15 +56,9 @@ export async function applyPreviewFile(
|
|
|
56
56
|
): Promise<void> {
|
|
57
57
|
const payload = await readPreviewFile(expectedKind, previewPath);
|
|
58
58
|
const sanitizedArgs = sanitizePreviewArgs(payload.args);
|
|
59
|
-
const
|
|
60
|
-
if (!command) {
|
|
61
|
-
throw new Error('Preview file does not include the original command.');
|
|
62
|
-
}
|
|
63
|
-
if (command !== expectedKind) {
|
|
64
|
-
throw new Error(`Preview command mismatch. Expected ${expectedKind}, got ${command}.`);
|
|
65
|
-
}
|
|
59
|
+
const rest = sanitizedArgs[0] === expectedKind ? sanitizedArgs.slice(1) : sanitizedArgs;
|
|
66
60
|
|
|
67
|
-
const replayArgs = [
|
|
61
|
+
const replayArgs = [expectedKind, ...rest];
|
|
68
62
|
if (!replayArgs.some((arg) => arg === '--write' || arg.startsWith('--write='))) {
|
|
69
63
|
replayArgs.push('--write');
|
|
70
64
|
}
|