@openora/create 0.4.1-canary.31 → 0.4.1-canary.310

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.
Files changed (91) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/generated/core-version.d.ts +1 -1
  3. package/dist/generated/core-version.d.ts.map +1 -1
  4. package/dist/generated/core-version.js +1 -1
  5. package/dist/generated/core-version.js.map +1 -1
  6. package/dist/index.js +47 -92
  7. package/dist/index.js.map +1 -1
  8. package/dist/render.d.ts +15 -0
  9. package/dist/render.d.ts.map +1 -0
  10. package/dist/render.js +125 -0
  11. package/dist/render.js.map +1 -0
  12. package/package.json +7 -5
  13. package/template/README.md.tpl +12 -7
  14. package/template/__dot__claude/settings.json.tpl +22 -12
  15. package/template/__dot__env.example +10 -0
  16. package/template/__dot__gitignore +6 -1
  17. package/template/__dot__rulesync/commands/adr.md +32 -0
  18. package/template/__dot__rulesync/commands/check.md +11 -10
  19. package/template/__dot__rulesync/commands/doctor.md.tpl +17 -0
  20. package/template/__dot__rulesync/commands/scaffold-module.md +3 -2
  21. package/template/__dot__rulesync/commands/scaffold-plugin.md +19 -3
  22. package/template/__dot__rulesync/commands/scaffold-route.md +1 -0
  23. package/template/__dot__rulesync/commands/start.md +2 -2
  24. package/template/__dot__rulesync/hooks/_shared.mjs +5 -0
  25. package/template/__dot__rulesync/hooks/guard-core.mjs.tpl +75 -26
  26. package/template/__dot__rulesync/hooks/guard-generated.mjs +12 -5
  27. package/template/__dot__rulesync/hooks/guard-mr.mjs +81 -0
  28. package/template/__dot__rulesync/hooks/guard-subagent.mjs +22 -6
  29. package/template/__dot__rulesync/hooks/post-edit.mjs.tpl +106 -0
  30. package/template/__dot__rulesync/hooks.json +27 -4
  31. package/template/__dot__rulesync/rules/conventions.md +114 -0
  32. package/template/__dot__rulesync/rules/db-conventions.md +6 -72
  33. package/template/__dot__rulesync/rules/e2e-conventions.md +52 -0
  34. package/template/__dot__rulesync/rules/frontend-conventions.md.tpl +35 -0
  35. package/template/__dot__rulesync/rules/oss-boundaries.md.tpl +78 -0
  36. package/template/__dot__rulesync/rules/overview.md +18 -73
  37. package/template/__dot__rulesync/skills/create-plugin/{SKILL.md → SKILL.md.tpl} +13 -24
  38. package/template/__dot__rulesync/skills/create-pr/SKILL.md.tpl +38 -0
  39. package/template/__dot__rulesync/skills/create-task/{SKILL.md → SKILL.md.tpl} +14 -22
  40. package/template/__dot__rulesync/skills/create-ui-module/SKILL.md.tpl +60 -0
  41. package/template/__dot__rulesync/skills/enhance-prompt/{SKILL.md → SKILL.md.tpl} +4 -4
  42. package/template/__dot__rulesync/skills/handoff/SKILL.md +95 -0
  43. package/template/__dot__rulesync/skills/review/SKILL.md.tpl +277 -0
  44. package/template/__dot__rulesync/skills/ship/SKILL.md.tpl +149 -0
  45. package/template/__dot__rulesync/skills/ship/handoff.md.tpl +48 -0
  46. package/template/__dot__rulesync/skills/ship/watch-pr.mjs +168 -0
  47. package/template/__dot__rulesync/subagents/builder.md.tpl +115 -0
  48. package/template/__dot__rulesync/subagents/compliance-reviewer.md +78 -0
  49. package/template/__dot__rulesync/subagents/debugger.md.tpl +86 -0
  50. package/template/__dot__rulesync/subagents/deployer.md.tpl +54 -0
  51. package/template/__dot__rulesync/subagents/expert.md +28 -34
  52. package/template/__dot__rulesync/subagents/planner.md.tpl +39 -0
  53. package/template/__dot__rulesync/subagents/quality-reviewer.md.tpl +112 -0
  54. package/template/__dot__rulesync/subagents/security-reviewer.md.tpl +85 -0
  55. package/template/__dot__rulesync/subagents/tester.md.tpl +88 -0
  56. package/template/__dot__rulesync/sync.json.tpl +26 -0
  57. package/template/apps/api/package.json.tpl +1 -2
  58. package/template/apps/api/src/extensions.config.ts.tpl +12 -0
  59. package/template/apps/api/src/main.ts.tpl +1 -4
  60. package/template/apps/api/src/seed.ts.tpl +8 -1
  61. package/template/docker-compose.yml +13 -0
  62. package/template/docs/agents/cross-repo.md.tpl +37 -0
  63. package/template/docs/agents/forge.md.tpl +54 -0
  64. package/template/docs/agents/issue-tracker.md.tpl +42 -0
  65. package/template/docs/standards/comments.md +16 -0
  66. package/template/docs/standards/database.md +77 -0
  67. package/template/docs/standards/enforcement.md +12 -0
  68. package/template/docs/standards/errors.md +26 -0
  69. package/template/docs/standards/frontend.md +60 -0
  70. package/template/docs/standards/functions.md +38 -0
  71. package/template/docs/standards/git-delivery.md +20 -0
  72. package/template/docs/standards/module-structure.md +37 -0
  73. package/template/docs/standards/testing.md +72 -0
  74. package/template/docs/standards/types.md +31 -0
  75. package/template/package.json.tpl +11 -5
  76. package/template/rulesync.jsonc +1 -1
  77. package/template/tools/oss-worktree.mjs.tpl +105 -0
  78. package/template/tools/review-precheck.mjs +531 -0
  79. package/template/tools/sync-agents.mjs +257 -0
  80. package/template/turbo/generators/config.ts +1 -1
  81. package/template/turbo.json +14 -2
  82. package/template/__dot__rulesync/hooks/post-edit.mjs +0 -57
  83. package/template/__dot__rulesync/skills/add-feature/SKILL.md +0 -113
  84. package/template/__dot__rulesync/skills/add-feature/handoff.md +0 -58
  85. package/template/__dot__rulesync/skills/code-review/SKILL.md +0 -111
  86. package/template/__dot__rulesync/skills/create-pr/SKILL.md +0 -55
  87. package/template/__dot__rulesync/subagents/builder.md +0 -93
  88. package/template/__dot__rulesync/subagents/debugger.md +0 -83
  89. package/template/__dot__rulesync/subagents/deployer.md +0 -66
  90. package/template/__dot__rulesync/subagents/qa.md +0 -88
  91. package/template/apps/api/src/migrate.ts.tpl +0 -46
