@final-commerce/common 1.1.4-beta.4
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/README.md +224 -0
- package/bin/gate-done.mjs +161 -0
- package/bin/gate-i18n-sync.mjs +351 -0
- package/bin/gate-save.mjs +146 -0
- package/bin/gate-setup.mjs +227 -0
- package/bin/gate-start.mjs +101 -0
- package/bin/prepare-commit-msg.sh +16 -0
- package/commitlint/index.mjs +14 -0
- package/dist/index.d.mts +468 -0
- package/dist/index.d.ts +468 -0
- package/dist/index.js +2702 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2565 -0
- package/dist/index.mjs.map +1 -0
- package/dist/interfaces-6GnQyqYd.d.mts +598 -0
- package/dist/interfaces-6GnQyqYd.d.ts +598 -0
- package/dist/pos-types/index.d.mts +1782 -0
- package/dist/pos-types/index.d.ts +1782 -0
- package/dist/pos-types/index.js +144 -0
- package/dist/pos-types/index.js.map +1 -0
- package/dist/pos-types/index.mjs +114 -0
- package/dist/pos-types/index.mjs.map +1 -0
- package/eslint/backend-nestjs.mjs +30 -0
- package/eslint/frontend-react.mjs +27 -0
- package/eslint/library.mjs +19 -0
- package/lint-staged/index.mjs +4 -0
- package/package.json +160 -0
- package/prettier/index.json +10 -0
- package/typescript/backend-nestjs.json +13 -0
- package/typescript/base.json +13 -0
- package/typescript/frontend-react.json +15 -0
- package/typescript/library.json +12 -0
- package/vitest/backend.ts +20 -0
- package/vitest/frontend.ts +22 -0
- package/vitest/library.ts +20 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gate-i18n-sync — generic i18n key sync bin.
|
|
4
|
+
*
|
|
5
|
+
* Reads config from the "fc-i18n" key in the project's package.json:
|
|
6
|
+
*
|
|
7
|
+
* "fc-i18n": {
|
|
8
|
+
* "slug": "my-project", // required — mt project slug
|
|
9
|
+
* "localesDir": "src/locales", // optional, default "src/locales"
|
|
10
|
+
* "i18nIndexPath": "src/providers/i18n/index.ts" // optional — file containing
|
|
11
|
+
* } // the SUPPORTED_LOCALES marker block
|
|
12
|
+
*
|
|
13
|
+
* Flow:
|
|
14
|
+
* 1. Run i18next-parser → JSON of all t() keys extracted from src/.
|
|
15
|
+
* 2. Diff against the committed localesDir/en.json.
|
|
16
|
+
* 3. If new/changed keys: call mt sync_keys (autoTranslate), then
|
|
17
|
+
* get_translations to pull the canonical bundle, write localesDir/*.json.
|
|
18
|
+
* 4. If i18nIndexPath set, rewrite SUPPORTED_LOCALES between marker comments.
|
|
19
|
+
* 5. git add localesDir (and i18nIndexPath if changed) so the bundle lands
|
|
20
|
+
* in the same commit.
|
|
21
|
+
*
|
|
22
|
+
* Env:
|
|
23
|
+
* MT_API_KEY — required for the mt round-trip
|
|
24
|
+
* SKIP_I18N_SYNC=1 — escape hatch for offline commits
|
|
25
|
+
* MT_URL — override mt MCP endpoint
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawnSync } from 'node:child_process';
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { resolve } from 'node:path';
|
|
31
|
+
|
|
32
|
+
// ── bootstrap ─────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const root = process.cwd();
|
|
35
|
+
|
|
36
|
+
if (process.env.SKIP_I18N_SYNC === '1') {
|
|
37
|
+
console.log('[i18n-sync] SKIP_I18N_SYNC=1 — skipping.');
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const pkgPath = resolve(root, 'package.json');
|
|
42
|
+
if (!existsSync(pkgPath)) process.exit(0);
|
|
43
|
+
|
|
44
|
+
let pkg;
|
|
45
|
+
try {
|
|
46
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
47
|
+
} catch {
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const cfg = pkg['fc-i18n'];
|
|
52
|
+
if (!cfg?.slug) {
|
|
53
|
+
console.error('[i18n-sync] No "fc-i18n" config with a "slug" found in package.json — skipping.');
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const PROJECT_SLUG = cfg.slug;
|
|
58
|
+
const SOURCE_LOCALE = 'en';
|
|
59
|
+
const LOCALES_DIR = resolve(root, cfg.localesDir ?? 'src/locales');
|
|
60
|
+
const I18N_INDEX_PATH = cfg.i18nIndexPath ? resolve(root, cfg.i18nIndexPath) : null;
|
|
61
|
+
const EXTRACT_DIR = resolve(root, 'node_modules/.cache/i18next-extract');
|
|
62
|
+
const CURRENT_EN = resolve(LOCALES_DIR, `${SOURCE_LOCALE}.json`);
|
|
63
|
+
const MT_URL = process.env.MT_URL ?? 'https://mt.finalpos.com/api/mcp';
|
|
64
|
+
|
|
65
|
+
const LOCALES_BLOCK_START = '// <i18n-sync:locales>';
|
|
66
|
+
const LOCALES_BLOCK_END = '// </i18n-sync:locales>';
|
|
67
|
+
|
|
68
|
+
// Load env from .env.local then .env (process.env values always win).
|
|
69
|
+
function loadEnvFile(path) {
|
|
70
|
+
if (!existsSync(path)) return;
|
|
71
|
+
for (const rawLine of readFileSync(path, 'utf-8').split(/\r?\n/)) {
|
|
72
|
+
const line = rawLine.trim();
|
|
73
|
+
if (!line || line.startsWith('#')) continue;
|
|
74
|
+
const eq = line.indexOf('=');
|
|
75
|
+
if (eq === -1) continue;
|
|
76
|
+
const key = line.slice(0, eq).trim();
|
|
77
|
+
if (!key || key in process.env) continue;
|
|
78
|
+
let value = line.slice(eq + 1).trim();
|
|
79
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
80
|
+
value = value.slice(1, -1);
|
|
81
|
+
}
|
|
82
|
+
process.env[key] = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
loadEnvFile(resolve(root, '.env.local'));
|
|
87
|
+
loadEnvFile(resolve(root, '.env'));
|
|
88
|
+
|
|
89
|
+
// ── ANSI helpers ──────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
const COLOR = !process.env.NO_COLOR && process.stderr.isTTY;
|
|
92
|
+
const c = {
|
|
93
|
+
reset: COLOR ? '\x1b[0m' : '',
|
|
94
|
+
bold: COLOR ? '\x1b[1m' : '',
|
|
95
|
+
dim: COLOR ? '\x1b[2m' : '',
|
|
96
|
+
red: COLOR ? '\x1b[31m' : '',
|
|
97
|
+
yellow: COLOR ? '\x1b[33m' : '',
|
|
98
|
+
cyan: COLOR ? '\x1b[36m' : '',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function readJson(path, fallback) {
|
|
104
|
+
if (!existsSync(path)) return fallback;
|
|
105
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writeJsonSorted(path, data) {
|
|
109
|
+
const sorted = Object.fromEntries(Object.entries(data).sort(([a], [b]) => a.localeCompare(b)));
|
|
110
|
+
const content = JSON.stringify(sorted, null, 2) + '\n';
|
|
111
|
+
if (existsSync(path) && readFileSync(path, 'utf-8') === content) return false;
|
|
112
|
+
writeFileSync(path, content, 'utf-8');
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readSupportedLocalesFromCode() {
|
|
117
|
+
if (!I18N_INDEX_PATH || !existsSync(I18N_INDEX_PATH)) return null;
|
|
118
|
+
const content = readFileSync(I18N_INDEX_PATH, 'utf-8');
|
|
119
|
+
const startIdx = content.indexOf(LOCALES_BLOCK_START);
|
|
120
|
+
const endIdx = content.indexOf(LOCALES_BLOCK_END);
|
|
121
|
+
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return null;
|
|
122
|
+
const matches = content.slice(startIdx, endIdx).match(/["']([a-zA-Z]{2,3}(?:-[a-zA-Z]{1,8})?)["']/g) ?? [];
|
|
123
|
+
return matches.map((m) => m.slice(1, -1));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writeSupportedLocalesToCode(locales) {
|
|
127
|
+
if (!I18N_INDEX_PATH) return false;
|
|
128
|
+
if (!existsSync(I18N_INDEX_PATH)) {
|
|
129
|
+
console.error(`[i18n-sync] ${I18N_INDEX_PATH} missing; cannot write locales.`);
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const content = readFileSync(I18N_INDEX_PATH, 'utf-8');
|
|
133
|
+
const startIdx = content.indexOf(LOCALES_BLOCK_START);
|
|
134
|
+
const endIdx = content.indexOf(LOCALES_BLOCK_END);
|
|
135
|
+
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
|
|
136
|
+
console.error(`[i18n-sync] Missing markers in ${I18N_INDEX_PATH}. Restore the // <i18n-sync:locales> block.`);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const arrayLiteral = `[${[...locales]
|
|
140
|
+
.sort()
|
|
141
|
+
.map((l) => JSON.stringify(l))
|
|
142
|
+
.join(', ')}]`;
|
|
143
|
+
const replacement =
|
|
144
|
+
LOCALES_BLOCK_START +
|
|
145
|
+
' — auto-managed by gate-i18n-sync; do not edit by hand\n' +
|
|
146
|
+
`export const SUPPORTED_LOCALES = ${arrayLiteral} as const;\n`;
|
|
147
|
+
const updated = content.slice(0, startIdx) + replacement + content.slice(endIdx);
|
|
148
|
+
if (updated === content) return false;
|
|
149
|
+
writeFileSync(I18N_INDEX_PATH, updated, 'utf-8');
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function runExtractor() {
|
|
154
|
+
mkdirSync(EXTRACT_DIR, { recursive: true });
|
|
155
|
+
const result = spawnSync('npx', ['i18next-parser', '--config', 'i18next-parser.config.cjs', '--silent'], {
|
|
156
|
+
cwd: root,
|
|
157
|
+
stdio: 'inherit',
|
|
158
|
+
});
|
|
159
|
+
if (result.status !== 0) {
|
|
160
|
+
console.error('[i18n-sync] i18next-parser failed.');
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
return readJson(resolve(EXTRACT_DIR, 'en.json'), {});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function computeDesiredSource(extracted, currentEn) {
|
|
167
|
+
const desired = {};
|
|
168
|
+
for (const [key, extractedValue] of Object.entries(extracted)) {
|
|
169
|
+
const isPlaceholder = extractedValue === key || extractedValue === '';
|
|
170
|
+
desired[key] = isPlaceholder ? (currentEn[key] ?? key) : extractedValue;
|
|
171
|
+
}
|
|
172
|
+
return desired;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function diffKeys(currentEn, desired) {
|
|
176
|
+
const added = [];
|
|
177
|
+
const changed = [];
|
|
178
|
+
const removed = [];
|
|
179
|
+
for (const key of Object.keys(desired)) {
|
|
180
|
+
if (!(key in currentEn)) added.push(key);
|
|
181
|
+
else if (currentEn[key] !== desired[key]) changed.push(key);
|
|
182
|
+
}
|
|
183
|
+
for (const key of Object.keys(currentEn)) {
|
|
184
|
+
if (!(key in desired)) removed.push(key);
|
|
185
|
+
}
|
|
186
|
+
return { added, changed, removed };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function unwrapMcpJson(result) {
|
|
190
|
+
const block = result.content?.[0];
|
|
191
|
+
if (!block || block.type !== 'text' || typeof block.text !== 'string') {
|
|
192
|
+
throw new Error('[i18n-sync] Unexpected MCP response shape.');
|
|
193
|
+
}
|
|
194
|
+
return JSON.parse(block.text);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function pruneLocaleFiles(liveKeys) {
|
|
198
|
+
let count = 0;
|
|
199
|
+
if (!existsSync(LOCALES_DIR)) return 0;
|
|
200
|
+
for (const file of readdirSync(LOCALES_DIR)) {
|
|
201
|
+
if (!file.endsWith('.json') || file.startsWith('_')) continue;
|
|
202
|
+
const path = resolve(LOCALES_DIR, file);
|
|
203
|
+
const existing = readJson(path, {});
|
|
204
|
+
const filtered = Object.fromEntries(Object.entries(existing).filter(([k]) => liveKeys.has(k)));
|
|
205
|
+
if (JSON.stringify(existing) !== JSON.stringify(filtered)) {
|
|
206
|
+
writeJsonSorted(path, filtered);
|
|
207
|
+
count += Object.keys(existing).length - Object.keys(filtered).length;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return count;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── main ──────────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
async function main() {
|
|
216
|
+
const extracted = runExtractor();
|
|
217
|
+
const currentEn = readJson(CURRENT_EN, {});
|
|
218
|
+
const desired = computeDesiredSource(extracted, currentEn);
|
|
219
|
+
const { added, changed, removed } = diffKeys(currentEn, desired);
|
|
220
|
+
const liveKeys = new Set(Object.keys(desired));
|
|
221
|
+
const needsPush = added.length > 0 || changed.length > 0;
|
|
222
|
+
|
|
223
|
+
const MT_API_KEY = process.env.MT_API_KEY;
|
|
224
|
+
if (!MT_API_KEY) {
|
|
225
|
+
if (needsPush) {
|
|
226
|
+
console.error(
|
|
227
|
+
`\n[i18n-sync] ERROR: MT_API_KEY is not set.\n\n` +
|
|
228
|
+
` ${added.length} new key(s), ${changed.length} changed key(s) need to be synced.\n\n` +
|
|
229
|
+
` Add MT_API_KEY to .env.local, or set SKIP_I18N_SYNC=1 to bypass.\n`,
|
|
230
|
+
);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
console.warn('[i18n-sync] MT_API_KEY not set — skipped server-side check.');
|
|
234
|
+
pruneLocaleFiles(liveKeys);
|
|
235
|
+
if (existsSync(LOCALES_DIR)) spawnSync('git', ['add', LOCALES_DIR], { cwd: root, stdio: 'inherit' });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let Client, StreamableHTTPClientTransport;
|
|
240
|
+
try {
|
|
241
|
+
({ Client } = await import('@modelcontextprotocol/sdk/client/index.js'));
|
|
242
|
+
({ StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js'));
|
|
243
|
+
} catch {
|
|
244
|
+
console.error('[i18n-sync] @modelcontextprotocol/sdk is required. Run: npm i -D @modelcontextprotocol/sdk');
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const transport = new StreamableHTTPClientTransport(new URL(MT_URL), {
|
|
249
|
+
requestInit: { headers: { Authorization: `Bearer ${MT_API_KEY}` } },
|
|
250
|
+
});
|
|
251
|
+
const client = new Client({ name: 'fc-i18n-sync', version: '1.0.0' });
|
|
252
|
+
|
|
253
|
+
const SYNC_TIMEOUT_MS = 5 * 60 * 1000;
|
|
254
|
+
const PULL_TIMEOUT_MS = 90 * 1000;
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
try {
|
|
258
|
+
await client.connect(transport);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
261
|
+
console.error(
|
|
262
|
+
`\n${c.yellow}${c.bold}[i18n-sync] WARN:${c.reset}${c.yellow} could not reach mt — skipping server-side sync.${c.reset}\n`,
|
|
263
|
+
);
|
|
264
|
+
console.error(` ${c.dim}Reason:${c.reset} ${c.red}${reason}${c.reset}`);
|
|
265
|
+
if (needsPush) {
|
|
266
|
+
console.error(
|
|
267
|
+
`\n ${c.red}${added.length} new + ${changed.length} changed key(s) will NOT be pushed to mt this commit.${c.reset}`,
|
|
268
|
+
);
|
|
269
|
+
console.error(
|
|
270
|
+
` ${c.dim}Re-run${c.reset} ${c.cyan}npm run i18n:sync${c.reset} ${c.dim}once mt is reachable.${c.reset}\n`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
pruneLocaleFiles(liveKeys);
|
|
274
|
+
if (existsSync(LOCALES_DIR)) spawnSync('git', ['add', LOCALES_DIR], { cwd: root, stdio: 'inherit' });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (needsPush) {
|
|
279
|
+
const keysPayload = Object.entries(desired).map(([key, value]) => ({ key, value }));
|
|
280
|
+
await client.callTool(
|
|
281
|
+
{ name: 'sync_keys', arguments: { slug: PROJECT_SLUG, keys: keysPayload, autoTranslate: true } },
|
|
282
|
+
undefined,
|
|
283
|
+
{ timeout: SYNC_TIMEOUT_MS },
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const translationsResult = await client.callTool(
|
|
288
|
+
{ name: 'get_translations', arguments: { slug: PROJECT_SLUG } },
|
|
289
|
+
undefined,
|
|
290
|
+
{ timeout: PULL_TIMEOUT_MS },
|
|
291
|
+
);
|
|
292
|
+
const payload = unwrapMcpJson(translationsResult);
|
|
293
|
+
const mtLocales = Object.keys(payload.locales);
|
|
294
|
+
const codeLocales = readSupportedLocalesFromCode() ?? [];
|
|
295
|
+
const newLocales = mtLocales.filter((l) => !codeLocales.includes(l));
|
|
296
|
+
const removedLocales = codeLocales.filter((l) => !mtLocales.includes(l));
|
|
297
|
+
|
|
298
|
+
const PLURAL_SUFFIX_RE = /_(zero|one|two|few|many|other)$/;
|
|
299
|
+
const localeFilesChanged = [];
|
|
300
|
+
|
|
301
|
+
for (const [locale, keys] of Object.entries(payload.locales)) {
|
|
302
|
+
const filtered = {};
|
|
303
|
+
for (const [key, rawValue] of Object.entries(keys)) {
|
|
304
|
+
const pluralMatch = key.match(PLURAL_SUFFIX_RE);
|
|
305
|
+
const baseKey = pluralMatch ? key.slice(0, pluralMatch.index) : key;
|
|
306
|
+
if (!liveKeys.has(key) && !liveKeys.has(baseKey)) continue;
|
|
307
|
+
let value = rawValue;
|
|
308
|
+
if (locale === SOURCE_LOCALE && pluralMatch && value === key) value = baseKey;
|
|
309
|
+
filtered[key] = value;
|
|
310
|
+
}
|
|
311
|
+
const path = resolve(LOCALES_DIR, `${locale}.json`);
|
|
312
|
+
if (writeJsonSorted(path, filtered)) localeFilesChanged.push(locale);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const locale of removedLocales) {
|
|
316
|
+
const path = resolve(LOCALES_DIR, `${locale}.json`);
|
|
317
|
+
if (existsSync(path)) spawnSync('git', ['rm', '--quiet', path], { cwd: root });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const codeUpdated = writeSupportedLocalesToCode(mtLocales);
|
|
321
|
+
|
|
322
|
+
if (localeFilesChanged.length > 0 || newLocales.length > 0 || removedLocales.length > 0 || codeUpdated) {
|
|
323
|
+
spawnSync('git', ['add', LOCALES_DIR], { cwd: root, stdio: 'inherit' });
|
|
324
|
+
if (codeUpdated && I18N_INDEX_PATH) {
|
|
325
|
+
spawnSync('git', ['add', I18N_INDEX_PATH], { cwd: root, stdio: 'inherit' });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const parts = [];
|
|
330
|
+
if (needsPush) parts.push(`${added.length} new`);
|
|
331
|
+
if (changed.length > 0) parts.push(`${changed.length} changed`);
|
|
332
|
+
if (removed.length > 0) parts.push(`${removed.length} orphan(s) on mt`);
|
|
333
|
+
if (newLocales.length > 0) parts.push(`pulled new locale(s): ${newLocales.join(', ')}`);
|
|
334
|
+
if (removedLocales.length > 0) parts.push(`dropped locale(s): ${removedLocales.join(', ')}`);
|
|
335
|
+
if (localeFilesChanged.length > 0 && !needsPush && newLocales.length === 0) {
|
|
336
|
+
parts.push(`updated ${localeFilesChanged.join(', ')} from mt`);
|
|
337
|
+
}
|
|
338
|
+
console.log(parts.length > 0 ? `[i18n-sync] ${parts.join(' · ')}` : '[i18n-sync] No changes.');
|
|
339
|
+
} finally {
|
|
340
|
+
try {
|
|
341
|
+
await client.close();
|
|
342
|
+
} catch {
|
|
343
|
+
/* ignore close errors */
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
main().catch((err) => {
|
|
349
|
+
console.error('[i18n-sync] Failed:', err);
|
|
350
|
+
process.exit(1);
|
|
351
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gate-save — pre-commit hook.
|
|
4
|
+
*
|
|
5
|
+
* 1. Runs lint-staged over modified surfaces.
|
|
6
|
+
* 2. Scans staged .ts/.tsx/.js files for TODO comments that lack a Jira ticket
|
|
7
|
+
* reference (e.g. "// TODO: do something FI-123"). Bare TODOs block commit.
|
|
8
|
+
*
|
|
9
|
+
* Exits 0 on success, 1 on any gate failure.
|
|
10
|
+
*
|
|
11
|
+
* Install as Husky pre-commit hook:
|
|
12
|
+
* echo "npx gate-save" > .husky/pre-commit
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** Run a command, inheriting stdio so output is streamed to the terminal. */
|
|
22
|
+
function run(cmd) {
|
|
23
|
+
return spawnSync(cmd, { shell: true, encoding: 'utf8', stdio: 'inherit' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Run a command capturing stdout/stderr (no terminal output). */
|
|
27
|
+
function capture(cmd) {
|
|
28
|
+
return spawnSync(cmd, { shell: true, encoding: 'utf8' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fail(msg) {
|
|
32
|
+
process.stderr.write(`\x1b[31m✖ ${msg}\x1b[0m\n`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function info(msg) {
|
|
37
|
+
process.stdout.write(`\x1b[36mℹ ${msg}\x1b[0m\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ok(msg) {
|
|
41
|
+
process.stdout.write(`\x1b[32m✔ ${msg}\x1b[0m\n`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── staged file list ──────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const stagedResult = capture('git diff --cached --name-only --diff-filter=ACMR');
|
|
47
|
+
if (stagedResult.status !== 0) {
|
|
48
|
+
fail(`Could not enumerate staged files:\n${stagedResult.stderr.trim()}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CODE_EXTS = /\.(ts|tsx|mts|cts|js|mjs|cjs|jsx)$/;
|
|
52
|
+
const VERSION_BUMP_FILES = /^(package\.json|package-lock\.json|yarn\.lock)$/;
|
|
53
|
+
|
|
54
|
+
const allStaged = stagedResult.stdout
|
|
55
|
+
.split('\n')
|
|
56
|
+
.map((f) => f.trim())
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
|
|
59
|
+
if (allStaged.length > 0 && allStaged.every((f) => VERSION_BUMP_FILES.test(f))) {
|
|
60
|
+
ok('Version bump commit detected — skipping gates.');
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const codeFiles = allStaged.filter((f) => CODE_EXTS.test(f) && !f.startsWith('bin/'));
|
|
65
|
+
|
|
66
|
+
// ── 1. lint-staged ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
info('Running lint-staged…');
|
|
69
|
+
|
|
70
|
+
const lintResult = run('npx lint-staged');
|
|
71
|
+
if (lintResult.status !== 0) {
|
|
72
|
+
fail('lint-staged reported errors. Fix them and re-stage before committing.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
ok('lint-staged passed.');
|
|
76
|
+
|
|
77
|
+
// ── 2. TODO scanner ──────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
info('Scanning staged files for bare TODOs…');
|
|
80
|
+
|
|
81
|
+
// A Jira-style ticket reference: one or more uppercase letters, a dash, and digits.
|
|
82
|
+
const JIRA_REF_RE = /[A-Z]+-\d+/;
|
|
83
|
+
// Matches any TODO annotation (case-insensitive), with optional owner parens.
|
|
84
|
+
const TODO_LINE_RE = /TODO(?:\s*\([^)]*\))?\s*[::]?\s+(.+)/i;
|
|
85
|
+
|
|
86
|
+
const violations = [];
|
|
87
|
+
|
|
88
|
+
for (const file of codeFiles) {
|
|
89
|
+
// Read the staged blob, not the working-tree file, to avoid false positives
|
|
90
|
+
// from unstaged edits made after the last `git add`.
|
|
91
|
+
const blobResult = capture(`git show :${file}`);
|
|
92
|
+
if (blobResult.status !== 0) continue;
|
|
93
|
+
|
|
94
|
+
const lines = blobResult.stdout.split('\n');
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < lines.length; i++) {
|
|
97
|
+
const match = lines[i].match(TODO_LINE_RE);
|
|
98
|
+
if (match && !JIRA_REF_RE.test(lines[i])) {
|
|
99
|
+
violations.push({ file, line: i + 1, text: lines[i].trim() });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (violations.length > 0) {
|
|
105
|
+
process.stderr.write(
|
|
106
|
+
'\x1b[31m✖ Bare TODOs detected — each TODO must carry a Jira ticket reference:\x1b[0m\n',
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const byFile = violations.reduce((acc, v) => {
|
|
110
|
+
(acc[v.file] ??= []).push(v);
|
|
111
|
+
return acc;
|
|
112
|
+
}, {});
|
|
113
|
+
|
|
114
|
+
for (const [file, items] of Object.entries(byFile)) {
|
|
115
|
+
process.stderr.write(`\x1b[33m\n ${file}\x1b[0m\n`);
|
|
116
|
+
items.forEach(({ line, text }) => {
|
|
117
|
+
process.stderr.write(`\x1b[90m ${String(line).padStart(4)} ${text}\x1b[0m\n`);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
process.stderr.write(
|
|
122
|
+
'\x1b[90m\n Example fix: // TODO: refactor this FI-999\x1b[0m\n',
|
|
123
|
+
);
|
|
124
|
+
fail(`${violations.length} bare TODO(s) must be resolved before committing.`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
ok('No bare TODOs detected.');
|
|
128
|
+
|
|
129
|
+
// ── 3. i18n sync ──────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
const pkgJsonPath = resolve(process.cwd(), 'package.json');
|
|
132
|
+
if (existsSync(pkgJsonPath)) {
|
|
133
|
+
let rootPkg;
|
|
134
|
+
try { rootPkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); } catch { /* ignore */ }
|
|
135
|
+
|
|
136
|
+
if (rootPkg?.['fc-i18n']?.slug) {
|
|
137
|
+
info('Running i18n sync…');
|
|
138
|
+
const syncResult = run('npx gate-i18n-sync');
|
|
139
|
+
if (syncResult.status !== 0) {
|
|
140
|
+
fail('i18n sync failed. Fix the errors above or set SKIP_I18N_SYNC=1 to bypass.');
|
|
141
|
+
}
|
|
142
|
+
ok('i18n sync complete.');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
process.exit(0);
|