@the-i18n-kit/cli 4.10.0 → 5.0.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.
Files changed (83) hide show
  1. package/README.md +29 -296
  2. package/dist/{_shared-Dn15c_g9.js → _shared-DFyy3sh8.js} +24 -12
  3. package/dist/_shared-DFyy3sh8.js.map +1 -0
  4. package/dist/{add-Dj6hsdl9.js → add-C-qs8iBa.js} +5 -5
  5. package/dist/{add-Dj6hsdl9.js.map → add-C-qs8iBa.js.map} +1 -1
  6. package/dist/bin.js +1 -1
  7. package/dist/{check-BI-CGxpY.js → check-BI1orjXb.js} +11 -19
  8. package/dist/check-BI1orjXb.js.map +1 -0
  9. package/dist/cli-DsFuVJNP.js +74 -0
  10. package/dist/cli-DsFuVJNP.js.map +1 -0
  11. package/dist/config/framework/stubs/{next-intl-routing-Se7Y8cCb.d.ts → next-intl-routing-Cwaa3f4R.d.ts} +1 -1
  12. package/dist/config/framework/stubs/{next-intl-routing-Se7Y8cCb.d.ts.map → next-intl-routing-Cwaa3f4R.d.ts.map} +1 -1
  13. package/dist/config/framework/stubs/{unplugin-vue-i18n-DrR5pbEW.d.ts → unplugin-vue-i18n-1ud7-Ly8.d.ts} +1 -1
  14. package/dist/config/framework/stubs/{unplugin-vue-i18n-DrR5pbEW.d.ts.map → unplugin-vue-i18n-1ud7-Ly8.d.ts.map} +1 -1
  15. package/dist/define-config-4ubtfvUy.js.map +1 -1
  16. package/dist/{define-config-DpbZ4BZ6.d.ts → define-config-BaZ1dZph.d.ts} +11 -4
  17. package/dist/define-config-BaZ1dZph.d.ts.map +1 -0
  18. package/dist/{define-config-BSsvRnWR.d.ts → define-config-CLgKN-7N.d.ts} +1 -1
  19. package/dist/define-config.d.ts +1 -1
  20. package/dist/{detect-BmvyRD1a.js → detect-7cVfsNJP.js} +5 -5
  21. package/dist/{detect-BmvyRD1a.js.map → detect-7cVfsNJP.js.map} +1 -1
  22. package/dist/{empty-Ww6FnmgY.js → empty-DB8Hl_8u.js} +5 -5
  23. package/dist/{empty-Ww6FnmgY.js.map → empty-DB8Hl_8u.js.map} +1 -1
  24. package/dist/find-duplicates-CmEqBfby.js +42 -0
  25. package/dist/find-duplicates-CmEqBfby.js.map +1 -0
  26. package/dist/{get-oF_xIbVR.js → get-BE21Iy6N.js} +5 -5
  27. package/dist/{get-oF_xIbVR.js.map → get-BE21Iy6N.js.map} +1 -1
  28. package/dist/{index-JeU02vfH.d.ts → index-DCG3dOdC.d.ts} +332 -3
  29. package/dist/index-DCG3dOdC.d.ts.map +1 -0
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.js +4 -4
  32. package/dist/{init-D4B1_DXb.js → init-BtzmhsOM.js} +5 -5
  33. package/dist/{init-D4B1_DXb.js.map → init-BtzmhsOM.js.map} +1 -1
  34. package/dist/{list-dirs-CgXh-Lg1.js → list-dirs-DUQbwvlE.js} +5 -5
  35. package/dist/{list-dirs-CgXh-Lg1.js.map → list-dirs-DUQbwvlE.js.map} +1 -1
  36. package/dist/{missing-BZT6PgW5.js → missing-CcYrgd54.js} +5 -5
  37. package/dist/{missing-BZT6PgW5.js.map → missing-CcYrgd54.js.map} +1 -1
  38. package/dist/move-B2px6Ck-.js +50 -0
  39. package/dist/move-B2px6Ck-.js.map +1 -0
  40. package/dist/{operations-BCJd7in3.js → operations-CTo44gPu.js} +1332 -241
  41. package/dist/operations-CTo44gPu.js.map +1 -0
  42. package/dist/php-reader-3Fgw80zK.js +64 -0
  43. package/dist/php-reader-3Fgw80zK.js.map +1 -0
  44. package/dist/php-reader-CpnaPSpZ.js +2 -0
  45. package/dist/{providers-BAwPjeCy.js → providers-CHE2ffi6.js} +53 -27
  46. package/dist/providers-CHE2ffi6.js.map +1 -0
  47. package/dist/{remove-B7m-hi2O.js → remove-DTbqM4bR.js} +5 -5
  48. package/dist/{remove-B7m-hi2O.js.map → remove-DTbqM4bR.js.map} +1 -1
  49. package/dist/{remove-orphans-DOTWE7Ji.js → remove-orphans-Dfzu7hRz.js} +5 -5
  50. package/dist/{remove-orphans-DOTWE7Ji.js.map → remove-orphans-Dfzu7hRz.js.map} +1 -1
  51. package/dist/{rename-DEJWj5_P.js → rename-C_rFKkU4.js} +5 -5
  52. package/dist/{rename-DEJWj5_P.js.map → rename-C_rFKkU4.js.map} +1 -1
  53. package/dist/{scaffold-BH_OX-5z.js → scaffold-CUimlNqR.js} +5 -5
  54. package/dist/{scaffold-BH_OX-5z.js.map → scaffold-CUimlNqR.js.map} +1 -1
  55. package/dist/{scan-LeU4xG0G.js → scan-BkOIYhjZ.js} +5 -5
  56. package/dist/{scan-LeU4xG0G.js.map → scan-BkOIYhjZ.js.map} +1 -1
  57. package/dist/{search-CHdDywqc.js → search-0io1BcsY.js} +5 -5
  58. package/dist/{search-CHdDywqc.js.map → search-0io1BcsY.js.map} +1 -1
  59. package/dist/{status-CwI94d_R.js → status-Bp-SzjTN.js} +5 -5
  60. package/dist/{status-CwI94d_R.js.map → status-Bp-SzjTN.js.map} +1 -1
  61. package/dist/{translate-Cf3npNpr.js → translate-cCb2Z57w.js} +6 -6
  62. package/dist/{translate-Cf3npNpr.js.map → translate-cCb2Z57w.js.map} +1 -1
  63. package/dist/{translate-key-Bg_8B6c_.js → translate-key-DHLWEmrA.js} +5 -5
  64. package/dist/{translate-key-Bg_8B6c_.js.map → translate-key-DHLWEmrA.js.map} +1 -1
  65. package/dist/{update-DNv-dU1U.js → update-DV4ijRVB.js} +5 -5
  66. package/dist/{update-DNv-dU1U.js.map → update-DV4ijRVB.js.map} +1 -1
  67. package/dist/{write-DUwMSc75.js → write-Czzm_Nb0.js} +5 -5
  68. package/dist/{write-DUwMSc75.js.map → write-Czzm_Nb0.js.map} +1 -1
  69. package/package.json +15 -5
  70. package/dist/_shared-Dn15c_g9.js.map +0 -1
  71. package/dist/check-BI-CGxpY.js.map +0 -1
  72. package/dist/cli-CDhNbaUM.js +0 -82
  73. package/dist/cli-CDhNbaUM.js.map +0 -1
  74. package/dist/define-config-DpbZ4BZ6.d.ts.map +0 -1
  75. package/dist/find-duplicates-BfBBqP7I.js +0 -31
  76. package/dist/find-duplicates-BfBBqP7I.js.map +0 -1
  77. package/dist/index-JeU02vfH.d.ts.map +0 -1
  78. package/dist/operations-BCJd7in3.js.map +0 -1
  79. package/dist/php-reader-DcgOAhTw.js +0 -2
  80. package/dist/php-reader-DuZ0Hyl_.js +0 -32
  81. package/dist/php-reader-DuZ0Hyl_.js.map +0 -1
  82. package/dist/providers-BAwPjeCy.js.map +0 -1
  83. /package/dist/{bin-CF8fSc-R.d.ts → bin-DrKRgnr9.d.ts} +0 -0
@@ -1,6 +1,6 @@
1
1
  import { i as toErrorMessage, n as FileIOError, r as ToolError, t as ConfigError } from "./errors-coI1dhw1.js";
2
- import { c as validateProjectConfig, l as log, n as TranslateProviderError, o as CONFIG_FILENAME, s as loadProjectConfig } from "./providers-BAwPjeCy.js";
3
- import { n as readPhpLocaleFile, t as clearPhpFileCacheEntry } from "./php-reader-DuZ0Hyl_.js";
2
+ import { c as validateProjectConfig, l as log, n as TranslateProviderError, o as CONFIG_FILENAME, s as loadProjectConfig } from "./providers-CHE2ffi6.js";
3
+ import { n as readPhpLocaleFile, t as clearPhpFileCacheEntry } from "./php-reader-3Fgw80zK.js";
4
4
  import { createRequire } from "node:module";
5
5
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
@@ -539,13 +539,6 @@ const layerSchema = z.object({
539
539
  rootDir: z.string().min(1),
540
540
  localeDir: z.string().min(1).optional()
541
541
  });
542
- /**
543
- * Authoring policy declared in nuxt.config.ts. Deliberately permissive: the
544
- * shape is owned by ProjectConfig's own schema, which validates it once the two
545
- * sources are merged. Re-stating it here would be a second definition to keep
546
- * in step.
547
- */
548
- const policySchema = z.record(z.string(), z.unknown());
549
542
  const artifactSchema = z.object({
550
543
  version: z.literal(1),
551
544
  generator: z.string(),
@@ -559,8 +552,7 @@ const artifactSchema = z.object({
559
552
  ]),
560
553
  localeFileFormat: z.literal("json"),
561
554
  locales: z.array(localeSchema).min(1),
562
- layers: z.array(layerSchema).min(1),
563
- policy: policySchema.optional()
555
+ layers: z.array(layerSchema).min(1)
564
556
  });
565
557
  /**
566
558
  * Read an app's artifact, or null when there isn't a usable one.
@@ -633,7 +625,6 @@ function staleAgainst(artifact, artifactPath) {
633
625
  * fallback path producing the same names for the same project.
634
626
  */
635
627
  async function artifactToConfig(artifact, appDir, discoveryRoot, projectConfig) {
636
- const merged = mergePolicy(artifact.policy, projectConfig);
637
628
  const localeDirs = [];
638
629
  const claimed = /* @__PURE__ */ new Map();
639
630
  const usedLayerNames = /* @__PURE__ */ new Set();
@@ -666,31 +657,13 @@ async function artifactToConfig(artifact, appDir, discoveryRoot, projectConfig)
666
657
  rootDir: appDir,
667
658
  defaultLocale: artifact.defaultLocale,
668
659
  fallbackLocale: normalizeFallbackLocale(artifact.fallbackLocale, artifact.defaultLocale),
669
- locales: applyLocaleOverride(locales, merged?.locales),
660
+ locales: applyLocaleOverride(locales, projectConfig?.locales),
670
661
  localeDirs,
671
662
  layerRootDirs,
672
- projectConfig: merged,
663
+ projectConfig: projectConfig ?? void 0,
673
664
  apps: [app]
674
665
  };
675
666
  }
676
- /**
677
- * Fold the policy declared in nuxt.config.ts into the policy read from
678
- * .i18n-mcp.json.
679
- *
680
- * The file wins on a collision, which should never happen: the module fails the
681
- * build when a key is declared in both. This is the belt to that braces — an
682
- * artifact generated before the check existed, or by a newer module, cannot
683
- * quietly override a value someone can see in their repository.
684
- */
685
- function mergePolicy(policy, projectConfig) {
686
- if (!policy || Object.keys(policy).length === 0) return projectConfig ?? void 0;
687
- const declared = Object.keys(policy).filter((key) => projectConfig?.[key] !== void 0);
688
- if (declared.length > 0) log.warn(`Ignoring ${declared.join(", ")} from the Nuxt artifact: also declared in .i18n-mcp.json, which wins.`);
689
- return {
690
- ...policy,
691
- ...projectConfig
692
- };
693
- }
694
667
  //#endregion
695
668
  //#region src/adapters/nuxt/merge-apps.ts
696
669
  /**
@@ -711,13 +684,6 @@ var AppMerger = class {
711
684
  apps = [];
712
685
  defaultLocale = "en";
713
686
  fallbackLocale = { default: ["en"] };
714
- /**
715
- * Authoring policy contributed by the apps themselves — each app's artifact
716
- * may carry what its nuxt.config declared. Accumulated first-wins, then
717
- * overlaid by .i18n-mcp.json, which is the source a reader can see without
718
- * building.
719
- */
720
- policy = {};
721
687
  claimedPaths = /* @__PURE__ */ new Map();
722
688
  seenLocaleCodes = /* @__PURE__ */ new Set();
723
689
  usedLayerNames = /* @__PURE__ */ new Set();
@@ -726,17 +692,12 @@ var AppMerger = class {
726
692
  this.defaultLocale = appConfig.defaultLocale;
727
693
  this.fallbackLocale = appConfig.fallbackLocale;
728
694
  }
729
- this.collectPolicy(appConfig);
730
695
  const renamedLayers = await this.claimDirs(appConfig, discoveryRoot);
731
696
  this.collectLocales(appConfig);
732
697
  this.collectLayerRoots(appConfig);
733
698
  this.collectApps(appConfig, renamedLayers);
734
699
  }