@@ -0,0 +1,531 @@
1
+ #!/usr/bin/env node
2
+ // Deterministic facts for the `review` skill, computed before any reviewer agent spends tokens:
3
+ // which changed files are worth an agent's attention, mechanical convention hits on the lines
4
+ // this change added, and whether security- or compliance-sensitive code is touched at all.
5
+ //
6
+ // pnpm review:precheck --base origin/dev [--head <ref>] [--since <sha>]
7
+ //
8
+ // Diff-scoped on purpose: it reads only lines the change added, so existing code never floods
9
+ // the output. It informs and never fails - the review verdict is the gate. Per-repo settings
10
+ // live under `reviewPrecheck` in .rulesync/sync.json.
11
+ import { execFileSync, spawnSync } from 'node:child_process';
12
+ import { posix } from 'node:path';
13
+ // oxlint-disable no-console
14
+
15
+ const MAX_LINES_PER_CHECK = 25;
16
+ const MAX_HITS_PER_DOMAIN = 20;
17
+ const CODE_FILE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
18
+
19
+ const die = (message) => {
20
+ console.error(`review:precheck - ${message}`);
21
+ process.exit(1);
22
+ };
23
+
24
+ const args = (() => {
25
+ const argv = process.argv.slice(2);
26
+ const read = (flag) => {
27
+ const index = argv.indexOf(flag);
28
+ return index === -1 ? undefined : argv[index + 1];
29
+ };
30
+ const base = read('--base');
31
+ if (!base) {
32
+ die('usage: review:precheck --base <ref> [--head <ref>] [--since <sha>]');
33
+ }
34
+ return { base, head: read('--head') ?? 'HEAD', since: read('--since') };
35
+ })();
36
+
37
+ const git = (...gitArgs) =>
38
+ execFileSync('git', gitArgs, {
39
+ encoding: 'utf8',
40
+ maxBuffer: 256 * 1024 * 1024,
41
+ stdio: ['ignore', 'pipe', 'ignore'],
42
+ });
43
+ const gitOk = (...gitArgs) => spawnSync('git', gitArgs, { stdio: 'ignore' }).status === 0;
44
+
45
+ // Read from the trusted base revision, never the head branch: a PR that widened its own
46
+ // extraSkipGlobs to skip review of its own changes must not be able to trust itself.
47
+ const config = (() => {
48
+ const path = '.rulesync/sync.json';
49
+ if (!gitOk('cat-file', '-e', `${args.base}:${path}`)) {
50
+ return {};
51
+ }
52
+ try {
53
+ return JSON.parse(git('show', `${args.base}:${path}`)).reviewPrecheck ?? {};
54
+ } catch (err) {
55
+ return die(`${path} is not valid JSON at ${args.base}: ${err.message}`);
56
+ }
57
+ })();
58
+
59
+ const globToRegExp = (glob) =>
60
+ new RegExp(
61
+ `^${glob
62
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
63
+ .replace(/\*\*\//g, '(?:.*/)?')
64
+ .replace(/\*\*/g, '.*')
65
+ .replace(/\*/g, '[^/]*')}$`,
66
+ );
67
+
68
+ const SKIP_RULES = [
69
+ { reason: 'lockfile', re: /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?)$/ },
70
+ { reason: 'locale data (see i18n checks)', re: /(^|\/)locales\/.*\.json$/ },
71
+ { reason: 'test', re: /(^|\/)__tests__\/|\.(test|spec)\.[cm]?[jt]sx?$|\.snap$/ },
72
+ {
73
+ reason: 'generated',
74
+ re: /(^|\/)drizzle\/migrations\/meta\/|(^|\/)next-env\.d\.ts$|routeTree\.gen\.ts$/,
75
+ },
76
+ ...(config.extraSkipGlobs ?? []).map((glob) => ({ reason: 'skip glob', re: globToRegExp(glob) })),
77
+ ];
78
+ const skipReason = (path) => SKIP_RULES.find(({ re }) => re.test(path))?.reason;
79
+ const under = (paths, file) =>
80
+ (paths ?? []).some((root) => file === root || file.startsWith(`${root.replace(/\/$/, '')}/`));
81
+
82
+ // ---- scope ---------------------------------------------------------------------------------
83
+
84
+ const range = `${args.base}...${args.head}`;
85
+ if (
86
+ !gitOk('rev-parse', '--verify', '--quiet', args.base) ||
87
+ !gitOk('rev-parse', '--verify', '--quiet', args.head)
88
+ ) {
89
+ die(`cannot resolve ${args.base} or ${args.head} - fetch them first`);
90
+ }
91
+
92
+ const numstat = new Map(
93
+ git('diff', '--numstat', '--no-renames', range)
94
+ .split('\n')
95
+ .filter(Boolean)
96
+ .map((row) => {
97
+ const [added, deleted, path] = row.split('\t');
98
+ return [path, { added: Number(added) || 0, deleted: Number(deleted) || 0 }];
99
+ }),
100
+ );
101
+
102
+ // ---- added and removed lines ---------------------------------------------------------------
103
+
104
+ const parseDiff = (diffRange) => {
105
+ const added = [];
106
+ const removed = new Map();
107
+ let oldFile;
108
+ let file;
109
+ let line = 0;
110
+ for (const row of git('diff', '-U0', '--no-renames', diffRange).split('\n')) {
111
+ if (row.startsWith('--- ')) {
112
+ oldFile = row === '--- /dev/null' ? undefined : row.slice(6);
113
+ } else if (row.startsWith('+++ ')) {
114
+ file = row === '+++ /dev/null' ? oldFile : row.slice(6);
115
+ } else if (row.startsWith('@@')) {
116
+ line = Number(row.match(/\+(\d+)/)?.[1] ?? 0);
117
+ } else if (file && row.startsWith('+')) {
118
+ added.push({ file, line, text: row.slice(1) });
119
+ line += 1;
120
+ } else if (file && row.startsWith('-')) {
121
+ removed.set(file, [...(removed.get(file) ?? []), row.slice(1).trim()]);
122
+ }
123
+ }
124
+ return { added, removed };
125
+ };
126
+
127
+ // Hunks since the last review, minus what a base-branch merge brought in: an added line counts
128
+ // only where the whole change added it too, a removed line only where the base did not remove it.
129
+ const sinceLastReview = (changeDiff) => {
130
+ const sinceDiff = parseDiff(`${args.since}..${args.head}`);
131
+ const mergeBase = (ref) => git('merge-base', ref, args.base).trim();
132
+ const baseMerged = parseDiff(`${mergeBase(args.since)}..${mergeBase(args.head)}`).removed;
133
+ const changeAdded = new Set(changeDiff.added.map(({ file, line }) => `${file}:${line}`));
134
+ return {
135
+ added: sinceDiff.added.filter(({ file, line }) => changeAdded.has(`${file}:${line}`)),
136
+ removed: new Map(
137
+ [...sinceDiff.removed].map(([file, texts]) => [
138
+ file,
139
+ texts.filter((text) => !(baseMerged.get(file) ?? []).includes(text)),
140
+ ]),
141
+ ),
142
+ };
143
+ };
144
+
145
+ const notes = [];
146
+ let mode = 'full';
147
+ let scoped = [...numstat.keys()];
148
+ let diff = parseDiff(range);
149
+ if (args.since) {
150
+ if (gitOk('merge-base', '--is-ancestor', args.since, args.head)) {
151
+ const changedSince = new Set(
152
+ git('diff', '--name-only', '--no-renames', args.since, args.head).split('\n').filter(Boolean),
153
+ );
154
+ scoped = scoped.filter((path) => changedSince.has(path));
155
+ diff = sinceLastReview(diff);
156
+ mode = 'incremental';
157
+ } else {
158
+ notes.push(`NOTE: --since ${args.since} is not an ancestor of ${args.head} - full review`);
159
+ }
160
+ }
161
+
162
+ const reviewable = scoped.filter((path) => !skipReason(path));
163
+ const skipped = scoped.filter((path) => skipReason(path));
164
+ const codeFiles = new Set(reviewable.filter((path) => CODE_FILE.test(path)));
165
+ const added = diff.added.filter(({ file }) => codeFiles.has(file));
166
+ const removed = new Map([...diff.removed].filter(([file]) => codeFiles.has(file)));
167
+
168
+ // ---- mechanical checks ---------------------------------------------------------------------
169
+
170
+ const findings = new Map();
171
+ const report = (check, severity, location, detail) =>
172
+ findings.set(check, [
173
+ ...(findings.get(check) ?? []),
174
+ `[${severity}] ${location} - ${check} - ${detail}`,
175
+ ]);
176
+
177
+ const CAST = /\bas\s+(?!const\b)[A-Za-z_$][\w.$]*(?:<[^>]*>)?(?:\[\])?/;
178
+ const LIMIT_LITERAL = /\b(limit|pageSize|perPage|take)\s*:\s*\d+\b/;
179
+ const LIMIT_CONSTANT = /\b[A-Z][A-Z0-9_]*(LIMIT|PAGE_SIZE|PER_PAGE|MAX_ROWS)\s*=\s*\d+/;
180
+ const TS_ENUM = /^\s*(?:export\s+)?(?:declare\s+)?(?:const\s+)?enum\s+\w/;
181
+ const HAND_MEMO = /\b(useMemo|useCallback|React\.memo|memo)\s*\(/;
182
+ const STRING_LITERAL = /(['"`])((?:\\.|(?!\1).)*)\1/g;
183
+ const CLASS_TOKEN = /^[a-z0-9:!\[\]\-/.%#_]+$/;
184
+ const NON_CLASS_ATTRIBUTE = /\b(?!class(?:Name)?\b)[\w-]+=\{?\s*$/;
185
+ const bannedClassRules = config.bannedClasses ?? [];
186
+
187
+ for (const { file, line, text } of added) {
188
+ const location = `${file}:${line}`;
189
+ const isReexport =
190
+ /^\s*(import|export)\b/.test(text) ||
191
+ /\*\s+as\s+\w/.test(text) ||
192
+ /^\s*\w+\s+as\s+\w+,?\s*$/.test(text);
193
+ if (!isReexport && CAST.test(text)) {
194
+ report('type-cast', 'WARN', location, `\`${text.match(CAST)[0]}\``);
195
+ }
196
+ const widened = text.trim().replace(/Partial<(Record<.*>)>/, '$1');
197
+ if (widened !== text.trim() && (removed.get(file) ?? []).includes(widened)) {
198
+ report(
199
+ 'type-cast',
200
+ 'WARN',
201
+ location,
202
+ '`Record` widened to `Partial<Record>` - exhaustiveness check lost',
203
+ );
204
+ }
205
+ const limit = text.match(LIMIT_LITERAL) ?? text.match(LIMIT_CONSTANT);
206
+ if (limit) {
207
+ report(
208
+ 'hardcoded-limit',
209
+ 'INFO',
210
+ location,
211
+ `\`${limit[0]}\` - rows past it must stay reachable`,
212
+ );
213
+ }
214
+ if (TS_ENUM.test(text)) {
215
+ report('ts-enum', 'WARN', location, '`enum` - use a values + schema + type triple');
216
+ }
217
+ if (under(config.reactCompilerPaths, file) && HAND_MEMO.test(text)) {
218
+ report(
219
+ 'hand-memo',
220
+ 'WARN',
221
+ location,
222
+ `\`${text.match(HAND_MEMO)[0]}\` under the React Compiler`,
223
+ );
224
+ }
225
+ const bannedTokens = bannedClassRules
226
+ .filter(({ paths }) => under(paths, file))
227
+ .flatMap(({ tokens }) => tokens);
228
+ if (bannedTokens.length > 0) {
229
+ const looksLikeClasses = /class(Name)?\b/.test(text);
230
+ for (const match of text.matchAll(STRING_LITERAL)) {
231
+ const tokens = match[2].trim().split(/\s+/);
232
+ const boundToOtherAttribute = NON_CLASS_ATTRIBUTE.test(text.slice(0, match.index));
233
+ const classShaped = tokens.every((token) => CLASS_TOKEN.test(token));
234
+ if (boundToOtherAttribute || !classShaped || (!looksLikeClasses && tokens.length < 2)) {
235
+ continue;
236
+ }
237
+ const hits = tokens.filter((token) =>
238
+ bannedTokens.some((banned) => token === banned || token.startsWith(`${banned}-`)),
239
+ );
240
+ if (hits.length > 0) {
241
+ report('banned-class', 'WARN', location, `\`${[...new Set(hits)].join(' ')}\``);
242
+ }
243
+ }
244
+ }
245
+ }
246
+ if (!config.reactCompilerPaths) {
247
+ report('hand-memo', 'INFO', '-', 'not configured (reviewPrecheck.reactCompilerPaths)');
248
+ }
249
+ if (bannedClassRules.length === 0) {
250
+ report('banned-class', 'INFO', '-', 'not configured (reviewPrecheck.bannedClasses)');
251
+ }
252
+
253
+ // ---- i18n ----------------------------------------------------------------------------------
254
+
255
+ const DEFAULT_LOCALE = config.defaultLocale ?? 'en';
256
+ const LOCALE_FILE = /^(?:(.+)-)?([a-z]{2}(?:-[A-Z]{2})?)\.json$/;
257
+
258
+ const flattenKeys = (value, prefix = '') =>
259
+ value && typeof value === 'object' && !Array.isArray(value)
260
+ ? Object.entries(value).flatMap(([key, child]) =>
261
+ flattenKeys(child, prefix ? `${prefix}.${key}` : key),
262
+ )
263
+ : [prefix];
264
+ const keysAt = (ref, path) => {
265
+ try {
266
+ return new Set(flattenKeys(JSON.parse(git('show', `${ref}:${path}`))));
267
+ } catch {
268
+ return new Set();
269
+ }
270
+ };
271
+ const localeFilesIn = (dir) =>
272
+ git('ls-tree', '--name-only', `${args.head}:${dir}`)
273
+ .split('\n')
274
+ .filter((name) => LOCALE_FILE.test(name));
275
+ const treeExists = (dir) => gitOk('cat-file', '-e', `${args.head}:${dir}`);
276
+
277
+ const touchedLocaleDirs = new Set(
278
+ scoped.map((path) => path.match(/^(.*\/locales)\/[^/]+\.json$/)?.[1]).filter(Boolean),
279
+ );
280
+ const mergeBase = git('merge-base', args.base, args.head).trim();
281
+ for (const dir of touchedLocaleDirs) {
282
+ if (!treeExists(dir)) {
283
+ continue;
284
+ }
285
+ const groups = new Map();
286
+ for (const name of localeFilesIn(dir)) {
287
+ const [, prefix = ''] = name.match(LOCALE_FILE);
288
+ groups.set(prefix, [...(groups.get(prefix) ?? []), `${dir}/${name}`]);
289
+ }
290
+ for (const files of groups.values()) {
291
+ if (files.length < 2) {
292
+ continue;
293
+ }
294
+ const headKeys = files.map((path) => ({ path, keys: keysAt(args.head, path) }));
295
+ const baseKeys = files.map((path) => ({ path, keys: keysAt(mergeBase, path) }));
296
+ const baseUnion = new Set(baseKeys.flatMap(({ keys }) => [...keys]));
297
+ const headUnion = new Set(headKeys.flatMap(({ keys }) => [...keys]));
298
+ const newKeys = new Set(
299
+ headKeys.flatMap(({ keys }) => [...keys]).filter((key) => !baseUnion.has(key)),
300
+ );
301
+ const shown = (keys) => {
302
+ const list = [...keys];
303
+ return `${list.slice(0, 5).join(', ')}${list.length > 5 ? ` (+${list.length - 5} more)` : ''}`;
304
+ };
305
+ for (const { path, keys } of headKeys) {
306
+ const addedElsewhere = [...newKeys].filter((key) => !keys.has(key));
307
+ if (addedElsewhere.length > 0) {
308
+ report(
309
+ 'i18n-parity',
310
+ 'WARN',
311
+ `${path}:1`,
312
+ `missing ${addedElsewhere.length} key(s) a sibling locale added: ${shown(addedElsewhere)}`,
313
+ );
314
+ }
315
+ // Dropped from this locale alone: still present in base and in a sibling at head, so a
316
+ // sibling-only removal never floods the union but still breaks per-file parity.
317
+ const priorKeys = baseKeys.find((entry) => entry.path === path)?.keys ?? new Set();
318
+ const droppedHere = [...priorKeys].filter((key) => !keys.has(key) && headUnion.has(key));
319
+ if (droppedHere.length > 0) {
320
+ report(
321
+ 'i18n-parity',
322
+ 'WARN',
323
+ `${path}:1`,
324
+ `dropped ${droppedHere.length} key(s) a sibling locale still has: ${shown(droppedHere)}`,
325
+ );
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ const defaultKeysCache = new Map();
332
+ const defaultLocaleKeysFor = (file) => {
333
+ let dir = posix.dirname(file);
334
+ while (dir && dir !== '.') {
335
+ const localesDir = `${dir}/locales`;
336
+ if (defaultKeysCache.has(localesDir)) {
337
+ return defaultKeysCache.get(localesDir);
338
+ }
339
+ if (treeExists(localesDir)) {
340
+ const keys = new Set(
341
+ localeFilesIn(localesDir)
342
+ .filter((name) => name.match(LOCALE_FILE)[2] === DEFAULT_LOCALE)
343
+ .flatMap((name) => [...keysAt(args.head, `${localesDir}/${name}`)]),
344
+ );
345
+ defaultKeysCache.set(localesDir, keys);
346
+ return keys;
347
+ }
348
+ dir = posix.dirname(dir);
349
+ }
350
+ return undefined;
351
+ };
352
+ const T_CALL = /\bt\(\s*['"]([\w.-]+)['"]/g;
353
+ for (const { file, line, text } of added) {
354
+ for (const [, rawKey] of text.matchAll(T_CALL)) {
355
+ const keys = defaultLocaleKeysFor(file);
356
+ const key = rawKey.includes(':') ? rawKey.split(':').at(-1) : rawKey;
357
+ const known =
358
+ keys && ['', '_one', '_other', '_zero'].some((suffix) => keys.has(`${key}${suffix}`));
359
+ if (keys && !known) {
360
+ report(
361
+ 'i18n-missing-key',
362
+ 'INFO',
363
+ `${file}:${line}`,
364
+ `\`${key}\` not in the nearest ${DEFAULT_LOCALE} locale file (another namespace?)`,
365
+ );
366
+ }
367
+ }
368
+ }
369
+
370
+ // ---- domains -------------------------------------------------------------------------------
371
+
372
+ const DOMAIN_PATTERNS = {
373
+ security: [
374
+ 'auth',
375
+ 'session',
376
+ 'guard',
377
+ 'permission',
378
+ 'process\\.env',
379
+ 'import\\.meta\\.env',
380
+ 'secret',
381
+ '\\btoken\\b',
382
+ 'webhook',
383
+ 'signature',
384
+ 'adapter',
385
+ '\\bfetch\\(',
386
+ 'dangerouslySetInnerHTML',
387
+ '\\b(href|src)=\\{',
388
+ 'password',
389
+ 'cookie',
390
+ 'cors',
391
+ ],
392
+ compliance: [
393
+ 'wallet',
394
+ 'ledger',
395
+ // Not a hyphenated utility class such as Tailwind's `text-balance`.
396
+ '(?<!-)balance',
397
+ 'deposit',
398
+ 'withdraw',
399
+ '\\bkyc\\b',
400
+ 'age.?verif',
401
+ 'date.?of.?birth',
402
+ '\\bgeo',
403
+ 'jurisdiction',
404
+ 'self.?exclu',
405
+ 'cool.?(off|ing)',
406
+ '\\brg\\b',
407
+ 'responsible.?gambl',
408
+ '(deposit|loss|wager|session|time).?limit',
409
+ 'wager',
410
+ 'bonus',
411
+ 'audit',
412
+ '\\baml\\b',
413
+ ],
414
+ };
415
+ const domainHitFiles = new Map();
416
+ for (const [domain, defaults] of Object.entries(DOMAIN_PATTERNS)) {
417
+ const patterns = [...defaults, ...(config.domainPatterns?.[domain] ?? [])].map(
418
+ (source) => new RegExp(source, 'i'),
419
+ );
420
+ const hits = [
421
+ ...reviewable.flatMap((path) => {
422
+ const pattern = patterns.find((re) => re.test(path));
423
+ return pattern ? [`${path} - path matches /${pattern.source}/`] : [];
424
+ }),
425
+ ...added.flatMap(({ file, line, text }) => {
426
+ const pattern = patterns.find((re) => re.test(text));
427
+ return pattern ? [`${file}:${line} - /${pattern.source}/`] : [];
428
+ }),
429
+ // A removed line can delete a guard or a limit check as easily as an added line can
430
+ // introduce one - a path with no added domain keyword can still lose one.
431
+ ...[...removed.entries()].flatMap(([file, texts]) =>
432
+ texts.flatMap((text) => {
433
+ const pattern = patterns.find((re) => re.test(text));
434
+ return pattern ? [`${file} - /${pattern.source}/ (removed)`] : [];
435
+ }),
436
+ ),
437
+ ];
438
+ domainHitFiles.set(
439
+ domain,
440
+ hits.map((hit) => hit.split(' - ')[0].replace(/:\d+$/, '')),
441
+ );
442
+ findings.set(`domain:${domain}`, [
443
+ `DOMAIN: ${domain} hits ${hits.length}`,
444
+ ...hits.slice(0, MAX_HITS_PER_DOMAIN).map((hit) => `DOMAIN-HIT: ${domain} ${hit}`),
445
+ ...(hits.length > MAX_HITS_PER_DOMAIN
446
+ ? [`DOMAIN-HIT: ${domain} +${hits.length - MAX_HITS_PER_DOMAIN} more`]
447
+ : []),
448
+ ]);
449
+ }
450
+
451
+ // ---- risk tier ----------------------------------------------------------------------------
452
+ // Picks the reviewer's effort from what the diff touches, never from the title or its size:
453
+ // a 30-line change can still add a route, a table, or a migration.
454
+
455
+ const BACKEND_PATHS = [
456
+ /(^|\/)(apps\/api|api|server|schema|drizzle|migrations?|migrate|seeds?|contracts?|routers?|routes?|services?|middleware)(\/|\.|$)/,
457
+ /(^|\/)(extensions?|plugins?|jobs?|workers?)\//,
458
+ /-contract\//,
459
+ /\.sql$/,
460
+ /(^|\/)(\.env|env\.ts|Dockerfile|package\.json$)/,
461
+ /(^|\/)\.github\/workflows\//,
462
+ ];
463
+ const FRONTEND_LOGIC_PATHS = [/(^|\/)(hooks?|utils?|lib|stores?)\//, /\.[cm]?[jt]s$/];
464
+
465
+ const E2E_PATH = /(^|\/)e2e\//;
466
+ const isBackend = (path) => BACKEND_PATHS.some((re) => re.test(path));
467
+ const isLogic = (path) => !E2E_PATH.test(path) && FRONTEND_LOGIC_PATHS.some((re) => re.test(path));
468
+
469
+ // A security keyword in a component or a layout ("session" in a skeleton, an `href`) says little;
470
+ // the same keyword in a service, a router, or a hook is where the risk lives. Compliance keywords
471
+ // stay strong on any screen (an RG or KYC page), and e2e page objects never raise the tier.
472
+ function riskTier() {
473
+ // A path hit on a doc or an asset (`docs/wallet.md`) is not code that moves money.
474
+ const compliance = domainHitFiles
475
+ .get('compliance')
476
+ .filter((path) => !E2E_PATH.test(path) && CODE_FILE.test(path));
477
+ if (compliance.length > 0)
478
+ return `critical - compliance hits ${compliance.length} in ${compliance[0]}`;
479
+ const security = domainHitFiles
480
+ .get('security')
481
+ .filter((path) => isBackend(path) || isLogic(path));
482
+ if (security.length > 0) return `high - security hits ${security.length} in ${security[0]}`;
483
+ const backend = reviewable.find(isBackend);
484
+ if (backend) return `high - ${backend} is server-side`;
485
+ const logic = reviewable.find(isLogic);
486
+ if (logic) return `medium - ${logic} is client logic`;
487
+ const keyword = [...domainHitFiles.values()].flat().find((path) => !E2E_PATH.test(path));
488
+ if (keyword) return `medium - domain keyword in ${keyword}`;
489
+ return 'low - components, styles, or copy only';
490
+ }
491
+
492
+ // ---- output --------------------------------------------------------------------------------
493
+
494
+ const lines = (path) =>
495
+ mode === 'incremental'
496
+ ? {
497
+ added: diff.added.filter(({ file }) => file === path).length,
498
+ deleted: diff.removed.get(path)?.length ?? 0,
499
+ }
500
+ : numstat.get(path);
501
+ const totals = scoped.reduce(
502
+ (sum, path) => ({
503
+ added: sum.added + lines(path).added,
504
+ deleted: sum.deleted + lines(path).deleted,
505
+ }),
506
+ { added: 0, deleted: 0 },
507
+ );
508
+ const reviewableChanged = reviewable.reduce(
509
+ (sum, path) => sum + lines(path).added + lines(path).deleted,
510
+ 0,
511
+ );
512
+
513
+ console.log(
514
+ `SCOPE: files ${scoped.length} lines +${totals.added}/-${totals.deleted} reviewable ${reviewable.length} (${reviewableChanged} changed lines) skipped ${skipped.length} mode ${mode}`,
515
+ );
516
+ console.log(`RISK: ${riskTier()}`);
517
+ notes.forEach((note) => console.log(note));
518
+ reviewable.forEach((path) =>
519
+ console.log(`REVIEWABLE: ${path} +${lines(path).added}/-${lines(path).deleted}`),
520
+ );
521
+ skipped.forEach((path) => console.log(`SKIPPED: ${path} - ${skipReason(path)}`));
522
+ for (const [check, rows] of findings) {
523
+ if (check.startsWith('domain:')) {
524
+ rows.forEach((row) => console.log(row));
525
+ continue;
526
+ }
527
+ rows.slice(0, MAX_LINES_PER_CHECK).forEach((row) => console.log(`PRECHECK: ${row}`));
528
+ if (rows.length > MAX_LINES_PER_CHECK) {
529
+ console.log(`PRECHECK: [INFO] - ${check} - +${rows.length - MAX_LINES_PER_CHECK} more`);
530
+ }
531
+ }