735
700
  toConfig(discoveryRoot, projectConfig, locales) {
736
- const merged = Object.keys(this.policy).length > 0 ? {
737
- ...this.policy,
738
- ...projectConfig
739
- } : projectConfig;
740
701
  return {
741
702
  rootDir: discoveryRoot,
742
703
  defaultLocale: this.defaultLocale,
@@ -744,7 +705,7 @@ var AppMerger = class {
744
705
  locales,
745
706
  localeDirs: this.localeDirs,
746
707
  layerRootDirs: this.layerRootDirs,
747
- projectConfig: merged,
708
+ projectConfig,
748
709
  apps: this.apps
749
710
  };
750
711
  }
@@ -775,9 +736,6 @@ var AppMerger = class {
775
736
  if (!this.usedLayerNames.has(dir.layer)) return dir.layer;
776
737
  return deriveLayerName(dir.layerRootDir, discoveryRoot, this.usedLayerNames);
777
738
  }
778
- collectPolicy(appConfig) {
779
- for (const [key, value] of Object.entries(appConfig.projectConfig ?? {})) if (value !== void 0 && this.policy[key] === void 0) this.policy[key] = value;
780
- }
781
739
  collectLocales(appConfig) {
782
740
  for (const locale of appConfig.locales) {
783
741
  if (this.seenLocaleCodes.has(locale.code)) continue;
@@ -2103,6 +2061,17 @@ function buildLayerGraph(config) {
2103
2061
  layersOfApp
2104
2062
  };
2105
2063
  }
2064
+ /** Flatten {@link buildLayerGraph}'s view of `config` into plain JSON. */
2065
+ function serializeLayerGraph(config) {
2066
+ const graph = buildLayerGraph(config);
2067
+ const canonical = graph.canonicalLayers.map((dir) => dir.layer);
2068
+ return {
2069
+ canonical,
2070
+ shared: graph.sharedLayers.map((dir) => dir.layer),
2071
+ aliases: Object.fromEntries((config.localeDirs ?? []).filter((dir) => dir.aliasOf).map((dir) => [dir.layer, graph.ownerOf(dir.layer)])),
2072
+ consumers: Object.fromEntries(canonical.map((layer) => [layer, graph.appsUsingLayer(layer)]))
2073
+ };
2074
+ }
2106
2075
  //#endregion
2107
2076
  //#region src/io/read-cache.ts
2108
2077
  /**
@@ -2521,7 +2490,7 @@ function detectPhpStyle(content) {
2521
2490
  * are inserted in sorted position among their siblings.
2522
2491
  */
2523
2492
  async function mutatePhpLocaleFile(filePath, mutate) {
2524
- const { readPhpLocaleFile } = await import("./php-reader-DcgOAhTw.js");
2493
+ const { readPhpLocaleFile } = await import("./php-reader-CpnaPSpZ.js");
2525
2494
  const data = await readPhpLocaleFile(filePath);
2526
2495
  const { quoteStyle, indent } = detectPhpStyle(await readFile(filePath, "utf-8"));
2527
2496
  const reference = structuredClone(data);
@@ -4016,34 +3985,565 @@ function propagateCounts(node) {
4016
3985
  node.keyCount = total;
4017
3986
  return total;
4018
3987
  }
4019
- const VUE_NUXT_PATTERNS = {
4020
- label: "Vue / Nuxt",
4021
- filePatterns: [
4022
- "**/*.vue",
4023
- "**/*.ts",
4024
- "**/*.tsx",
4025
- "**/*.js",
4026
- "**/*.jsx",
4027
- "**/*.mjs",
4028
- "**/*.mts"
4029
- ],
4030
- ignoreDirs: [
4031
- "node_modules",
4032
- ".nuxt",
4033
- ".output",
4034
- "dist",
4035
- ".git",
4036
- "coverage",
4037
- ".tmp"
4038
- ],
4039
- staticKeyPatterns: [/(?<!\w)(this\.\$te?|\$te?|\bt)\s*\(\s*(['"])((?:(?!\2).)*)\2/g],
4040
- dynamicKeyPatterns: [/(?<!\w)(this\.\$te?|\$te?|\bt)\s*\(\s*`((?:[^`]|\\.)*)`/g],
4041
- concatKeyPatterns: [/(?<!\w)(this\.\$te?|\$te?|\bt)\s*\(\s*(['"])((?:(?!\2).)*)\2\s*\+/g],
4042
- requiresDotForCallee: (callee) => callee === "t",
4043
- promoteStaticDynamicMatches: true,
4044
- resolveLocalConsts: true,
4045
- bareShapes: "js"
4046
- };
3988
+ //#endregion
3989
+ //#region src/scanner/frontends/oxc.ts
3990
+ /**
3991
+ * JavaScript, TypeScript and Vue SFCs, read as syntax rather than matched as
3992
+ * text (#332).
3993
+ *
3994
+ * What this buys is one thing: it can follow `t` back to where it came from.
3995
+ * A regex sees the name and has to guess from the argument's shape whether the
3996
+ * call is a translation — the guess that offered a live key for deletion in
3997
+ * #298. Here, a `t` destructured from `useI18n()` is known to be i18n, and a
3998
+ * `t` that is anything else is known not to be.
3999
+ */
4000
+ const JS_EXTENSIONS = new Set([
4001
+ ".js",
4002
+ ".jsx",
4003
+ ".mjs",
4004
+ ".cjs",
4005
+ ".mts",
4006
+ ".cts",
4007
+ ".ts",
4008
+ ".tsx"
4009
+ ]);
4010
+ /** Packages whose exports are translation functions. */
4011
+ const I18N_MODULES = new Set([
4012
+ "vue-i18n",
4013
+ "@nuxtjs/i18n",
4014
+ "next-intl",
4015
+ "react-i18next",
4016
+ "i18next",
4017
+ "petite-vue-i18n"
4018
+ ]);
4019
+ /** Composables whose destructured `t` is a translation function. */
4020
+ const I18N_FACTORIES = new Set([
4021
+ "useI18n",
4022
+ "useTranslation",
4023
+ "useTranslations",
4024
+ "getTranslations"
4025
+ ]);
4026
+ /**
4027
+ * Callees that are unambiguous wherever they appear: a Vue template's `$t`, or
4028
+ * `this.$t` in an options-API component. Nothing else is named that.
4029
+ */
4030
+ const ALWAYS_I18N = new Set([
4031
+ "$t",
4032
+ "$te",
4033
+ "$tc"
4034
+ ]);
4035
+ /**
4036
+ * Names that might be a translation function without proving it — the same set
4037
+ * the patterns match. Without this the frontend reports every call in the file,
4038
+ * and `axios.get('/api/v1')` or `require('node:fs')` become translation keys
4039
+ * because their argument happens to contain a dot.
4040
+ *
4041
+ * A call whose callee resolves to an i18n import is reported whatever it is
4042
+ * named, which is the point: the list bounds guesswork, not knowledge.
4043
+ */
4044
+ const MAYBE_I18N = new Set([
4045
+ "t",
4046
+ "te",
4047
+ "tc",
4048
+ "$t",
4049
+ "$te",
4050
+ "$tc"
4051
+ ]);
4052
+ function createOxcFrontend() {
4053
+ return {
4054
+ name: "oxc",
4055
+ handles(filePath) {
4056
+ return JS_EXTENSIONS.has(extname(filePath)) || filePath.endsWith(".vue");
4057
+ },
4058
+ async read(content, filePath) {
4059
+ const parse = await loadParser();
4060
+ if (!parse) return null;
4061
+ const blocks = readableBlocks(content, filePath);
4062
+ if (!blocks) return null;
4063
+ const parsed = [];
4064
+ for (const block of blocks) {
4065
+ const ast = parseBlock(parse, block.source, filePath);
4066
+ if (!ast) return null;
4067
+ parsed.push({
4068
+ block,
4069
+ ast
4070
+ });
4071
+ }
4072
+ return collectAcrossBlocks(parsed);
4073
+ }
4074
+ };
4075
+ }
4076
+ /**
4077
+ * The parseable blocks of a file, or null to decline it. A .vue file with
4078
+ * neither a template nor a script tag is not an SFC: the block splitter would
4079
+ * read only the fragments it recognises and silently drop everything between
4080
+ * them — declining hands the whole file to the fallback instead. The same
4081
+ * goes for an SFC yielding no blocks at all.
4082
+ */
4083
+ function readableBlocks(content, filePath) {
4084
+ if (!filePath.endsWith(".vue")) return [{
4085
+ source: content,
4086
+ lineOffset: 0
4087
+ }];
4088
+ if (!/<template[\s>]|<script[\s>]/.test(content)) return null;
4089
+ const blocks = vueBlocks(content);
4090
+ return blocks.length === 0 ? null : blocks;
4091
+ }
4092
+ /**
4093
+ * An SFC is one scope split across blocks: a template uses what the script
4094
+ * declared. Collecting per block would leave `t(`${base}.title`)` in the
4095
+ * template unresolvable, and it is the same file.
4096
+ */
4097
+ function collectAcrossBlocks(parsed) {
4098
+ const i18nNames = new Set(parsed.flatMap((p) => [...p.ast.i18nNames]));
4099
+ const constants = /* @__PURE__ */ new Map();
4100
+ for (const { ast } of parsed) for (const [name, value] of ast.constants) addConstant(constants, name, value);
4101
+ const sites = [];
4102
+ for (const { block, ast } of parsed) collect({
4103
+ ...ast,
4104
+ i18nNames,
4105
+ constants
4106
+ }, sites, lineResolver(block.source, block.lineOffset));
4107
+ return sites;
4108
+ }
4109
+ /**
4110
+ * Loaded on first use rather than at import, so a command that never scans does
4111
+ * not pay for a native binary it will not touch. Cached including the failure,
4112
+ * so a broken install is reported once and then falls back quietly.
4113
+ */
4114
+ let parserPromise;
4115
+ function loadParser() {
4116
+ parserPromise ??= import("oxc-parser").then((m) => m.parseSync).catch((error) => {
4117
+ log.warn(`oxc-parser could not be loaded (${error instanceof Error ? error.message : String(error)}) — falling back to pattern matching for JavaScript and TypeScript.`);
4118
+ return null;
4119
+ });
4120
+ return parserPromise;
4121
+ }
4122
+ function parseBlock(parseSync, source, filePath) {
4123
+ const result = parseSync(filePath.endsWith(".vue") ? "block.ts" : filePath, source);
4124
+ if (result.errors.length > 0) {
4125
+ log.debug(`oxc declined ${filePath}: ${result.errors[0]?.message ?? "parse error"}`);
4126
+ return null;
4127
+ }
4128
+ return {
4129
+ program: result.program,
4130
+ source,
4131
+ i18nNames: collectI18nNames(result.program),
4132
+ constants: collectStringConstants(result.program)
4133
+ };
4134
+ }
4135
+ /**
4136
+ * The names bound to a translation function in this file — imports from an
4137
+ * i18n package, and destructures of the composables those packages expose.
4138
+ */
4139
+ function collectI18nNames(program) {
4140
+ const names = /* @__PURE__ */ new Set();
4141
+ const factories = /* @__PURE__ */ new Set();
4142
+ walk$1(program, (node) => {
4143
+ if (node.type !== "ImportDeclaration") return;
4144
+ if (typeof node.source?.value !== "string" || !I18N_MODULES.has(node.source.value)) return;
4145
+ readImportSpecifiers(node, names, factories);
4146
+ });
4147
+ walk$1(program, (node) => {
4148
+ if (node.type !== "VariableDeclarator") return;
4149
+ if (!isI18nFactoryCall(node.init, factories)) return;
4150
+ readBoundNames(node.id, names);
4151
+ });
4152
+ return names;
4153
+ }
4154
+ function isI18nFactoryCall(init, factories) {
4155
+ if (init?.type !== "CallExpression") return false;
4156
+ const callee = calleeName$1(init.callee);
4157
+ return callee !== void 0 && (factories.has(callee) || I18N_FACTORIES.has(callee));
4158
+ }
4159
+ /** The names a destructure or a plain assignment binds. */
4160
+ function readBoundNames(id, names) {
4161
+ if (!id) return;
4162
+ if (id.type === "Identifier") {
4163
+ names.add(id.name);
4164
+ return;
4165
+ }
4166
+ if (id.type !== "ObjectPattern") return;
4167
+ for (const prop of id.properties ?? []) {
4168
+ const local = propertyName(prop);
4169
+ if (local) names.add(local);
4170
+ }
4171
+ }
4172
+ /** `{ t }` binds `t`; `{ t: translate }` binds `translate`. */
4173
+ function propertyName(prop) {
4174
+ return prop.value?.name ?? prop.key?.name;
4175
+ }
4176
+ /**
4177
+ * String constants, so a key assembled from one resolves to the key it names.
4178
+ *
4179
+ * const base = 'pages.settings'
4180
+ * t(`${base}.title`) → pages.settings.title
4181
+ *
4182
+ * The regex path approximates this with a table of textual substitutions. Here
4183
+ * it is the binding itself, which is the difference between resolving a name
4184
+ * and hoping no other name looks like it.
4185
+ */
4186
+ function collectStringConstants(program) {
4187
+ const constants = /* @__PURE__ */ new Map();
4188
+ walk$1(program, (node) => {
4189
+ if (node.type !== "VariableDeclarator") return;
4190
+ if (node.id?.type !== "Identifier") return;
4191
+ if (node.init?.type !== "Literal" || typeof node.init.value !== "string") return;
4192
+ addConstant(constants, node.id.name, node.init.value);
4193
+ });
4194
+ return constants;
4195
+ }
4196
+ function addConstant(table, name, value) {
4197
+ const existing = table.get(name);
4198
+ if (existing === void 0) table.set(name, value);
4199
+ else if (existing !== value) table.set(name, null);
4200
+ }
4201
+ function readImportSpecifiers(node, names, factories) {
4202
+ for (const spec of node.specifiers ?? []) {
4203
+ const local = spec.local?.name;
4204
+ if (!local) continue;
4205
+ const imported = spec.imported?.name ?? local;
4206
+ if (I18N_FACTORIES.has(imported)) factories.add(local);
4207
+ else names.add(local);
4208
+ }
4209
+ }
4210
+ /**
4211
+ * Offsets to line numbers. The parser reports positions as byte offsets, so
4212
+ * the line index is built once per block rather than counted per call site.
4213
+ */
4214
+ function lineResolver(source, lineOffset) {
4215
+ const starts = [0];
4216
+ for (let i = 0; i < source.length; i++) if (source[i] === "\n") starts.push(i + 1);
4217
+ return (offset) => {
4218
+ let low = 0;
4219
+ let high = starts.length - 1;
4220
+ while (low < high) {
4221
+ const mid = Math.ceil((low + high) / 2);
4222
+ if ((starts[mid] ?? 0) <= offset) low = mid;
4223
+ else high = mid - 1;
4224
+ }
4225
+ return low + 1 + lineOffset;
4226
+ };
4227
+ }
4228
+ function collect(parsed, sites, lineAt) {
4229
+ walk$1(parsed.program, (node) => {
4230
+ if (node.type !== "CallExpression") return;
4231
+ const site = toCallSite(node, parsed, lineAt);
4232
+ if (site) sites.push(site);
4233
+ });
4234
+ }
4235
+ function toCallSite(node, parsed, lineAt) {
4236
+ const callee = resolveCallee(node.callee, parsed.i18nNames);
4237
+ if (!callee) return void 0;
4238
+ if (!callee.resolved && !MAYBE_I18N.has(callee.name)) return void 0;
4239
+ const [first] = node.arguments ?? [];
4240
+ if (!first) return void 0;
4241
+ return {
4242
+ callee: callee.name,
4243
+ binding: callee.resolved ? "resolved" : "ambiguous",
4244
+ argument: readArgument$1(first, parsed),
4245
+ line: lineAt(node.start ?? 0)
4246
+ };
4247
+ }
4248
+ /**
4249
+ * Name a callee and decide whether its binding proves it is i18n.
4250
+ *
4251
+ * An identifier resolves through what this file bound — an i18n import or a
4252
+ * destructure of `useI18n()`. A member call resolves only through its own
4253
+ * shape: `$t` is unambiguous on any receiver, and `t` proves i18n only when
4254
+ * the receiver itself is an i18n binding (`const i18n = useI18n(); i18n.t(…)`).
4255
+ * A local `t` from `useI18n()` says nothing about `client.t(…)` — matching a
4256
+ * member by its property name against local bindings would resolve exactly the
4257
+ * calls this frontend exists to tell apart.
4258
+ */
4259
+ function resolveCallee(node, i18nNames) {
4260
+ if (!node) return void 0;
4261
+ if (node.type === "Identifier") return {
4262
+ name: node.name,
4263
+ resolved: i18nNames.has(node.name) || ALWAYS_I18N.has(node.name)
4264
+ };
4265
+ if (node.type === "MemberExpression") return resolveMemberCallee(node, i18nNames);
4266
+ }
4267
+ function resolveMemberCallee(node, i18nNames) {
4268
+ if (node.property?.type !== "Identifier") return void 0;
4269
+ const name = node.property.name;
4270
+ const receiverIsI18n = node.object?.type === "Identifier" && i18nNames.has(node.object.name);
4271
+ return {
4272
+ name,
4273
+ resolved: ALWAYS_I18N.has(name) || receiverIsI18n && MAYBE_I18N.has(name)
4274
+ };
4275
+ }
4276
+ function readArgument$1(node, parsed) {
4277
+ if (node.type === "Literal" && typeof node.value === "string") return {
4278
+ kind: "static",
4279
+ value: node.value
4280
+ };
4281
+ if (node.type === "TemplateLiteral") return readTemplateArgument(node, parsed);
4282
+ if (node.type === "BinaryExpression" && node.operator === "+" && typeof node.left?.value === "string") return {
4283
+ kind: "concat",
4284
+ prefix: node.left.value
4285
+ };
4286
+ return { kind: "unknown" };
4287
+ }
4288
+ function readTemplateArgument(node, parsed) {
4289
+ if ((node.expressions ?? []).length === 0) {
4290
+ const only = node.quasis?.[0]?.value?.cooked;
4291
+ return typeof only === "string" ? {
4292
+ kind: "static",
4293
+ value: only
4294
+ } : { kind: "unknown" };
4295
+ }
4296
+ const resolved = resolveTemplate(node, parsed.constants);
4297
+ if (resolved !== void 0) return {
4298
+ kind: "static",
4299
+ value: resolved
4300
+ };
4301
+ return {
4302
+ kind: "template",
4303
+ expression: typeof node.start === "number" && typeof node.end === "number" ? parsed.source.slice(node.start + 1, node.end - 1) : (node.quasis ?? []).map((q) => q.value?.cooked ?? "").join("${_}")
4304
+ };
4305
+ }
4306
+ /**
4307
+ * The literal a template resolves to, or undefined when any slot is something
4308
+ * other than a constant this file declares.
4309
+ */
4310
+ function resolveTemplate(node, constants) {
4311
+ const parts = [];
4312
+ const quasis = node.quasis ?? [];
4313
+ const expressions = node.expressions ?? [];
4314
+ for (const [i, quasi] of quasis.entries()) {
4315
+ parts.push(quasi.value?.cooked ?? "");
4316
+ const expression = expressions[i];
4317
+ if (!expression) continue;
4318
+ if (expression.type !== "Identifier") return void 0;
4319
+ const value = constants.get(expression.name);
4320
+ if (typeof value !== "string") return void 0;
4321
+ parts.push(value);
4322
+ }
4323
+ return parts.join("");
4324
+ }
4325
+ function calleeName$1(node) {
4326
+ if (!node) return void 0;
4327
+ if (node.type === "Identifier") return node.name;
4328
+ if (node.type === "MemberExpression" && node.property?.type === "Identifier") return node.property.name;
4329
+ }
4330
+ function walk$1(node, visit) {
4331
+ if (!node || typeof node !== "object") return;
4332
+ if (typeof node.type === "string") visit(node);
4333
+ for (const [key, child] of Object.entries(node)) {
4334
+ if (SKIP_KEYS.has(key)) continue;
4335
+ walkChild$1(child, visit);
4336
+ }
4337
+ }
4338
+ /** Positional metadata, not syntax — walking it wastes time and finds nothing. */
4339
+ const SKIP_KEYS = new Set([
4340
+ "loc",
4341
+ "range",
4342
+ "parent"
4343
+ ]);
4344
+ function walkChild$1(child, visit) {
4345
+ if (Array.isArray(child)) {
4346
+ for (const item of child) walkChild$1(item, visit);
4347
+ return;
4348
+ }
4349
+ if (child && typeof child === "object") walk$1(child, visit);
4350
+ }
4351
+ /**
4352
+ * Split an SFC into parseable blocks. A template block's expressions are not
4353
+ * JavaScript, so its interpolations are lifted out and parsed as expressions —
4354
+ * the same trick the PHP frontend uses for Blade directives.
4355
+ */
4356
+ function vueBlocks(content) {
4357
+ return [...scriptBlocks(content), ...templateExpressionBlocks(content)];
4358
+ }
4359
+ const lineOffsetAt = (content, offset) => content.slice(0, offset).split("\n").length - 1;
4360
+ function scriptBlocks(content) {
4361
+ const blocks = [];
4362
+ for (const match of content.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)) {
4363
+ const openTagLength = match[0].length - (match[1]?.length ?? 0) - 9;
4364
+ blocks.push({
4365
+ source: match[1] ?? "",
4366
+ lineOffset: lineOffsetAt(content, (match.index ?? 0) + openTagLength)
4367
+ });
4368
+ }
4369
+ return blocks;
4370
+ }
4371
+ function templateExpressionBlocks(content) {
4372
+ const blocks = [];
4373
+ for (const match of content.matchAll(/\{\{([\s\S]*?)\}\}|(?:v-[a-z-]+|:[\w-]+|@[\w-]+)=(?:"([^"]*)"|'([^']*)')/g)) {
4374
+ const expression = match[1] ?? match[2] ?? match[3];
4375
+ if (!expression?.trim()) continue;
4376
+ blocks.push({
4377
+ source: `(${expression})`,
4378
+ lineOffset: lineOffsetAt(content, match.index ?? 0)
4379
+ });
4380
+ }
4381
+ return blocks;
4382
+ }
4383
+ //#endregion
4384
+ //#region src/scanner/frontends/php/index.ts
4385
+ /**
4386
+ * Laravel PHP, read as syntax (#403, #332).
4387
+ *
4388
+ * The helpers are global functions, so recognition is by name rather than by
4389
+ * following an import — but the *arguments* are read from a real parse:
4390
+ * `"statuses.{$status}.label"` is an interpolated string with known parts, not
4391
+ * a regex's guess about where the quotes end. Heredocs, escapes and nested
4392
+ * quotes come for free.
4393
+ *
4394
+ * Blade is not read here: templates are lifted to PHP expressions by their own
4395
+ * frontend. This one declines `.blade.php`.
4396
+ */
4397
+ /** The Laravel translation helpers. Global names; nothing else is called that. */
4398
+ const PHP_I18N_CALLEES = new Set([
4399
+ "__",
4400
+ "trans",
4401
+ "trans_choice"
4402
+ ]);
4403
+ function createPhpFrontend() {
4404
+ return {
4405
+ name: "php",
4406
+ handles(filePath) {
4407
+ return filePath.endsWith(".php") && !filePath.endsWith(".blade.php");
4408
+ },
4409
+ async read(content, filePath) {
4410
+ const parser = await loadPhpParser(filePath);
4411
+ if (!parser) return null;
4412
+ let program;
4413
+ try {
4414
+ program = parser.parseCode(content, filePath);
4415
+ } catch (error) {
4416
+ log.debug(`php frontend declined ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
4417
+ return null;
4418
+ }
4419
+ return collectPhpSites(program);
4420
+ }
4421
+ };
4422
+ }
4423
+ /**
4424
+ * Call sites in a parsed program. Shared with the Blade frontend, which parses
4425
+ * lifted expressions through the same engine and interprets them identically —
4426
+ * a key's fate must not depend on which file type referenced it (#332).
4427
+ */
4428
+ function collectPhpSites(program, lineOffset = 0, calleeOverride) {
4429
+ const sites = [];
4430
+ walk(program, (node) => {
4431
+ if (node.kind !== "call") return;
4432
+ const callee = calleeName(node.what);
4433
+ if (!callee) return;
4434
+ const [first] = node.arguments ?? [];
4435
+ if (!first) return;
4436
+ sites.push({
4437
+ callee: calleeOverride ?? callee,
4438
+ binding: "resolved",
4439
+ argument: readArgument(first),
4440
+ line: (node.loc?.start?.line ?? 1) + lineOffset
4441
+ });
4442
+ });
4443
+ return sites;
4444
+ }
4445
+ function calleeName(what) {
4446
+ if (!what) return void 0;
4447
+ if (what.kind === "name" && typeof what.name === "string" && PHP_I18N_CALLEES.has(what.name)) return what.name;
4448
+ return isLangGet(what) ? "Lang::get" : void 0;
4449
+ }
4450
+ function isLangGet(what) {
4451
+ if (what.kind !== "staticlookup") return false;
4452
+ return what.what?.name === "Lang" && what.offset?.name === "get";
4453
+ }
4454
+ function readArgument(node) {
4455
+ if ((node.kind === "string" || node.kind === "nowdoc") && typeof node.value === "string") return {
4456
+ kind: "static",
4457
+ value: node.value
4458
+ };
4459
+ if (node.kind === "encapsed" && Array.isArray(node.value)) return readEncapsed(node.value);
4460
+ if (node.kind === "bin" && node.type === ".") return readConcat(node);
4461
+ return { kind: "unknown" };
4462
+ }
4463
+ /** `'orders.status.' . $status` — the literal side bounds what the call can produce. */
4464
+ function readConcat(node) {
4465
+ const left = node.left;
4466
+ if (left?.kind === "string" && typeof left.value === "string") return {
4467
+ kind: "concat",
4468
+ prefix: left.value
4469
+ };
4470
+ return { kind: "unknown" };
4471
+ }
4472
+ /**
4473
+ * Double-quoted or heredoc string with interpolation: known literal parts
4474
+ * around `${_}` slots — or a plain string after all, when nothing
4475
+ * interpolates.
4476
+ */
4477
+ function readEncapsed(parts) {
4478
+ const rendered = parts.map((part) => {
4479
+ const expression = part.expression;
4480
+ return expression?.kind === "string" && typeof expression.value === "string" ? expression.value : "${_}";
4481
+ }).join("");
4482
+ return rendered.includes("${_}") ? {
4483
+ kind: "template",
4484
+ expression: rendered
4485
+ } : {
4486
+ kind: "static",
4487
+ value: rendered
4488
+ };
4489
+ }
4490
+ const parserPromises = /* @__PURE__ */ new Map();
4491
+ function loadPhpParser(fromFile) {
4492
+ const key = dirname(fromFile);
4493
+ let promise = parserPromises.get(key);
4494
+ if (!promise) {
4495
+ promise = resolveParser(fromFile);
4496
+ parserPromises.set(key, promise);
4497
+ }
4498
+ return promise;
4499
+ }
4500
+ let warnedMissingParser = false;
4501
+ async function resolveParser(fromFile) {
4502
+ const Engine = requireFromProject(fromFile) ?? await importFromOwnTree();
4503
+ if (!Engine) {
4504
+ if (warnedMissingParser) return null;
4505
+ warnedMissingParser = true;
4506
+ log.warn("PHP files found, but php-parser is not installed — falling back to pattern matching. Laravel projects need the PHP packages installed: npm i -D php-parser php-array-reader");
4507
+ return null;
4508
+ }
4509
+ return new Engine({
4510
+ parser: {
4511
+ php7: true,
4512
+ suppressErrors: false
4513
+ },
4514
+ ast: { withPositions: true }
4515
+ });
4516
+ }
4517
+ function requireFromProject(fromFile) {
4518
+ try {
4519
+ return createRequire(isAbsolute(fromFile) ? fromFile : join(process.cwd(), fromFile))("php-parser");
4520
+ } catch {
4521
+ return;
4522
+ }
4523
+ }
4524
+ async function importFromOwnTree() {
4525
+ try {
4526
+ const mod = await import("php-parser");
4527
+ return mod.default ?? mod;
4528
+ } catch {
4529
+ return;
4530
+ }
4531
+ }
4532
+ function walk(node, visit) {
4533
+ if (!node || typeof node !== "object") return;
4534
+ if (typeof node.kind === "string") visit(node);
4535
+ for (const [key, child] of Object.entries(node)) {
4536
+ if (key === "loc" || key === "parent") continue;
4537
+ walkChild(child, visit);
4538
+ }
4539
+ }
4540
+ function walkChild(child, visit) {
4541
+ if (Array.isArray(child)) {
4542
+ for (const item of child) walkChild(item, visit);
4543
+ return;
4544
+ }
4545
+ if (child && typeof child === "object") walk(child, visit);
4546
+ }
4047
4547
  const LARAVEL_PATTERNS = {
4048
4548
  label: "Laravel",
4049
4549
  filePatterns: ["**/*.blade.php", "**/*.php"],
@@ -4062,163 +4562,438 @@ const LARAVEL_PATTERNS = {
4062
4562
  bareShapes: "php"
4063
4563
  };
4064
4564
  /**
4065
- * Maps locale file format to the appropriate scan pattern set.
4066
- * 'php-array' Laravel (PHP translation helpers in Blade/PHP files).
4067
- * 'json' / undefined → Vue/Nuxt ($t / t calls in Vue/TS/JS files).
4565
+ * Matches PHP double-quoted interpolated strings with i18n-key shape,
4566
+ * regardless of call context `$transKey = "api.x.{$key}"` assigned first
4567
+ * and passed to Lang::get() later must still suppress api.x.* orphans.
4568
+ * Content is restricted to key-like chars plus {$expr} / $var->prop
4569
+ * interpolations: a permissive "any double-quoted string containing $"
4570
+ * match swallows the code BETWEEN quoted strings (PHP code is full of $),
4571
+ * shifting quote parity past the real candidates.
4068
4572
  */
4069
- function getPatternSet(format) {
4070
- switch (format) {
4071
- case "php-array": return LARAVEL_PATTERNS;
4072
- default: return VUE_NUXT_PATTERNS;
4573
+ const BARE_PHP_DYNAMIC = /"((?:[\w.-]|\{\$[^}]+\}|\$[a-zA-Z_][a-zA-Z0-9_]*(?:->[a-zA-Z_][a-zA-Z0-9_]*)*)+)"/g;
4574
+ function collectBarePhpCandidates(content, bareDynamics) {
4575
+ BARE_PHP_DYNAMIC.lastIndex = 0;
4576
+ for (const match of content.matchAll(BARE_PHP_DYNAMIC)) {
4577
+ const expr = match[1];
4578
+ if (!expr?.includes("$")) continue;
4579
+ const normalized = expr.replace(/\{\$[^}]+\}/g, "${_}").replace(/\$[a-zA-Z_][a-zA-Z0-9_]*(?:->[a-zA-Z_][a-zA-Z0-9_]*)*/g, "${_}");
4580
+ if (!normalized.replace(/\$\{_\}/g, "").includes(".")) continue;
4581
+ bareDynamics.add(`\`${normalized}\``);
4073
4582
  }
4074
4583
  }
4075
4584
  //#endregion
4076
- //#region src/scanner/code-scanner.ts
4585
+ //#region src/scanner/frontends/php/blade.ts
4077
4586
  /**
4078
- * Matches `const` declarations initialized to a key-shaped string literal
4079
- * (≥1 dot). Scope is deliberately tight: identifier = literal only — no
4080
- * object properties, no expressions, and no `let` (a reassigned binding
4081
- * would substitute a stale literal and bypass the conservative widening).
4082
- */
4083
- const CONST_KEY_DECL = /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(['"])((?:[\w-]+\.)+[\w-]+)\2/g;
4084
- /**
4085
- * Collects same-file `const NAME = 'dotted.path'` declarations so
4086
- * `${NAME}` interpolations can be substituted with the literal value.
4087
- * Same-file only imported/cross-file constants are NOT resolved and fall
4088
- * back to the conservative `${_}` widening in buildDynamicKeyRegexes.
4089
- * A name bound to different values (shadowing across scopes) is ambiguous
4090
- * and dropped: substituting one of several possible values could narrow a
4091
- * pattern past a live key.
4587
+ * Blade, by lifting (#404, #332).
4588
+ *
4589
+ * No maintained Blade AST parser exists, and none is needed: every construct
4590
+ * that can carry a translation key wraps a PHP expression. The lexical pass
4591
+ * here finds those wrappers — echoes, `@lang`/`@choice`, `@php` blocks, raw
4592
+ * PHP tags — and hands the expression inside to the same parser and the same
4593
+ * site collection plain PHP uses. The regex frames text; it never decides
4594
+ * what a key is.
4595
+ *
4596
+ * A lifted chunk the parser cannot read declines the whole file to the
4597
+ * pattern fallback: partially-read templates would silently drop keys.
4092
4598
  */
4093
- function collectConstKeyTable(content) {
4094
- const table = /* @__PURE__ */ new Map();
4095
- const ambiguous = /* @__PURE__ */ new Set();
4096
- CONST_KEY_DECL.lastIndex = 0;
4097
- for (const match of content.matchAll(CONST_KEY_DECL)) {
4098
- const name = match[1];
4099
- const value = match[3];
4100
- if (!name || !value || ambiguous.has(name)) continue;
4101
- const existing = table.get(name);
4102
- if (existing !== void 0 && existing !== value) {
4103
- table.delete(name);
4104
- ambiguous.add(name);
4105
- continue;
4599
+ function createBladeFrontend() {
4600
+ return {
4601
+ name: "blade",
4602
+ handles(filePath) {
4603
+ return filePath.endsWith(".blade.php");
4604
+ },
4605
+ async read(content, filePath) {
4606
+ const parser = await loadPhpParser(filePath);
4607
+ if (!parser) return null;
4608
+ const sites = [];
4609
+ for (const chunk of liftChunks(content)) {
4610
+ const parsed = parseChunk(parser, chunk, filePath);
4611
+ if (!parsed) {
4612
+ if (chunk.optional) continue;
4613
+ return null;
4614
+ }
4615
+ sites.push(...collectPhpSites(parsed, chunk.lineOffset, chunk.callee));
4616
+ }
4617
+ sites.sort((a, b) => a.line - b.line);
4618
+ return sites;
4106
4619
  }
4107
- table.set(name, value);
4620
+ };
4621
+ }
4622
+ function parseChunk(parser, chunk, filePath) {
4623
+ try {
4624
+ return parser.parseCode(`<?php ${chunk.source}`, filePath);
4625
+ } catch (error) {
4626
+ log.debug(`blade frontend declined ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
4627
+ return null;
4108
4628
  }
4109
- return table;
4110
4629
  }
4111
- /**
4112
- * Substitutes `${NAME}` interpolations with the const table's literal value,
4113
- * producing an exact or narrower pattern: `${i18nBase}.title` +
4114
- * `const i18nBase = 'a.b.c'` `a.b.c.title`. Only plain-identifier
4115
- * interpolations qualify; member expressions and anything else stay dynamic.
4116
- */
4117
- function substituteConstIdentifiers(expr, table) {
4118
- if (table.size === 0 || !expr.includes("${")) return expr;
4119
- return expr.replace(/\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g, (whole, name) => table.get(name) ?? whole);
4630
+ const BLADE_COMMENT = /\{\{--[\s\S]*?--\}\}/g;
4631
+ function liftChunks(content) {
4632
+ const source = content.replace(BLADE_COMMENT, (m) => m.replace(/[^\n]/g, " "));
4633
+ const lineAt = (offset) => source.slice(0, offset).split("\n").length - 1;
4634
+ return [
4635
+ ...echoChunks(source, lineAt),
4636
+ ...phpBlockChunks(source, lineAt),
4637
+ ...boundAttributeChunks(source, lineAt),
4638
+ ...directiveChunks(source, lineAt)
4639
+ ];
4120
4640
  }
4121
- function extractStaticMatches(line, lineNumber, ctx) {
4122
- for (const regex of ctx.pat.staticKeyPatterns) {
4123
- regex.lastIndex = 0;
4124
- for (const match of line.matchAll(regex)) {
4125
- const callee = match[1] ?? "";
4126
- const key = match[3];
4127
- if (!key) continue;
4128
- if (key.includes("{$")) continue;
4129
- if (ctx.pat.requiresDotForCallee?.(callee) && !key.includes(".")) {
4130
- ctx.bareCandidates.add(key);
4131
- continue;
4132
- }
4133
- ctx.usages.push({
4134
- key,
4135
- file: ctx.filePath,
4136
- line: lineNumber,
4137
- callee
4138
- });
4139
- }
4641
+ /** {{ expr }} and {!! expr !!} — echoes of a PHP expression. */
4642
+ function echoChunks(source, lineAt) {
4643
+ return expressionChunks(source, /\{\{([\s\S]*?)\}\}|\{!!([\s\S]*?)!!\}/g, lineAt, (expression) => ({ source: `${expression};` }));
4644
+ }
4645
+ function expressionChunks(source, pattern, lineAt, shape) {
4646
+ const chunks = [];
4647
+ for (const match of source.matchAll(pattern)) {
4648
+ const expression = match[1] ?? match[2];
4649
+ if (!expression?.trim()) continue;
4650
+ chunks.push({
4651
+ ...shape(expression),
4652
+ lineOffset: lineAt(match.index ?? 0)
4653
+ });
4654
+ }
4655
+ return chunks;
4656
+ }
4657
+ /** @php ... @endphp and raw <?php ... ?> — statements as written. */
4658
+ function phpBlockChunks(source, lineAt) {
4659
+ return [...bodyChunks(source, /@php\b(?!\s*\()([\s\S]*?)@endphp/g, lineAt), ...bodyChunks(source, /<\?php\b([\s\S]*?)(?:\?>|$)/g, lineAt)];
4660
+ }
4661
+ function bodyChunks(source, pattern, lineAt) {
4662
+ const chunks = [];
4663
+ for (const match of source.matchAll(pattern)) {
4664
+ const body = match[1];
4665
+ if (body?.trim()) chunks.push({
4666
+ source: body,
4667
+ lineOffset: lineAt(match.index ?? 0)
4668
+ });
4140
4669
  }
4670
+ return chunks;
4141
4671
  }
4142
4672
  /**
4143
- * Normalizes every interpolation syntax (JS `${expr}`, PHP `{$expr}` and
4144
- * bare `$var->prop`) to `${_}` slots. Returns undefined when the expression
4145
- * contains no interpolation at all.
4673
+ * Bound component attributes :message="__('alerts.saved')" compiles to a
4674
+ * PHP expression. `::` escapes to a literal colon and carries none.
4146
4675
  */
4147
- function normalizeDynamicExpression(expression) {
4148
- const hasDollarBrace = expression.includes("${");
4149
- const hasBraceDollar = expression.includes("{$");
4150
- const hasBarePHP = !hasDollarBrace && !hasBraceDollar && /\$[a-zA-Z_]/.test(expression);
4151
- if (!hasDollarBrace && !hasBraceDollar && !hasBarePHP) return void 0;
4152
- return hasBraceDollar ? expression.replace(/\{\$[^}]+\}/g, "${_}") : hasBarePHP ? expression.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*(?:->[a-zA-Z_][a-zA-Z0-9_]*)*/g, "${_}") : expression;
4676
+ function boundAttributeChunks(source, lineAt) {
4677
+ return expressionChunks(source, /(?<![:\w]):[\w-]+=(?:"([^"]*)"|'([^']*)')/g, lineAt, (expression) => ({
4678
+ source: `__args__(${expression});`,
4679
+ optional: true
4680
+ }));
4681
+ }
4682
+ /**
4683
+ * Directive arguments. @lang and @choice are thin wrappers over __ and
4684
+ * trans_choice — the argument list is the translation call, reported under
4685
+ * the directive's own name. Every other directive gets its arguments read
4686
+ * as an expression list (`@section('title', __('Forbidden'))` carries a
4687
+ * real call), best-effort: what is not an expression is Blade grammar.
4688
+ * The inline @php($x = ...) form is an expression list like any other.
4689
+ */
4690
+ function directiveChunks(source, lineAt) {
4691
+ const chunks = [];
4692
+ for (const match of source.matchAll(/@(\w+)\s*\(/g)) {
4693
+ const args = balancedParens(source, (match.index ?? 0) + match[0].length - 1);
4694
+ if (args === void 0) continue;
4695
+ const chunk = directiveChunk(match[1] ?? "", args, lineAt(match.index ?? 0));
4696
+ if (chunk) chunks.push(chunk);
4697
+ }
4698
+ return chunks;
4699
+ }
4700
+ function directiveChunk(directive, args, lineOffset) {
4701
+ if (directive === "lang" || directive === "choice") return {
4702
+ source: `${directive === "lang" ? "__" : "trans_choice"}(${args});`,
4703
+ lineOffset,
4704
+ callee: `@${directive}`
4705
+ };
4706
+ return args.trim() ? {
4707
+ source: `__args__(${args});`,
4708
+ lineOffset,
4709
+ optional: true
4710
+ } : void 0;
4711
+ }
4712
+ /**
4713
+ * The argument text between a directive's parentheses, quote-aware — a `)`
4714
+ * inside a string does not close the call. Returns undefined when the call
4715
+ * never closes, which declines the construct rather than guessing.
4716
+ */
4717
+ function balancedParens(source, openIndex) {
4718
+ let depth = 0;
4719
+ for (let i = openIndex; i < source.length; i++) {
4720
+ const ch = source[i];
4721
+ if (ch === "'" || ch === "\"") i = skipString(source, i);
4722
+ else if (ch === "(") depth++;
4723
+ else if (ch === ")" && --depth === 0) return source.slice(openIndex + 1, i);
4724
+ }
4725
+ }
4726
+ /** The index of a string literal's closing quote, escapes respected. */
4727
+ function skipString(source, start) {
4728
+ const quote = source[start];
4729
+ for (let i = start + 1; i < source.length; i++) if (source[i] === "\\") i++;
4730
+ else if (source[i] === quote) return i;
4731
+ return source.length;
4732
+ }
4733
+ //#endregion
4734
+ //#region src/scanner/rules.ts
4735
+ /**
4736
+ * The one callee whose dotless arguments are not evidence: a bare `t` is what
4737
+ * `emit`, test helpers and local functions are also called. Every other name
4738
+ * the pattern sets match (`$t`, `__`, `trans`, ...) is distinctive enough that
4739
+ * its argument counts, dot or no dot. Formerly per-pattern-set configuration;
4740
+ * it is a rule about meaning, so it lives with the rules.
4741
+ */
4742
+ const ambiguousCalleeNeedsDot = (callee) => callee === "t";
4743
+ function interpret(sites, ctx) {
4744
+ const usages = [];
4745
+ const dynamicKeys = [];
4746
+ const bareStringCandidates = /* @__PURE__ */ new Set();
4747
+ for (const site of sites) {
4748
+ const { callee, line, argument } = site;
4749
+ const guarded = site.binding === "ambiguous" && ctx.ambiguousCalleeNeedsDot(callee);
4750
+ switch (argument.kind) {
4751
+ case "static":
4752
+ if (guarded && !argument.value.includes(".")) {
4753
+ bareStringCandidates.add(argument.value);
4754
+ break;
4755
+ }
4756
+ usages.push({
4757
+ key: argument.value,
4758
+ file: ctx.filePath,
4759
+ line,
4760
+ callee
4761
+ });
4762
+ break;
4763
+ case "template":
4764
+ dynamicKeys.push({
4765
+ expression: `\`${argument.expression}\``,
4766
+ file: ctx.filePath,
4767
+ line,
4768
+ callee
4769
+ });
4770
+ break;
4771
+ case "concat":
4772
+ if (guarded && !argument.prefix.includes(".")) break;
4773
+ dynamicKeys.push({
4774
+ expression: `\`${argument.prefix}\${_}\``,
4775
+ file: ctx.filePath,
4776
+ line,
4777
+ callee
4778
+ });
4779
+ break;
4780
+ case "unknown": break;
4781
+ }
4782
+ }
4783
+ return {
4784
+ usages,
4785
+ dynamicKeys,
4786
+ bareStringCandidates
4787
+ };
4788
+ }
4789
+ //#endregion
4790
+ //#region src/scanner/frontends/patterns.ts
4791
+ /**
4792
+ * The regex path as a language frontend (#332).
4793
+ *
4794
+ * Regexes frame text and report call sites; what a site means is decided once,
4795
+ * in the rules, the same as for every other frontend. Binding is always
4796
+ * `ambiguous`, because a regex can never prove what a name is bound to — which
4797
+ * is the entire reason the syntax frontends exist.
4798
+ *
4799
+ * This frontend never declines: it is the floor every scan can fall back to.
4800
+ */
4801
+ function createPatternsFrontend(pat) {
4802
+ return {
4803
+ name: "patterns",
4804
+ handles: () => true,
4805
+ read: (content, filePath) => Promise.resolve(readPatternSites(content, filePath, pat))
4806
+ };
4807
+ }
4808
+ /**
4809
+ * Synchronous core, so the sync `extractKeys` contract the scanner suites are
4810
+ * written against keeps working unchanged.
4811
+ */
4812
+ function readPatternSites(content, _filePath, pat) {
4813
+ const sites = [];
4814
+ const seen = /* @__PURE__ */ new Set();
4815
+ const lines = content.split("\n");
4816
+ for (const [i, line] of lines.entries()) {
4817
+ const lineNumber = i + 1;
4818
+ staticSites(line, lineNumber, pat, sites, seen);
4819
+ dynamicSites(line, lineNumber, pat, sites, seen);
4820
+ concatSites(line, lineNumber, pat, sites);
4821
+ }
4822
+ return sites;
4823
+ }
4824
+ function pushStatic(sites, seen, callee, value, line) {
4825
+ const id = `${line}:${callee}:${value}`;
4826
+ if (seen.has(id)) return;
4827
+ seen.add(id);
4828
+ sites.push({
4829
+ callee,
4830
+ binding: "ambiguous",
4831
+ argument: {
4832
+ kind: "static",
4833
+ value
4834
+ },
4835
+ line
4836
+ });
4153
4837
  }
4154
- function extractDynamicMatches(line, lineNumber, ctx) {
4155
- for (const regex of ctx.pat.dynamicKeyPatterns) {
4838
+ function staticSites(line, lineNumber, pat, sites, seen) {
4839
+ for (const regex of pat.staticKeyPatterns) {
4840
+ regex.lastIndex = 0;
4841
+ for (const match of line.matchAll(regex)) {
4842
+ const callee = match[1] ?? "";
4843
+ const key = match[3];
4844
+ if (!key) continue;
4845
+ if (key.includes("{$")) continue;
4846
+ pushStatic(sites, seen, callee, key, lineNumber);
4847
+ }
4848
+ }
4849
+ }
4850
+ function dynamicSites(line, lineNumber, pat, sites, seen) {
4851
+ for (const regex of pat.dynamicKeyPatterns) {
4156
4852
  regex.lastIndex = 0;
4157
4853
  for (const match of line.matchAll(regex)) {
4158
4854
  const callee = match[1] ?? "";
4159
4855
  const raw = match[2];
4160
4856
  if (!raw) continue;
4161
- const expression = substituteConstIdentifiers(raw, ctx.constTable);
4162
- const normalized = normalizeDynamicExpression(expression);
4857
+ const normalized = normalizeDynamicExpression(raw);
4163
4858
  if (normalized === void 0) {
4164
- if (!ctx.pat.promoteStaticDynamicMatches) continue;
4165
- if (!expression) continue;
4166
- if (ctx.pat.requiresDotForCallee?.(callee) && !expression.includes(".")) continue;
4167
- ctx.usages.push({
4168
- key: expression,
4169
- file: ctx.filePath,
4170
- line: lineNumber,
4171
- callee
4172
- });
4859
+ pushStatic(sites, seen, callee, raw, lineNumber);
4173
4860
  continue;
4174
4861
  }
4175
- ctx.dynamicKeys.push({
4176
- expression: `\`${normalized}\``,
4177
- file: ctx.filePath,
4178
- line: lineNumber,
4179
- callee
4862
+ sites.push({
4863
+ callee,
4864
+ binding: "ambiguous",
4865
+ argument: {
4866
+ kind: "template",
4867
+ expression: normalized
4868
+ },
4869
+ line: lineNumber
4180
4870
  });
4181
4871
  }
4182
4872
  }
4183
4873
  }
4184
- function extractConcatMatches(line, lineNumber, ctx) {
4185
- for (const regex of ctx.pat.concatKeyPatterns) {
4874
+ function concatSites(line, lineNumber, pat, sites) {
4875
+ for (const regex of pat.concatKeyPatterns) {
4186
4876
  regex.lastIndex = 0;
4187
4877
  for (const match of line.matchAll(regex)) {
4188
4878
  const callee = match[1] ?? "";
4189
4879
  const prefix = match[3];
4190
4880
  if (!prefix) continue;
4191
- if (ctx.pat.requiresDotForCallee?.(callee) && !prefix.includes(".")) continue;
4192
- ctx.dynamicKeys.push({
4193
- expression: `\`${prefix}\${_}\``,
4194
- file: ctx.filePath,
4195
- line: lineNumber,
4196
- callee
4881
+ sites.push({
4882
+ callee,
4883
+ binding: "ambiguous",
4884
+ argument: {
4885
+ kind: "concat",
4886
+ prefix
4887
+ },
4888
+ line: lineNumber
4197
4889
  });
4198
4890
  }
4199
4891
  }
4200
4892
  }
4201
- function extractKeys(content, filePath, patterns, constTable) {
4202
- const pat = patterns ?? VUE_NUXT_PATTERNS;
4203
- const ctx = {
4204
- pat,
4205
- constTable: constTable ?? (pat.resolveLocalConsts ? collectConstKeyTable(content) : /* @__PURE__ */ new Map()),
4206
- filePath,
4207
- usages: [],
4208
- dynamicKeys: [],
4209
- bareCandidates: /* @__PURE__ */ new Set()
4210
- };
4211
- const lines = content.split("\n");
4212
- for (const [i, line] of lines.entries()) {
4213
- const lineNumber = i + 1;
4214
- extractStaticMatches(line, lineNumber, ctx);
4215
- extractDynamicMatches(line, lineNumber, ctx);
4216
- extractConcatMatches(line, lineNumber, ctx);
4893
+ /**
4894
+ * Matches `const` declarations initialized to a key-shaped string literal
4895
+ * (>=1 dot). Scope is deliberately tight: identifier = literal only — no
4896
+ * object properties, no expressions, and no `let` (a reassigned binding
4897
+ * would substitute a stale literal and bypass the conservative widening).
4898
+ */
4899
+ const CONST_KEY_DECL = /\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(['"])((?:[\w-]+\.)+[\w-]+)\2/g;
4900
+ /**
4901
+ * Collects same-file `const NAME = 'dotted.path'` declarations for the
4902
+ * bare-candidate net, which stays deliberately non-syntactic (#332): a
4903
+ * substituted candidate is an exact protector where an unsubstituted one
4904
+ * would be a wildcard. Usage extraction no longer consults this — the syntax
4905
+ * frontends resolve real bindings instead.
4906
+ */
4907
+ function collectConstKeyTable(content) {
4908
+ const table = /* @__PURE__ */ new Map();
4909
+ const ambiguous = /* @__PURE__ */ new Set();
4910
+ CONST_KEY_DECL.lastIndex = 0;
4911
+ for (const match of content.matchAll(CONST_KEY_DECL)) {
4912
+ const name = match[1];
4913
+ const value = match[3];
4914
+ if (!name || !value || ambiguous.has(name)) continue;
4915
+ const existing = table.get(name);
4916
+ if (existing !== void 0 && existing !== value) {
4917
+ table.delete(name);
4918
+ ambiguous.add(name);
4919
+ continue;
4920
+ }
4921
+ table.set(name, value);
4922
+ }
4923
+ return table;
4924
+ }
4925
+ /**
4926
+ * Substitutes `${NAME}` interpolations with the const table's literal value,
4927
+ * producing an exact or narrower pattern: `${i18nBase}.title` +
4928
+ * `const i18nBase = 'a.b.c'` -> `a.b.c.title`. Only plain-identifier
4929
+ * interpolations qualify; member expressions and anything else stay dynamic.
4930
+ */
4931
+ function substituteConstIdentifiers(expr, table) {
4932
+ if (table.size === 0 || !expr.includes("${")) return expr;
4933
+ return expr.replace(/\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g, (whole, name) => table.get(name) ?? whole);
4934
+ }
4935
+ /**
4936
+ * Normalizes every interpolation syntax (JS `${expr}`, PHP `{$expr}` and
4937
+ * bare `$var->prop`) to `${_}` slots. Returns undefined when the expression
4938
+ * contains no interpolation at all.
4939
+ */
4940
+ function normalizeDynamicExpression(expression) {
4941
+ const hasDollarBrace = expression.includes("${");
4942
+ const hasBraceDollar = expression.includes("{$");
4943
+ const hasBarePHP = !hasDollarBrace && !hasBraceDollar && /\$[a-zA-Z_]/.test(expression);
4944
+ if (!hasDollarBrace && !hasBraceDollar && !hasBarePHP) return void 0;
4945
+ return hasBraceDollar ? expression.replace(/\{\$[^}]+\}/g, "${_}") : hasBarePHP ? expression.replace(/\$[a-zA-Z_][a-zA-Z0-9_]*(?:->[a-zA-Z_][a-zA-Z0-9_]*)*/g, "${_}") : expression;
4946
+ }
4947
+ const VUE_NUXT_PATTERNS = {
4948
+ label: "Vue / Nuxt",
4949
+ filePatterns: [
4950
+ "**/*.vue",
4951
+ "**/*.ts",
4952
+ "**/*.tsx",
4953
+ "**/*.js",
4954
+ "**/*.jsx",
4955
+ "**/*.mjs",
4956
+ "**/*.mts"
4957
+ ],
4958
+ ignoreDirs: [
4959
+ "node_modules",
4960
+ ".nuxt",
4961
+ ".output",
4962
+ "dist",
4963
+ ".git",
4964
+ "coverage",
4965
+ ".tmp"
4966
+ ],
4967
+ staticKeyPatterns: [/(?<!\w)(this\.\$te?|\$te?|\bt)\s*\(\s*(['"])((?:(?!\2).)*)\2/g],
4968
+ dynamicKeyPatterns: [/(?<!\w)(this\.\$te?|\$te?|\bt)\s*\(\s*`((?:[^`]|\\.)*)`/g],
4969
+ concatKeyPatterns: [/(?<!\w)(this\.\$te?|\$te?|\bt)\s*\(\s*(['"])((?:(?!\2).)*)\2\s*\+/g],
4970
+ bareShapes: "js"
4971
+ };
4972
+ /**
4973
+ * Maps locale file format to the appropriate scan pattern set.
4974
+ * 'php-array' → Laravel (PHP translation helpers in Blade/PHP files).
4975
+ * 'json' / undefined → Vue/Nuxt ($t / t calls in Vue/TS/JS files).
4976
+ */
4977
+ function getPatternSet(format) {
4978
+ switch (format) {
4979
+ case "php-array": return LARAVEL_PATTERNS;
4980
+ default: return VUE_NUXT_PATTERNS;
4217
4981
  }
4982
+ }
4983
+ //#endregion
4984
+ //#region src/scanner/code-scanner.ts
4985
+ /**
4986
+ * One file's evidence through the pattern frontend — the sync contract the
4987
+ * scanner suites are written against. Same pipeline as every scan: the
4988
+ * frontend reports call sites, the rules decide what they mean.
4989
+ */
4990
+ function extractKeys(content, filePath, patterns) {
4991
+ return interpret(readPatternSites(content, filePath, patterns ?? VUE_NUXT_PATTERNS), ruleContext(filePath));
4992
+ }
4993
+ function ruleContext(filePath) {
4218
4994
  return {
4219
- usages: ctx.usages,
4220
- dynamicKeys: ctx.dynamicKeys,
4221
- bareStringCandidates: ctx.bareCandidates
4995
+ filePath,
4996
+ ambiguousCalleeNeedsDot
4222
4997
  };
4223
4998
  }
4224
4999
  /**
@@ -4315,16 +5090,6 @@ const BARE_DYNAMIC_TEMPLATE = /`([\w.-]*(?:\$\{(?:[^`{}\n]|\{[^`{}\n]*\})*\}[\w.
4315
5090
  /** Longer candidates cannot plausibly be i18n keys — drop, don't truncate. */
4316
5091
  const MAX_BARE_TEMPLATE_LENGTH = 120;
4317
5092
  /**
4318
- * Matches PHP double-quoted interpolated strings with i18n-key shape,
4319
- * regardless of call context — `$transKey = "api.x.{$key}"` assigned first
4320
- * and passed to Lang::get() later must still suppress api.x.* orphans.
4321
- * Content is restricted to key-like chars plus {$expr} / $var->prop
4322
- * interpolations: a permissive "any double-quoted string containing $"
4323
- * match swallows the code BETWEEN quoted strings (PHP code is full of $),
4324
- * shifting quote parity past the real candidates.
4325
- */
4326
- const BARE_PHP_DYNAMIC = /"((?:[\w.-]|\{\$[^}]+\}|\$[a-zA-Z_][a-zA-Z0-9_]*(?:->[a-zA-Z_][a-zA-Z0-9_]*)*)+)"/g;
4327
- /**
4328
5093
  * Matches prefix-shaped string literals (≥1 key-like segment, trailing dot,
4329
5094
  * closing quote right after the dot) regardless of call context: concat
4330
5095
  * prefixes ('menu.' + var, __('a.b.' . $x) — incl. multiline t() calls where
@@ -4362,16 +5127,6 @@ function collectBareTemplateCandidates(content, constTable, bareStrings, bareDyn
4362
5127
  bareDynamics.add(`\`${normalized}\``);
4363
5128
  }
4364
5129
  }
4365
- function collectBarePhpCandidates(content, bareDynamics) {
4366
- BARE_PHP_DYNAMIC.lastIndex = 0;
4367
- for (const match of content.matchAll(BARE_PHP_DYNAMIC)) {
4368
- const expr = match[1];
4369
- if (!expr?.includes("$")) continue;
4370
- const normalized = expr.replace(/\{\$[^}]+\}/g, "${_}").replace(/\$[a-zA-Z_][a-zA-Z0-9_]*(?:->[a-zA-Z_][a-zA-Z0-9_]*)*/g, "${_}");
4371
- if (!normalized.replace(/\$\{_\}/g, "").includes(".")) continue;
4372
- bareDynamics.add(`\`${normalized}\``);
4373
- }
4374
- }
4375
5130
  function collectBarePrefixCandidates(content, bareDynamics) {
4376
5131
  BARE_PREFIX_LITERAL.lastIndex = 0;
4377
5132
  for (const match of content.matchAll(BARE_PREFIX_LITERAL)) bareDynamics.add(`\`${match[2]}\${_}\``);
@@ -4417,8 +5172,69 @@ function collectBareCandidates(content, constTable, bareStrings, bareDynamics, b
4417
5172
  *
4418
5173
  * When `patterns` is omitted, defaults to Vue/Nuxt patterns.
4419
5174
  */
4420
- async function scanSourceFiles(rootDir, excludeDirs, patterns) {
5175
+ /**
5176
+ * One file's evidence, preferring a frontend that reads the language as syntax
5177
+ * over one that matches it as text (#332).
5178
+ *
5179
+ * A frontend that declines — an unparseable file, a parser that would not load
5180
+ * — falls through to pattern matching rather than failing. Better evidence when
5181
+ * it is available, never a scan that stops because it was not.
5182
+ */
5183
+ async function extractFileEvidence(content, filePath, frontends, patterns) {
5184
+ const pat = patterns ?? VUE_NUXT_PATTERNS;
5185
+ let declined = false;
5186
+ for (const frontend of frontends) {
5187
+ if (!frontend.handles(filePath)) continue;
5188
+ const sites = await frontend.read(content, filePath);
5189
+ if (!sites) {
5190
+ declined = true;
5191
+ continue;
5192
+ }
5193
+ return {
5194
+ ...interpret(sites, ruleContext(filePath)),
5195
+ declined
5196
+ };
5197
+ }
5198
+ return {
5199
+ ...extractKeys(content, filePath, pat),
5200
+ declined
5201
+ };
5202
+ }
5203
+ /**
5204
+ * Opt-in, via `I18N_SCANNER=ast`.
5205
+ *
5206
+ * The architecture is settled; the migration is not. #332 gates each frontend
5207
+ * on a differential run showing it is at least as conservative as what it
5208
+ * replaces, and on anny-ui the AST frontend still misses 13 keys the patterns
5209
+ * find — most of them regex artifacts, a handful genuine. A key the outgoing
5210
+ * frontend saw and the incoming one does not becomes an orphan, and orphans
5211
+ * get deleted, so the default stays where the evidence is.
5212
+ *
5213
+ * Flip it once `scripts/scanner-diff.mjs` reports nothing in that direction.
5214
+ */
5215
+ let warnedRegexHatch = false;
5216
+ /**
5217
+ * The syntax frontends are the default (#402 for JS/TS/Vue, #405 for
5218
+ * PHP/Blade); patterns read only what they decline. `I18N_SCANNER=regex`
5219
+ * restores the old scanner for exactly one release — an escape hatch for
5220
+ * reporting a regression, not a mode.
5221
+ */
5222
+ function defaultFrontends(pat) {
5223
+ if (process.env.I18N_SCANNER === "regex") {
5224
+ if (!warnedRegexHatch) {
5225
+ warnedRegexHatch = true;
5226
+ log.warn("I18N_SCANNER=regex is deprecated and will be removed in the next major. If the default scanner misses something the regex found, please file it: https://github.com/fabkho/the-i18n-kit/issues");
5227
+ }
5228
+ return [createPatternsFrontend(pat)];
5229
+ }
5230
+ return [...pat.bareShapes === "php" ? [phpFrontend, bladeFrontend] : [oxcFrontend], createPatternsFrontend(pat)];
5231
+ }
5232
+ const oxcFrontend = createOxcFrontend();
5233
+ const phpFrontend = createPhpFrontend();
5234
+ const bladeFrontend = createBladeFrontend();
5235
+ async function scanSourceFiles(rootDir, excludeDirs, patterns, frontends) {
4421
5236
  const pat = patterns ?? VUE_NUXT_PATTERNS;
5237
+ const active = frontends ?? defaultFrontends(pat);
4422
5238
  const ignore = [...pat.ignoreDirs, ...excludeDirs ?? []];
4423
5239
  let relativePaths;
4424
5240
  try {
@@ -4433,6 +5249,7 @@ async function scanSourceFiles(rootDir, excludeDirs, patterns) {
4433
5249
  usages: [],
4434
5250
  dynamicKeys: [],
4435
5251
  filesScanned: 0,
5252
+ declinedFiles: [],
4436
5253
  uniqueKeys: /* @__PURE__ */ new Set(),
4437
5254
  bareStringCandidates: /* @__PURE__ */ new Set(),
4438
5255
  bareDynamicCandidates: /* @__PURE__ */ new Set()
@@ -4442,6 +5259,7 @@ async function scanSourceFiles(rootDir, excludeDirs, patterns) {
4442
5259
  const allDynamicKeys = [];
4443
5260
  const bareStringCandidates = /* @__PURE__ */ new Set();
4444
5261
  const bareDynamicCandidates = /* @__PURE__ */ new Set();
5262
+ const declinedFiles = [];
4445
5263
  let filesScanned = 0;
4446
5264
  for (const relPath of relativePaths) {
4447
5265
  const filePath = join(rootDir, relPath);
@@ -4452,8 +5270,9 @@ async function scanSourceFiles(rootDir, excludeDirs, patterns) {
4452
5270
  log.warn(`Failed to read file: ${filePath}`);
4453
5271
  continue;
4454
5272
  }
4455
- const constTable = pat.resolveLocalConsts ? collectConstKeyTable(content) : /* @__PURE__ */ new Map();
4456
- const { usages, dynamicKeys, bareStringCandidates: bareFromCalls } = extractKeys(content, filePath, pat, constTable);
5273
+ const constTable = (pat.bareShapes ?? "js") === "js" ? collectConstKeyTable(content) : /* @__PURE__ */ new Map();
5274
+ const { usages, dynamicKeys, bareStringCandidates: bareFromCalls, declined } = await extractFileEvidence(content, filePath, active, pat);
5275
+ if (declined) declinedFiles.push(relPath);
4457
5276
  allUsages.push(...usages);
4458
5277
  allDynamicKeys.push(...dynamicKeys);
4459
5278
  for (const candidate of bareFromCalls) bareStringCandidates.add(candidate);
@@ -4466,6 +5285,7 @@ async function scanSourceFiles(rootDir, excludeDirs, patterns) {
4466
5285
  usages: allUsages,
4467
5286
  dynamicKeys: allDynamicKeys,
4468
5287
  filesScanned,
5288
+ declinedFiles,
4469
5289
  uniqueKeys,
4470
5290
  bareStringCandidates,
4471
5291
  bareDynamicCandidates
@@ -4530,10 +5350,12 @@ async function findOrphanKeysForConfig(options) {
4530
5350
  const evidences = [];
4531
5351
  const allDynamicKeysRaw = [];
4532
5352
  let totalFilesScanned = 0;
5353
+ let totalFilesDeclined = 0;
4533
5354
  for (const unit of units) {
4534
5355
  const ignores = globalScope ? [] : nestedUnitIgnores(unit, units);
4535
5356
  const result = await scanSourceFiles(unit.dir, [...excludeDirs ?? [], ...ignores], patterns);
4536
5357
  totalFilesScanned += result.filesScanned;
5358
+ totalFilesDeclined += result.declinedFiles.length;
4537
5359
  const dynamicRaw = [...result.dynamicKeys, ...[...result.bareDynamicCandidates].map((bd) => ({
4538
5360
  expression: bd,
4539
5361
  file: "",
@@ -4578,6 +5400,8 @@ async function findOrphanKeysForConfig(options) {
4578
5400
  const unitDynRegexes = (evidence) => evidence.dynRegexes ??= buildDynamicKeyRegexes(evidence.dynamicRaw);
4579
5401
  const orphansByLayer = {};
4580
5402
  let orphanCount = 0;
5403
+ const candidateOnlyByLayer = {};
5404
+ let candidateOnlyCount = 0;
4581
5405
  const uncertainByLayer = {};
4582
5406
  let uncertainCount = 0;
4583
5407
  let dynamicMatchedCount = 0;
@@ -4592,9 +5416,13 @@ async function findOrphanKeysForConfig(options) {
4592
5416
  const outOfScope = globalScope ? [] : evidences.filter((e) => !scopeNameSet.has(e.unit.name));
4593
5417
  const ignorePatterns = resolveIgnorePatterns(layerName);
4594
5418
  const ignoreRegexes = ignorePatterns ? buildIgnorePatternRegexes(ignorePatterns) : [];
5419
+ const candidateOnly = [];
4595
5420
  const orphans = keys.filter((k) => {
4596
5421
  if (scope.unique.has(k)) return false;
4597
- if (scope.bare.has(k)) return false;
5422
+ if (scope.bare.has(k)) {
5423
+ if (!scope.dynRegexes.some((re) => re.test(k))) candidateOnly.push(k);
5424
+ return false;
5425
+ }
4598
5426
  if (scope.dynRegexes.some((re) => re.test(k))) {
4599
5427
  dynamicMatchedCount++;
4600
5428
  return false;
@@ -4628,6 +5456,10 @@ async function findOrphanKeysForConfig(options) {
4628
5456
  uncertainByLayer[layerName] = uncertain;
4629
5457
  uncertainCount += uncertain.length;
4630
5458
  }
5459
+ if (candidateOnly.length > 0) {
5460
+ candidateOnlyByLayer[layerName] = candidateOnly.sort();
5461
+ candidateOnlyCount += candidateOnly.length;
5462
+ }
4631
5463
  }
4632
5464
  misplacedUsages.sort((a, b) => a.layer.localeCompare(b.layer) || a.key.localeCompare(b.key));
4633
5465
  const byLocation = (a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.expression.localeCompare(b.expression);
@@ -4638,7 +5470,10 @@ async function findOrphanKeysForConfig(options) {
4638
5470
  orphanCount,
4639
5471
  uncertainByLayer,
4640
5472
  uncertainCount,
5473
+ candidateOnlyByLayer,
5474
+ candidateOnlyCount,
4641
5475
  totalFilesScanned,
5476
+ totalFilesDeclined,
4642
5477
  dynamicMatchedCount,
4643
5478
  ignoredCount,
4644
5479
  allDynamicKeys: allDynamicKeysRaw,
@@ -5099,6 +5934,147 @@ async function scaffoldLocaleFiles(opts) {
5099
5934
  dryRun: opts.dryRun ?? false
5100
5935
  };
5101
5936
  }
5937
+ /**
5938
+ * Move a key from one layer to another, carrying every locale that defines it.
5939
+ *
5940
+ * Promoting an app-layer key to the shared layer once a second app needs it is
5941
+ * a first-class operation in a layered monorepo, and composing it out of
5942
+ * get/write/remove is three calls across up to thirty locales with no way to
5943
+ * fail cleanly: a truncation between the write and the remove leaves the key in
5944
+ * both layers, which is the state `find_duplicate_keys` exists to flag (#341).
5945
+ *
5946
+ * So the whole move is planned before anything is written. A target that
5947
+ * already holds a *different* value is a conflict, and one conflict in one
5948
+ * locale writes nothing at all — a half-moved key across thirty files is worse
5949
+ * than a refusal. A target already holding the *same* value is not a conflict
5950
+ * but a duplicate the move resolves: the source copy is dropped and the locale
5951
+ * is reported as deduplicated.
5952
+ *
5953
+ * Locales come from the resolved config rather than from caller-supplied refs,
5954
+ * so there is no ref to leave unresolved (#301) — a locale the source layer
5955
+ * does not define is reported in `notFoundInLocales` rather than skipped
5956
+ * silently.
5957
+ */
5958
+ async function moveTranslationKey(opts) {
5959
+ const { fromLayer, toLayer, key } = opts;
5960
+ const targetKey = opts.newKey ?? key;
5961
+ const config = await detectI18nConfig(opts.projectDir ?? process.cwd());
5962
+ if (fromLayer === toLayer) throw new ToolError(`fromLayer and toLayer are both "${fromLayer}". To rename a key within one layer, use rename_translation_key.`, "SAME_LAYER");
5963
+ findWritableLayerOrThrow(config, fromLayer);
5964
+ findWritableLayerOrThrow(config, toLayer);
5965
+ const { plan, notFound, conflicts } = await planMove(config, {
5966
+ fromLayer,
5967
+ toLayer,
5968
+ key,
5969
+ targetKey
5970
+ });
5971
+ const identity = {
5972
+ fromLayer,
5973
+ toLayer,
5974
+ key,
5975
+ ...opts.newKey ? { newKey: opts.newKey } : {},
5976
+ ...notFound.length > 0 ? { notFoundInLocales: notFound } : {}
5977
+ };
5978
+ if (conflicts.length > 0) return {
5979
+ ...identity,
5980
+ conflictsInLocales: conflicts,
5981
+ summary: {
5982
+ localesAffected: 0,
5983
+ message: "Nothing was written.",
5984
+ warning: `"${targetKey}" already exists in "${toLayer}" with a different value in ${conflicts.length} locale(s). Resolve those locales first — reconcile the values, or move to a key that does not collide.`
5985
+ }
5986
+ };
5987
+ if (opts.dryRun ?? false) return {
5988
+ dryRun: true,
5989
+ wouldMove: plan,
5990
+ ...identity,
5991
+ summary: {
5992
+ localesAffected: plan.length,
5993
+ message: "Call again with dryRun: false to apply these changes."
5994
+ }
5995
+ };
5996
+ const applied = await applyMove(config, {
5997
+ fromLayer,
5998
+ toLayer,
5999
+ key,
6000
+ targetKey
6001
+ }, plan);
6002
+ return {
6003
+ movedLocales: applied.moved,
6004
+ ...applied.deduplicated.length > 0 ? { deduplicatedLocales: applied.deduplicated } : {},
6005
+ filesWritten: applied.filesWritten,
6006
+ ...identity
6007
+ };
6008
+ }
6009
+ /**
6010
+ * Decide every locale's outcome before any of them is written, so that one
6011
+ * conflicting locale can stop the whole move rather than half of it.
6012
+ */
6013
+ async function planMove(config, { fromLayer, toLayer, key, targetKey }) {
6014
+ const plan = [];
6015
+ const notFound = [];
6016
+ const conflicts = [];
6017
+ for (const locale of config.locales) {
6018
+ const source = await readLocaleDataIfPresent(config, fromLayer, locale);
6019
+ const value = source ? getNestedValue(source, key) : void 0;
6020
+ if (value === void 0) {
6021
+ notFound.push(locale.code);
6022
+ continue;
6023
+ }
6024
+ const target = await readLocaleDataIfPresent(config, toLayer, locale);
6025
+ const existing = target ? getNestedValue(target, targetKey) : void 0;
6026
+ if (existing === void 0) plan.push({
6027
+ locale: locale.code,
6028
+ value,
6029
+ action: "move"
6030
+ });
6031
+ else if (sameTranslation(existing, value)) plan.push({
6032
+ locale: locale.code,
6033
+ value,
6034
+ action: "deduplicate"
6035
+ });
6036
+ else conflicts.push(locale.code);
6037
+ }
6038
+ return {
6039
+ plan,
6040
+ notFound,
6041
+ conflicts
6042
+ };
6043
+ }
6044
+ /** Execute an already-validated plan. */
6045
+ async function applyMove(config, { fromLayer, toLayer, key, targetKey }, plan) {
6046
+ const moved = [];
6047
+ const deduplicated = [];
6048
+ const filesWritten = /* @__PURE__ */ new Set();
6049
+ for (const entry of plan) {
6050
+ const locale = findLocaleImpl(config, entry.locale);
6051
+ if (!locale) continue;
6052
+ if (entry.action === "move") {
6053
+ for (const file of await mutateLocaleData(config, toLayer, locale, (data) => {
6054
+ setNestedValue(data, targetKey, entry.value);
6055
+ })) filesWritten.add(file);
6056
+ moved.push(entry.locale);
6057
+ } else deduplicated.push(entry.locale);
6058
+ for (const file of await mutateLocaleData(config, fromLayer, locale, (data) => {
6059
+ removeNestedValue(data, key);
6060
+ })) filesWritten.add(file);
6061
+ }
6062
+ return {
6063
+ moved,
6064
+ deduplicated,
6065
+ filesWritten: filesWritten.size
6066
+ };
6067
+ }
6068
+ /**
6069
+ * Whether the target already holds what the move would write. Values are
6070
+ * usually strings, but a key can name a whole namespace object, so this
6071
+ * compares structurally rather than by identity.
6072
+ */
6073
+ function sameTranslation(a, b) {
6074
+ if (a === b) return true;
6075
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
6076
+ return JSON.stringify(a) === JSON.stringify(b);
6077
+ }
5102
6078
  //#endregion
5103
6079
  //#region src/core/ops-init.ts
5104
6080
  /**
@@ -5545,6 +6521,7 @@ function referenceLocaleAnchorPaths(config, layers, locale, projectDir) {
5545
6521
  * referenced in source code, plus code-usage scanning.
5546
6522
  */
5547
6523
  const MISPLACED_USAGE_NOTE = "Keys referenced only from apps that do not consume their layer. Either the key belongs in a broader (shared) layer, or the usage is a bug. These keys are not counted as orphans and are never removed.";
6524
+ const CANDIDATE_ONLY_NOTE = "These keys are protected only by the bare-candidate net: either a dotted string somewhere merely shares their name (often a comment or a data structure), or a call too ambiguous to commit to references them (a bare t(...) that could be anything). They are not offered for removal, but dead references hide here - verify before pruning.";
5548
6525
  /** True when `child` equals `parent` or lies inside it. */
5549
6526
  function isWithin(child, parent) {
5550
6527
  const rel = relative(parent, child);
@@ -5763,17 +6740,21 @@ async function findOrphanKeys(opts) {
5763
6740
  const output = {
5764
6741
  orphanKeys: sortedByLayer,
5765
6742
  uncertainKeys: orphanResult.uncertainCount > 0 ? orphanResult.uncertainByLayer : void 0,
6743
+ candidateOnlyKeys: orphanResult.candidateOnlyCount > 0 ? orphanResult.candidateOnlyByLayer : void 0,
6744
+ candidateOnlyNote: orphanResult.candidateOnlyCount > 0 ? CANDIDATE_ONLY_NOTE : void 0,
5766
6745
  misplacedUsages: misplacedCount > 0 ? orphanResult.misplacedUsages : void 0,
5767
6746
  misplacedUsageNote: misplacedCount > 0 ? MISPLACED_USAGE_NOTE : void 0,
5768
6747
  summary: {
5769
6748
  totalKeys,
5770
6749
  orphanCount: orphanResult.orphanCount,
5771
6750
  uncertainCount: orphanResult.uncertainCount,
6751
+ candidateOnlyCount: orphanResult.candidateOnlyCount,
5772
6752
  misplacedCount,
5773
6753
  dynamicMatchedCount: orphanResult.dynamicMatchedCount,
5774
6754
  ignoredCount: orphanResult.ignoredCount,
5775
6755
  usedCount: totalKeys - orphanResult.orphanCount - orphanResult.uncertainCount - misplacedCount,
5776
6756
  filesScanned: orphanResult.totalFilesScanned,
6757
+ filesDeclined: orphanResult.totalFilesDeclined,
5777
6758
  layersChecked: layersToCheck.map((d) => d.layer),
5778
6759
  dirsScanned: orphanResult.dirsScanned,
5779
6760
  scanScope: relativeScanScope(orphanResult, dir),
@@ -5818,9 +6799,11 @@ async function scanCodeUsage(opts) {
5818
6799
  const allUsages = [];
5819
6800
  const allDynamicKeys = [];
5820
6801
  let totalFilesScanned = 0;
6802
+ let totalFilesDeclined = 0;
5821
6803
  for (const scanDir of dirsToScan) {
5822
6804
  const result = await scanSourceFiles(scanDir, excludeDirs, getPatternSet(config.localeFileFormat));
5823
6805
  totalFilesScanned += result.filesScanned;
6806
+ totalFilesDeclined += result.declinedFiles.length;
5824
6807
  allUsages.push(...result.usages);
5825
6808
  allDynamicKeys.push(...result.dynamicKeys);
5826
6809
  }
@@ -5840,6 +6823,7 @@ async function scanCodeUsage(opts) {
5840
6823
  uniqueKeysFound: Object.keys(sortedByKey).length,
5841
6824
  totalReferences: filteredUsages.length,
5842
6825
  filesScanned: totalFilesScanned,
6826
+ filesDeclined: totalFilesDeclined,
5843
6827
  dirsScanned: dirsToScan
5844
6828
  }
5845
6829
  };
@@ -6084,6 +7068,26 @@ async function removeOrphanKeys(opts) {
6084
7068
  * Cross-layer duplicate-key detection: keys defined in both a shared layer
6085
7069
  * and a consuming child layer, compared in one reference locale.
6086
7070
  */
7071
+ /**
7072
+ * Values shorter than this are excluded from value grouping. "Ja", "OK" and
7073
+ * "Nein" repeat across unrelated namespaces legitimately, and reporting them
7074
+ * buries the findings worth acting on. A length floor is a blunt rule, which
7075
+ * is the point — anything cleverer would be guessing at intent.
7076
+ */
7077
+ const DEFAULT_MIN_VALUE_LENGTH = 4;
7078
+ /**
7079
+ * A floor arrives as a CLI string or an MCP number, so it can be NaN or
7080
+ * negative by the time it lands here. Comparing a length against NaN is always
7081
+ * false, which silently removes the floor and buries the report in "OK" — the
7082
+ * opposite of what asking for a floor means. Saying so beats defaulting: the
7083
+ * caller asked for a specific threshold and would not learn it was ignored.
7084
+ */
7085
+ function resolveMinValueLength(requested) {
7086
+ if (requested === void 0) return DEFAULT_MIN_VALUE_LENGTH;
7087
+ if (!Number.isFinite(requested) || requested < 0) throw new ToolError(`minValueLength must be a non-negative number, got ${String(requested)}.`, "INVALID_MIN_VALUE_LENGTH");
7088
+ return Math.floor(requested);
7089
+ }
7090
+ const VALUE_DUPLICATE_GUIDANCE = "Different keys carrying the same value. \"reuse\" means a shared layer already defines this value: delete the app-layer keys and repoint their call sites at the shared key. \"promote\" means two or more app layers define it and no shared layer does: move one to a shared layer (move_translation_key) and repoint the rest. \"consolidate\" is duplication inside one layer. Each duplicate is translated into every locale separately, so removing one saves provider spend on every future translate run, not just tidiness. Short generic labels (\"Name\", \"Status\") dominate the list by group size and are the least worth acting on individually; raise minValueLength to see the longer copy, where duplication is rarely deliberate.";
6087
7091
  const DUPLICATE_GUIDANCE = "At runtime the child layer's value shadows the shared layer's value for the same key. Fix each collision by deleting one side — usually the shared copy when the child value is authoritative, or the child copy to fall through to the shared value. Never move the key: both layers already define it.";
6088
7092
  /** Leaf values may be arrays (getLeafKeys treats them as leaves) — compare
6089
7093
  * those structurally; identity covers primitives. */
@@ -6137,25 +7141,75 @@ function emptyPairsMessage(config) {
6137
7141
  return "No (shared layer, child layer) pairs to check — no app consumes more than one locale-backed layer.";
6138
7142
  }
6139
7143
  /**
6140
- * Find keys defined in both a shared layer and a consuming child layer,
6141
- * comparing values in a single reference locale (default: the project
6142
- * default locale). Pure locale-file I/O — no source scanning.
7144
+ * Group keys by the value they carry, so that two keys spelling the same
7145
+ * string differently are visible as the duplication they are.
7146
+ *
7147
+ * Key-path collision detection cannot see this: `common.actions.save` and
7148
+ * `calendar.views.save` collide on nothing while both holding "Speichern"
7149
+ * (#343). Each duplicate is then translated into every locale independently,
7150
+ * so the duplication costs provider spend on every run, not just tidiness.
6143
7151
  */
6144
- async function findDuplicateKeys(opts = {}) {
6145
- const dir = opts.projectDir ?? process.cwd();
6146
- const config = await detectI18nConfig(dir);
6147
- const locale = opts.locale ? findLocaleOrThrow(config, opts.locale) : findLocaleImpl(config, config.defaultLocale) ?? config.locales[0];
6148
- if (!locale) throw new ToolError("No locales found in configuration.", "LOCALE_NOT_FOUND");
6149
- const pairs = deriveLayerPairs(config, buildLayerGraph(config));
6150
- const layerDataCache = /* @__PURE__ */ new Map();
6151
- const dataFor = (layer) => {
6152
- let cached = layerDataCache.get(layer);
6153
- if (!cached) {
6154
- cached = readLocaleData(config, layer, locale);
6155
- layerDataCache.set(layer, cached);
6156
- }
6157
- return cached;
7152
+ function groupByValue(entries, sharedLayers, minValueLength) {
7153
+ const byNormalized = /* @__PURE__ */ new Map();
7154
+ for (const entry of entries) {
7155
+ if (typeof entry.value !== "string") continue;
7156
+ const normalized = normalizeValue(entry.value);
7157
+ if (normalized.length < minValueLength) continue;
7158
+ const group = byNormalized.get(normalized) ?? {
7159
+ value: entry.value,
7160
+ normalized,
7161
+ action: "consolidate",
7162
+ members: []
7163
+ };
7164
+ group.members.push({
7165
+ key: entry.key,
7166
+ layer: entry.layer,
7167
+ shared: sharedLayers.has(entry.layer)
7168
+ });
7169
+ byNormalized.set(normalized, group);
7170
+ }
7171
+ const groups = [...byNormalized.values()].filter((group) => group.members.length > 1 && new Set(group.members.map((m) => m.key)).size > 1);
7172
+ for (const group of groups) group.action = classifyGroup(group.members);
7173
+ const rank = {
7174
+ reuse: 0,
7175
+ promote: 1,
7176
+ consolidate: 2
6158
7177
  };
7178
+ return groups.sort((a, b) => rank[a.action] - rank[b.action] || b.members.length - a.members.length || a.normalized.localeCompare(b.normalized));
7179
+ }
7180
+ function classifyGroup(members) {
7181
+ const inShared = members.filter((m) => m.shared);
7182
+ if (inShared.length > 0 && inShared.length < members.length) return "reuse";
7183
+ if (new Set(members.map((m) => m.layer)).size > 1) return "promote";
7184
+ return "consolidate";
7185
+ }
7186
+ /**
7187
+ * Fold away the differences that do not change what a translator would write:
7188
+ * surrounding space, capitalisation, internal whitespace runs and trailing
7189
+ * punctuation. "Speichern", "speichern " and "Speichern." group together.
7190
+ */
7191
+ function normalizeValue(value) {
7192
+ return value.trim().replace(/\s+/g, " ").replace(/[.!?:;,\u2026]+$/u, "").toLowerCase();
7193
+ }
7194
+ /** Every leaf key of every canonical layer, minus the ones config says to ignore. */
7195
+ async function collectLayerEntries(config, layers, dataFor) {
7196
+ const entries = [];
7197
+ for (const layer of layers) {
7198
+ const data = await dataFor(layer.layer);
7199
+ const ignore = buildIgnorePatternRegexes(resolveOrphanIgnorePatterns(config, layer.layer) ?? []);
7200
+ for (const key of getLeafKeys(data)) {
7201
+ if (ignore.some((re) => re.test(key))) continue;
7202
+ entries.push({
7203
+ key,
7204
+ layer: layer.layer,
7205
+ value: getNestedValue(data, key)
7206
+ });
7207
+ }
7208
+ }
7209
+ return entries;
7210
+ }
7211
+ /** Keys defined on both sides of each (shared, child) pair, in one locale. */
7212
+ async function findCollisions(pairs, dataFor) {
6159
7213
  const collisions = [];
6160
7214
  for (const { shared, child } of pairs) {
6161
7215
  const sharedData = await dataFor(shared.layer);
@@ -6175,23 +7229,57 @@ async function findDuplicateKeys(opts = {}) {
6175
7229
  });
6176
7230
  }
6177
7231
  }
7232
+ return collisions;
7233
+ }
7234
+ /**
7235
+ * Find keys defined in both a shared layer and a consuming child layer,
7236
+ * comparing values in a single reference locale (default: the project
7237
+ * default locale). Pure locale-file I/O — no source scanning.
7238
+ */
7239
+ async function findDuplicateKeys(opts = {}) {
7240
+ const dir = opts.projectDir ?? process.cwd();
7241
+ const config = await detectI18nConfig(dir);
7242
+ const locale = opts.locale ? findLocaleOrThrow(config, opts.locale) : findLocaleImpl(config, config.defaultLocale) ?? config.locales[0];
7243
+ if (!locale) throw new ToolError("No locales found in configuration.", "LOCALE_NOT_FOUND");
7244
+ const graph = buildLayerGraph(config);
7245
+ const pairs = deriveLayerPairs(config, graph);
7246
+ const layerDataCache = /* @__PURE__ */ new Map();
7247
+ const dataFor = (layer) => {
7248
+ let cached = layerDataCache.get(layer);
7249
+ if (!cached) {
7250
+ cached = readLocaleData(config, layer, locale);
7251
+ layerDataCache.set(layer, cached);
7252
+ }
7253
+ return cached;
7254
+ };
7255
+ const collisions = await findCollisions(pairs, dataFor);
7256
+ const valueDuplicates = opts.byValue ? groupByValue(await collectLayerEntries(config, graph.canonicalLayers, dataFor), new Set(pairs.map((pair) => pair.shared.layer)), resolveMinValueLength(opts.minValueLength)) : void 0;
6178
7257
  const summary = {
6179
7258
  totalCollisions: collisions.length,
6180
7259
  divergentCount: collisions.filter((c) => c.divergent).length,
6181
7260
  pairsChecked: pairs.length,
6182
7261
  locale: locale.code,
7262
+ ...valueDuplicates ? {
7263
+ valueGroups: valueDuplicates.length,
7264
+ reusableGroups: valueDuplicates.filter((g) => g.action === "reuse").length
7265
+ } : {},
6183
7266
  ...pairs.length === 0 ? { message: emptyPairsMessage(config) } : {}
6184
7267
  };
6185
7268
  const output = {
6186
7269
  collisions,
6187
- guidance: DUPLICATE_GUIDANCE,
7270
+ ...valueDuplicates ? { valueDuplicates } : {},
7271
+ guidance: valueDuplicates ? `${DUPLICATE_GUIDANCE}\n\n${VALUE_DUPLICATE_GUIDANCE}` : DUPLICATE_GUIDANCE,
6188
7272
  summary
6189
7273
  };
6190
7274
  const reportPath = resolveOutputFile(dir, opts.outputFile) ?? resolveReportFilePath(config, dir, "find_duplicate_keys");
6191
7275
  if (reportPath) {
6192
7276
  await writeReportFile(reportPath, output, {
6193
7277
  tool: "find_duplicate_keys",
6194
- args: { locale: opts.locale }
7278
+ args: {
7279
+ locale: opts.locale,
7280
+ byValue: opts.byValue,
7281
+ minValueLength: opts.minValueLength
7282
+ }
6195
7283
  });
6196
7284
  return {
6197
7285
  reportFile: reportPath,
@@ -6400,11 +7488,13 @@ async function checkUndefinedKeys(opts = {}) {
6400
7488
  const searchedLayersByApp = {};
6401
7489
  const checkedKeys = /* @__PURE__ */ new Set();
6402
7490
  let filesScanned = 0;
7491
+ let filesDeclined = 0;
6403
7492
  let ignoredCount = 0;
6404
7493
  for (const unit of units) {
6405
7494
  const ignores = globalScope ? [] : nestedUnitIgnores(unit, units);
6406
7495
  const scan = await scanSourceFiles(unit.dir, [...opts.excludeDirs ?? [], ...ignores], patterns);
6407
7496
  filesScanned += scan.filesScanned;
7497
+ filesDeclined += scan.declinedFiles.length;
6408
7498
  const searchedLayers = layersForUnit(unit.name);
6409
7499
  searchedLayersByApp[unit.name] = searchedLayers;
6410
7500
  const outcome = classifyUnitUsages(scan, {
@@ -6427,6 +7517,7 @@ async function checkUndefinedKeys(opts = {}) {
6427
7517
  uncertainCount: uncertainKeys.length,
6428
7518
  ignoredCount,
6429
7519
  filesScanned,
7520
+ filesDeclined,
6430
7521
  locale: localeCode,
6431
7522
  searchedLayersByApp,
6432
7523
  message: buildCheckMessage(undefinedKeys.length, uncertainKeys.length)
@@ -6459,6 +7550,6 @@ async function checkUndefinedKeys(opts = {}) {
6459
7550
  return output;
6460
7551
  }
6461
7552
  //#endregion
6462
- export { translateMissing as C, detectI18nConfig as D, clearConfigCache as E, getCachedConfig as O, translateKey as S, buildLayerGraph as T, getTranslations as _, scanCodeUsage as a, searchTranslations as b, addTranslations as c, scaffoldLocaleFiles as d, updateTranslations as f, getMissingTranslations as g, findEmptyTranslations as h, removeOrphanKeys as i, findLocaleImpl as k, removeTranslations as l, detectConfig as m, findDuplicateKeys as n, getTranslationStatus as o, writeTranslations as p, findOrphanKeys as r, initProjectConfig as s, checkUndefinedKeys as t, renameTranslationKey as u, listLocaleDirs as v, readLocaleData as w, resolveProtectedLocales as x, listNamespaces as y };
7553
+ export { resolveProtectedLocales as A, detectConfig as C, listLocaleDirs as D, getTranslations as E, serializeLayerGraph as F, clearConfigCache as I, detectI18nConfig as L, translateMissing as M, readLocaleData as N, listNamespaces as O, buildLayerGraph as P, getCachedConfig as R, createOxcFrontend as S, getMissingTranslations as T, getPatternSet as _, scanCodeUsage as a, LARAVEL_PATTERNS as b, addTranslations as c, renameTranslationKey as d, scaffoldLocaleFiles as f, VUE_NUXT_PATTERNS as g, scanSourceFiles as h, removeOrphanKeys as i, translateKey as j, searchTranslations as k, moveTranslationKey as l, writeTranslations as m, findDuplicateKeys as n, getTranslationStatus as o, updateTranslations as p, findOrphanKeys as r, initProjectConfig as s, checkUndefinedKeys as t, removeTranslations as u, createPatternsFrontend as v, findEmptyTranslations as w, createPhpFrontend as x, createBladeFrontend as y, findLocaleImpl as z };
6463
7554
 
6464
- //# sourceMappingURL=operations-BCJd7in3.js.map
7555
+ //# sourceMappingURL=operations-CTo44gPu.js.map