@aperant/framework 0.13.0 → 0.15.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 (241) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +1 -1
  3. package/agents/apt-pr-review-i18n.md +190 -0
  4. package/agents/apt-pr-review-xplatform.md +189 -0
  5. package/dist/cli/commands/audit-branch-current.d.mts +27 -5
  6. package/dist/cli/commands/audit-branch-current.d.mts.map +1 -1
  7. package/dist/cli/commands/audit-branch-current.mjs +3 -3
  8. package/dist/cli/commands/audit-branch-current.mjs.map +1 -1
  9. package/dist/cli/commands/event.d.mts.map +1 -1
  10. package/dist/cli/commands/event.mjs +6 -4
  11. package/dist/cli/commands/event.mjs.map +1 -1
  12. package/dist/cli/commands/fan-out.d.mts.map +1 -1
  13. package/dist/cli/commands/fan-out.mjs +3 -1
  14. package/dist/cli/commands/fan-out.mjs.map +1 -1
  15. package/dist/cli/commands/features-audit.d.mts.map +1 -1
  16. package/dist/cli/commands/features-audit.mjs +6 -1
  17. package/dist/cli/commands/features-audit.mjs.map +1 -1
  18. package/dist/cli/commands/followups.d.mts +5 -0
  19. package/dist/cli/commands/followups.d.mts.map +1 -0
  20. package/dist/cli/commands/followups.mjs +266 -0
  21. package/dist/cli/commands/followups.mjs.map +1 -0
  22. package/dist/cli/commands/health-check.d.mts.map +1 -1
  23. package/dist/cli/commands/health-check.mjs +3 -1
  24. package/dist/cli/commands/health-check.mjs.map +1 -1
  25. package/dist/cli/commands/merge-detect.d.mts +29 -0
  26. package/dist/cli/commands/merge-detect.d.mts.map +1 -0
  27. package/dist/cli/commands/merge-detect.mjs +196 -0
  28. package/dist/cli/commands/merge-detect.mjs.map +1 -0
  29. package/dist/cli/commands/merge-integrate.d.mts +28 -0
  30. package/dist/cli/commands/merge-integrate.d.mts.map +1 -0
  31. package/dist/cli/commands/merge-integrate.mjs +383 -0
  32. package/dist/cli/commands/merge-integrate.mjs.map +1 -0
  33. package/dist/cli/commands/pr-review/detect-specialists.d.mts +58 -0
  34. package/dist/cli/commands/pr-review/detect-specialists.d.mts.map +1 -0
  35. package/dist/cli/commands/pr-review/detect-specialists.mjs +980 -0
  36. package/dist/cli/commands/pr-review/detect-specialists.mjs.map +1 -0
  37. package/dist/cli/commands/pr-review.d.mts.map +1 -1
  38. package/dist/cli/commands/pr-review.mjs +11 -2
  39. package/dist/cli/commands/pr-review.mjs.map +1 -1
  40. package/dist/cli/commands/precompact-hook.d.mts.map +1 -1
  41. package/dist/cli/commands/precompact-hook.mjs +10 -6
  42. package/dist/cli/commands/precompact-hook.mjs.map +1 -1
  43. package/dist/cli/commands/roadmap.d.mts.map +1 -1
  44. package/dist/cli/commands/roadmap.mjs +24 -11
  45. package/dist/cli/commands/roadmap.mjs.map +1 -1
  46. package/dist/cli/commands/seed.d.mts.map +1 -1
  47. package/dist/cli/commands/seed.mjs +3 -1
  48. package/dist/cli/commands/seed.mjs.map +1 -1
  49. package/dist/cli/commands/state.d.mts.map +1 -1
  50. package/dist/cli/commands/state.mjs +9 -3
  51. package/dist/cli/commands/state.mjs.map +1 -1
  52. package/dist/cli/commands/statusline.d.mts.map +1 -1
  53. package/dist/cli/commands/statusline.mjs +5 -1
  54. package/dist/cli/commands/statusline.mjs.map +1 -1
  55. package/dist/cli/commands/task.d.mts.map +1 -1
  56. package/dist/cli/commands/task.mjs +126 -23
  57. package/dist/cli/commands/task.mjs.map +1 -1
  58. package/dist/cli/commands/worktree.d.mts +1 -1
  59. package/dist/cli/commands/worktree.d.mts.map +1 -1
  60. package/dist/cli/commands/worktree.mjs +282 -83
  61. package/dist/cli/commands/worktree.mjs.map +1 -1
  62. package/dist/cli/config/post-merge-sweep.d.mts.map +1 -1
  63. package/dist/cli/config/post-merge-sweep.mjs +3 -2
  64. package/dist/cli/config/post-merge-sweep.mjs.map +1 -1
  65. package/dist/cli/config/share-policy.d.mts +3 -1
  66. package/dist/cli/config/share-policy.d.mts.map +1 -1
  67. package/dist/cli/config/share-policy.mjs +16 -3
  68. package/dist/cli/config/share-policy.mjs.map +1 -1
  69. package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
  70. package/dist/cli/config/upgrade-gitignore.mjs +27 -5
  71. package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
  72. package/dist/cli/consistency/parse-qa.mjs +12 -1
  73. package/dist/cli/consistency/parse-qa.mjs.map +1 -1
  74. package/dist/cli/consistency/parse-review.d.mts +23 -0
  75. package/dist/cli/consistency/parse-review.d.mts.map +1 -1
  76. package/dist/cli/consistency/parse-review.mjs +36 -0
  77. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  78. package/dist/cli/consistency/parse-spec.d.mts +16 -0
  79. package/dist/cli/consistency/parse-spec.d.mts.map +1 -1
  80. package/dist/cli/consistency/parse-spec.mjs +44 -0
  81. package/dist/cli/consistency/parse-spec.mjs.map +1 -1
  82. package/dist/cli/coordination/agent-identity.d.mts +5 -2
  83. package/dist/cli/coordination/agent-identity.d.mts.map +1 -1
  84. package/dist/cli/coordination/agent-identity.mjs +6 -4
  85. package/dist/cli/coordination/agent-identity.mjs.map +1 -1
  86. package/dist/cli/coordination/store.d.mts +1 -1
  87. package/dist/cli/dispatch.d.mts.map +1 -1
  88. package/dist/cli/dispatch.mjs +4 -0
  89. package/dist/cli/dispatch.mjs.map +1 -1
  90. package/dist/cli/followups/derive.d.mts +63 -0
  91. package/dist/cli/followups/derive.d.mts.map +1 -0
  92. package/dist/cli/followups/derive.mjs +115 -0
  93. package/dist/cli/followups/derive.mjs.map +1 -0
  94. package/dist/cli/followups/schema.d.mts +165 -0
  95. package/dist/cli/followups/schema.d.mts.map +1 -0
  96. package/dist/cli/followups/schema.mjs +251 -0
  97. package/dist/cli/followups/schema.mjs.map +1 -0
  98. package/dist/cli/gate/context.d.mts +1 -1
  99. package/dist/cli/gate/gates/followups-adjudicated.d.mts +69 -0
  100. package/dist/cli/gate/gates/followups-adjudicated.d.mts.map +1 -0
  101. package/dist/cli/gate/gates/followups-adjudicated.mjs +141 -0
  102. package/dist/cli/gate/gates/followups-adjudicated.mjs.map +1 -0
  103. package/dist/cli/gate/gates/index.mjs +1 -0
  104. package/dist/cli/gate/gates/index.mjs.map +1 -1
  105. package/dist/cli/git/materialize-env.d.mts +39 -0
  106. package/dist/cli/git/materialize-env.d.mts.map +1 -0
  107. package/dist/cli/git/materialize-env.mjs +217 -0
  108. package/dist/cli/git/materialize-env.mjs.map +1 -0
  109. package/dist/cli/help.d.mts.map +1 -1
  110. package/dist/cli/help.mjs +9 -1
  111. package/dist/cli/help.mjs.map +1 -1
  112. package/dist/cli/install/legacy-paths.d.mts.map +1 -1
  113. package/dist/cli/install/legacy-paths.mjs +4 -0
  114. package/dist/cli/install/legacy-paths.mjs.map +1 -1
  115. package/dist/cli/roadmap/lifecycle.d.mts.map +1 -1
  116. package/dist/cli/roadmap/lifecycle.mjs +4 -1
  117. package/dist/cli/roadmap/lifecycle.mjs.map +1 -1
  118. package/dist/cli/task/ids.d.mts +9 -0
  119. package/dist/cli/task/ids.d.mts.map +1 -1
  120. package/dist/cli/task/ids.mjs +9 -0
  121. package/dist/cli/task/ids.mjs.map +1 -1
  122. package/dist/cli/task/index-md.d.mts +5 -0
  123. package/dist/cli/task/index-md.d.mts.map +1 -1
  124. package/dist/cli/task/index-md.mjs +12 -4
  125. package/dist/cli/task/index-md.mjs.map +1 -1
  126. package/dist/cli/util/io.d.mts +6 -0
  127. package/dist/cli/util/io.d.mts.map +1 -1
  128. package/dist/cli/util/io.mjs +16 -1
  129. package/dist/cli/util/io.mjs.map +1 -1
  130. package/dist/cli/util/project.d.mts +7 -2
  131. package/dist/cli/util/project.d.mts.map +1 -1
  132. package/dist/cli/util/project.mjs +11 -14
  133. package/dist/cli/util/project.mjs.map +1 -1
  134. package/dist/cli/util/state-paths.d.mts +19 -0
  135. package/dist/cli/util/state-paths.d.mts.map +1 -0
  136. package/dist/cli/util/state-paths.mjs +45 -0
  137. package/dist/cli/util/state-paths.mjs.map +1 -0
  138. package/dist/plugin/.claude-plugin/plugin.json +4 -1
  139. package/dist/plugin/agents/apt-pr-review-i18n.md +190 -0
  140. package/dist/plugin/agents/apt-pr-review-xplatform.md +189 -0
  141. package/dist/plugin/skills/apt/SKILL.md +9 -3
  142. package/dist/plugin/skills/apt-close-task/SKILL.md +48 -0
  143. package/dist/plugin/skills/apt-debug/SKILL.md +19 -9
  144. package/dist/plugin/skills/apt-execute/SKILL.md +3 -1
  145. package/dist/plugin/skills/apt-fan-out/SKILL.md +65 -1
  146. package/dist/plugin/skills/apt-merge-conflict-resolution/SKILL.md +227 -0
  147. package/dist/plugin/skills/apt-pr-review/SCHEMA.md +4 -2
  148. package/dist/plugin/skills/apt-pr-review/SKILL.md +117 -29
  149. package/dist/plugin/skills/apt-quick/SKILL.md +24 -1
  150. package/dist/plugin/skills/apt-review/SKILL.md +6 -0
  151. package/dist/plugin/skills/apt-run/SKILL.md +1 -1
  152. package/dist/plugin/skills/apt-setup/SKILL.md +21 -3
  153. package/dist/plugin/skills/apt-ship/SKILL.md +32 -11
  154. package/dist/plugin/skills/apt-terminal/SKILL.md +8 -0
  155. package/dist/plugin/skills/apt-triage/SKILL.md +4 -0
  156. package/dist/plugin/skills/apt-verify/SKILL.md +24 -0
  157. package/dist/plugin/skills/apt-watch-ci/SKILL.md +3 -1
  158. package/dist/types/config.d.ts +33 -0
  159. package/dist/types/config.d.ts.map +1 -1
  160. package/package.json +1 -1
  161. package/prompts/conductor-framework-context.md +1 -1
  162. package/prompts/github/pr_i18n_agent.md +279 -0
  163. package/prompts/github/pr_xplatform_agent.md +283 -0
  164. package/prompts/inbox-clarification.md +18 -11
  165. package/skills/apt/SKILL.md +9 -3
  166. package/skills/apt-close-task/SKILL.md +48 -0
  167. package/skills/apt-debug/SKILL.md +19 -9
  168. package/skills/apt-execute/SKILL.md +3 -1
  169. package/skills/apt-fan-out/SKILL.md +65 -1
  170. package/skills/apt-merge-conflict-resolution/SKILL.md +227 -0
  171. package/skills/apt-pr-review/SCHEMA.md +4 -2
  172. package/skills/apt-pr-review/SKILL.md +117 -29
  173. package/skills/apt-quick/SKILL.md +24 -1
  174. package/skills/apt-review/SKILL.md +6 -0
  175. package/skills/apt-run/SKILL.md +1 -1
  176. package/skills/apt-setup/SKILL.md +21 -3
  177. package/skills/apt-ship/SKILL.md +32 -11
  178. package/skills/apt-terminal/SKILL.md +8 -0
  179. package/skills/apt-triage/SKILL.md +4 -0
  180. package/skills/apt-verify/SKILL.md +24 -0
  181. package/skills/apt-watch-ci/SKILL.md +3 -1
  182. package/src/cli/commands/audit-branch-current.mjs +3 -3
  183. package/src/cli/commands/event.mjs +6 -4
  184. package/src/cli/commands/fan-out.mjs +3 -1
  185. package/src/cli/commands/features-audit.mjs +6 -1
  186. package/src/cli/commands/followups.mjs +303 -0
  187. package/src/cli/commands/health-check.mjs +3 -1
  188. package/src/cli/commands/merge-detect.mjs +203 -0
  189. package/src/cli/commands/merge-integrate.mjs +392 -0
  190. package/src/cli/commands/pr-review/__fixtures__/android-strings/app/src/main/res/values-fr/strings.xml +3 -0
  191. package/src/cli/commands/pr-review/__fixtures__/babel-js/package.json +4 -0
  192. package/src/cli/commands/pr-review/__fixtures__/c-ifdef/src/main.c +4 -0
  193. package/src/cli/commands/pr-review/__fixtures__/ci-matrix/.github/workflows/ci.yml +10 -0
  194. package/src/cli/commands/pr-review/__fixtures__/cmake-win/CMakeLists.txt +5 -0
  195. package/src/cli/commands/pr-review/__fixtures__/diffs/add-arb.patch +9 -0
  196. package/src/cli/commands/pr-review/__fixtures__/diffs/ci-single-os-dep.patch +13 -0
  197. package/src/cli/commands/pr-review/__fixtures__/diffs/ci-single-os-list-item-dep.patch +17 -0
  198. package/src/cli/commands/pr-review/__fixtures__/diffs/lone-weak-t.patch +8 -0
  199. package/src/cli/commands/pr-review/__fixtures__/diffs/monorepo-b.patch +7 -0
  200. package/src/cli/commands/pr-review/__fixtures__/diffs/two-weak-xpl.patch +12 -0
  201. package/src/cli/commands/pr-review/__fixtures__/dropped-underscore/src/app.js +2 -0
  202. package/src/cli/commands/pr-review/__fixtures__/electron-app/package.json +4 -0
  203. package/src/cli/commands/pr-review/__fixtures__/i18n-locale-file/src/locales/fr.json +1 -0
  204. package/src/cli/commands/pr-review/__fixtures__/monorepo/packages/a/locales/fr.json +1 -0
  205. package/src/cli/commands/pr-review/__fixtures__/monorepo/packages/b/src/index.ts +1 -0
  206. package/src/cli/commands/pr-review/__fixtures__/py-babel/pyproject.toml +3 -0
  207. package/src/cli/commands/pr-review/__fixtures__/qt-i18n-dir/i18n/app_fr.ts +11 -0
  208. package/src/cli/commands/pr-review/__fixtures__/rust-cli/Cargo.toml +7 -0
  209. package/src/cli/commands/pr-review/__fixtures__/rust-cli/src/main.rs +1 -0
  210. package/src/cli/commands/pr-review/__fixtures__/ts-dir/ts/foo.ts +3 -0
  211. package/src/cli/commands/pr-review/detect-specialists.mjs +1023 -0
  212. package/src/cli/commands/pr-review.mjs +12 -2
  213. package/src/cli/commands/precompact-hook.mjs +10 -6
  214. package/src/cli/commands/roadmap.mjs +24 -11
  215. package/src/cli/commands/seed.mjs +3 -1
  216. package/src/cli/commands/state.mjs +9 -3
  217. package/src/cli/commands/statusline.mjs +5 -1
  218. package/src/cli/commands/task.mjs +135 -23
  219. package/src/cli/commands/worktree.mjs +298 -81
  220. package/src/cli/config/post-merge-sweep.mjs +3 -2
  221. package/src/cli/config/share-policy.mjs +16 -3
  222. package/src/cli/config/upgrade-gitignore.mjs +27 -5
  223. package/src/cli/consistency/parse-qa.mjs +11 -1
  224. package/src/cli/consistency/parse-review.mjs +36 -0
  225. package/src/cli/consistency/parse-spec.mjs +43 -0
  226. package/src/cli/coordination/agent-identity.mjs +6 -4
  227. package/src/cli/dispatch.mjs +4 -0
  228. package/src/cli/followups/derive.mjs +123 -0
  229. package/src/cli/followups/schema.mjs +264 -0
  230. package/src/cli/gate/gates/followups-adjudicated.mjs +158 -0
  231. package/src/cli/gate/gates/index.mjs +1 -0
  232. package/src/cli/git/materialize-env.mjs +212 -0
  233. package/src/cli/help.mjs +9 -1
  234. package/src/cli/install/legacy-paths.mjs +4 -0
  235. package/src/cli/roadmap/lifecycle.mjs +4 -1
  236. package/src/cli/task/ids.mjs +9 -0
  237. package/src/cli/task/index-md.mjs +12 -4
  238. package/src/cli/util/io.mjs +15 -1
  239. package/src/cli/util/project.mjs +11 -14
  240. package/src/cli/util/state-paths.mjs +45 -0
  241. package/prompts/followup_planner.md +0 -399
@@ -0,0 +1,980 @@
1
+ /**
2
+ * commands/pr-review/detect-specialists.mjs — deterministic detector for the
3
+ * two CONDITIONAL /apt:pr-review specialists (i18n + cross-platform).
4
+ *
5
+ * Restores the 2.8 `i18n-reviewer` + `xplatform` perspectives but gates their
6
+ * Phase-3 spawn on a reproducible, language/platform/ecosystem-agnostic scan
7
+ * with a config override on top. The SKILL orchestrator NEVER decides
8
+ * engagement by reading the diff itself — it calls this detector and obeys the
9
+ * JSON (ID-01).
10
+ *
11
+ * Engagement model (spec ID-01/ID-01a/ID-02/ID-02a/ID-03/ID-03a/ID-03b):
12
+ *
13
+ * 1. Config check FIRST and short-circuit (ID-01a). `.aperant/config.json`
14
+ * pr_review.specialists.{i18n,xplatform} ∈ {auto|always|never}, default
15
+ * auto. `always` → engage (no scan); `never` → skip (no scan); only
16
+ * `auto`/absent proceeds. Config is the TOP of the precedence chain — a
17
+ * diff-introduced STRONG signal does NOT override `never`.
18
+ * 2. Collect signals from repo baseline ∪ PR diff (ID-02). A `--diff <path>`
19
+ * flag (the orchestrator already writes diff.patch) adds changed-file
20
+ * paths + added lines as diff-introduced signals.
21
+ * 3. Apply --scope (ID-02a). Default `repo`: baseline counts repo-wide.
22
+ * `diff`: a baseline signal only counts when it shares an owning/top-level
23
+ * directory with a diff-touched path; otherwise only diff signals count.
24
+ * 4. Decide engage via the STRONG/WEAK tier rule (ID-03a):
25
+ * engage = (>= 1 STRONG signal) OR (>= 2 distinct WEAK signals)
26
+ * A lone weak token never spawns a specialist.
27
+ *
28
+ * Each signal is `{ kind, detail, tier }` where `tier ∈ {strong, weak}` and
29
+ * `detail` names the exact match (file path / manifest dep / token + file) so
30
+ * the human-readable `reason` is traceable.
31
+ *
32
+ * The signal tables are CURATED + EXTENSIBLE — a new ecosystem is added by
33
+ * appending one entry; the engine logic is unchanged.
34
+ */
35
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
36
+ import { extname, join, relative, resolve, sep } from 'node:path';
37
+ import { parseFlags } from '../../util/args.mjs';
38
+ import { err, ok } from '../../util/result.mjs';
39
+ /**
40
+ * @typedef {{ kind: string, detail: string, tier: 'strong' | 'weak' }} Signal
41
+ *
42
+ * @typedef {{ engage: boolean, signals: Signal[], reason: string }} SpecialistDecision
43
+ */
44
+ // Manifests scanned for dependency-substring i18n/xplatform library markers.
45
+ // No YAML/TOML parse — substring/regex on the raw text, mirroring
46
+ // toolchain-detect.mjs:isBerryNodeLinker (R2).
47
+ const MANIFEST_FILES = [
48
+ 'package.json',
49
+ 'Cargo.toml',
50
+ 'pyproject.toml',
51
+ 'requirements.txt',
52
+ 'go.mod',
53
+ 'Gemfile',
54
+ 'composer.json',
55
+ 'build.gradle',
56
+ 'build.gradle.kts',
57
+ 'Podfile',
58
+ 'pubspec.yaml',
59
+ ];
60
+ // CI workflow files scanned for a multi-OS matrix (substring of runs-on:/os:).
61
+ const CI_FILES = ['.gitlab-ci.yml', 'azure-pipelines.yml'];
62
+ // Directories never walked when collecting baseline file-pattern signals.
63
+ const SKIP_DIRS = new Set([
64
+ '.git',
65
+ 'node_modules',
66
+ '.aperant',
67
+ 'dist',
68
+ 'build',
69
+ 'target',
70
+ '.next',
71
+ 'vendor',
72
+ '.venv',
73
+ 'venv',
74
+ '__pycache__',
75
+ ]);
76
+ // Cap the baseline file walk so a huge repo can't make the detector slow.
77
+ const MAX_WALK_FILES = 20000;
78
+ /**
79
+ * Walk `root` collecting relative file paths (POSIX-normalized), skipping
80
+ * heavy/irrelevant dirs. Bounded by MAX_WALK_FILES.
81
+ *
82
+ * @param {string} root
83
+ * @returns {string[]} repo-relative paths using '/'
84
+ */
85
+ function walkFiles(root) {
86
+ const out = [];
87
+ const stack = [root];
88
+ while (stack.length > 0 && out.length < MAX_WALK_FILES) {
89
+ const dir = stack.pop();
90
+ let entries;
91
+ try {
92
+ entries = readdirSync(dir, { withFileTypes: true });
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ for (const entry of entries) {
98
+ const abs = join(dir, entry.name);
99
+ if (entry.isDirectory()) {
100
+ if (SKIP_DIRS.has(entry.name))
101
+ continue;
102
+ stack.push(abs);
103
+ }
104
+ else if (entry.isFile()) {
105
+ out.push(relative(root, abs).split(sep).join('/'));
106
+ if (out.length >= MAX_WALK_FILES)
107
+ break;
108
+ }
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+ /** Read a file as UTF-8, returning '' on any error. */
114
+ function readSafe(absPath) {
115
+ try {
116
+ if (!existsSync(absPath))
117
+ return '';
118
+ if (statSync(absPath).isDirectory())
119
+ return '';
120
+ return readFileSync(absPath, 'utf-8');
121
+ }
122
+ catch {
123
+ return '';
124
+ }
125
+ }
126
+ // ── i18n file-pattern matchers (STRONG) ─────────────────────────────────────
127
+ // Locale/translation file EXTENSIONS that are unambiguous on their own.
128
+ const I18N_LOCALE_EXTS = new Set([
129
+ '.po',
130
+ '.pot',
131
+ '.mo',
132
+ '.xliff',
133
+ '.xlf',
134
+ '.arb',
135
+ '.strings',
136
+ '.stringsdict',
137
+ '.resx',
138
+ '.resw',
139
+ '.xcstrings',
140
+ '.ftl',
141
+ ]);
142
+ // Directory-name segments that mark a path as i18n/locale territory.
143
+ const I18N_DIR_SEGMENTS = ['locales', 'i18n', 'translations'];
144
+ /**
145
+ * Decide whether a repo-relative path is a STRONG i18n locale-file signal.
146
+ * Returns a `detail` string when it matches, else null. Encodes the ID-03 +
147
+ * ID-03b file-pattern rules (Android values-*, Java messages bundle, Qt *.ts
148
+ * disambiguation).
149
+ *
150
+ * @param {string} relPath POSIX-normalized repo-relative path
151
+ * @returns {string | null}
152
+ */
153
+ function i18nLocaleFileDetail(relPath) {
154
+ const lower = relPath.toLowerCase();
155
+ const segments = lower.split('/');
156
+ const base = segments[segments.length - 1];
157
+ const ext = extname(lower);
158
+ // 1. Unambiguous locale-file extension.
159
+ if (I18N_LOCALE_EXTS.has(ext))
160
+ return `locale-file ${relPath}`;
161
+ // 2. Path lives under a locale/i18n/translations dir.
162
+ if (segments.some((s) => I18N_DIR_SEGMENTS.includes(s))) {
163
+ return `locale-dir file ${relPath}`;
164
+ }
165
+ // 3. lang/**/*.json
166
+ if (segments.includes('lang') && ext === '.json')
167
+ return `lang-json ${relPath}`;
168
+ // 4. messages.<locale>.json (e.g. messages.fr.json)
169
+ if (/^messages\.[a-z-]+\.json$/.test(base))
170
+ return `messages-json ${relPath}`;
171
+ // 5. Android string resources: res/values/strings.xml + res/values-*/strings.xml
172
+ if (base === 'strings.xml') {
173
+ const valuesIdx = segments.findIndex((s) => s === 'values' || s.startsWith('values-'));
174
+ if (valuesIdx >= 0 && segments[valuesIdx - 1] === 'res') {
175
+ return `android-strings ${relPath}`;
176
+ }
177
+ }
178
+ // 6. Java resource bundles: *_*.properties under a messages/i18n/resources dir.
179
+ if (/^[a-zA-Z0-9]+_[a-zA-Z]{2}(_[A-Za-z]+)?\.properties$/.test(base)) {
180
+ if (segments.some((s) => ['messages', 'i18n', 'resources'].includes(s))) {
181
+ return `java-bundle ${relPath}`;
182
+ }
183
+ }
184
+ // 7. Qt translation *.ts — ID-03b: ONLY under a translations/i18n dir
185
+ // (a bare top-level *.ts is TypeScript and is NOT an i18n signal; a plain
186
+ // source dir literally named `ts/` is the common false positive, so it is
187
+ // NOT a translation-dir marker — real Qt Linguist files live under
188
+ // translations/ or i18n/).
189
+ if (ext === '.ts') {
190
+ if (segments.some((s) => ['translations', 'i18n'].includes(s))) {
191
+ return `qt-translation ${relPath}`;
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+ // ── i18n manifest-dependency matchers ───────────────────────────────────────
197
+ // Unambiguous i18n libraries (STRONG) matched as substrings in ANY manifest
198
+ // EXCEPT the Python-only Babel family (handled separately in
199
+ // scanManifestForI18n because JS babel is a transpiler, NOT i18n — ID-03b).
200
+ const I18N_LIB_SUBSTRINGS = [
201
+ 'i18next',
202
+ 'react-i18next',
203
+ 'vue-i18n',
204
+ '@formatjs',
205
+ 'intl-messageformat',
206
+ 'lingui',
207
+ '@lingui',
208
+ 'node-polyglot',
209
+ '@fluent',
210
+ 'fluent-bundle',
211
+ 'rust-i18n',
212
+ 'go-i18n',
213
+ 'gotext',
214
+ 'symfony/translation',
215
+ 'gettext',
216
+ 'rails-i18n',
217
+ ];
218
+ // Python-only Babel family (STRONG) — only counts in a Python manifest
219
+ // (pyproject.toml / requirements.txt). ID-03b: a package.json babel* dep is
220
+ // NEVER an i18n signal.
221
+ const PY_BABEL_SUBSTRINGS = ['babel', 'flask-babel', 'pybabel'];
222
+ // ACCEPTED LIMITATION (no-parse design, R2): this scans the manifest's RAW
223
+ // text as a substring, NOT a parsed dependency graph. A library name that
224
+ // appears in prose — e.g. `"description": "a gettext parser"` — can therefore
225
+ // false-trigger an i18n signal. A real fix requires parsing the manifest
226
+ // (YAML/TOML/JSON), which this detector DELIBERATELY avoids (see the file
227
+ // header + the MANIFEST_FILES comment). The STRONG/WEAK tier model bounds the
228
+ // blast radius for source TOKENS, but a manifest LIBRARY match is STRONG, so
229
+ // this specific prose-substring false positive is knowingly tolerated.
230
+ /**
231
+ * Scan a single manifest's raw text for i18n library substrings.
232
+ *
233
+ * @param {string} manifestName
234
+ * @param {string} text
235
+ * @returns {Array<{kind:string, detail:string, tier:string}>}
236
+ */
237
+ function scanManifestForI18n(manifestName, text) {
238
+ const found = [];
239
+ const lower = text.toLowerCase();
240
+ const isPythonManifest = manifestName === 'pyproject.toml' || manifestName === 'requirements.txt';
241
+ for (const lib of I18N_LIB_SUBSTRINGS) {
242
+ if (lower.includes(lib.toLowerCase())) {
243
+ found.push({ kind: 'i18n-lib', detail: `${lib} in ${manifestName}`, tier: 'strong' });
244
+ }
245
+ }
246
+ // Python Babel family — ID-03b. `flask-babel`/`pybabel` are unambiguous;
247
+ // bare `babel` only counts in a Python manifest context.
248
+ if (isPythonManifest) {
249
+ for (const lib of PY_BABEL_SUBSTRINGS) {
250
+ // Match `babel` as a token (word boundary) so we don't catch e.g.
251
+ // some unrelated `babelfish` substring; flask-babel/pybabel are
252
+ // matched as plain substrings.
253
+ const re = lib === 'babel' ? /\bbabel\b/i : new RegExp(lib.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
254
+ if (re.test(text)) {
255
+ found.push({
256
+ kind: 'i18n-lib',
257
+ detail: `python-${lib} in ${manifestName}`,
258
+ tier: 'strong',
259
+ });
260
+ }
261
+ }
262
+ }
263
+ return found;
264
+ }
265
+ // ── i18n source-token matchers (WEAK unless corroborated by an i18n import) ──
266
+ // An i18n import present in a source file makes a `t(`/`i18n.t(` token STRONG.
267
+ const I18N_IMPORT_HINTS = [
268
+ 'i18next',
269
+ 'react-i18next',
270
+ 'vue-i18n',
271
+ '@lingui',
272
+ '@formatjs',
273
+ 'use-intl',
274
+ 'next-intl',
275
+ 'gettext',
276
+ '@fluent',
277
+ ];
278
+ // Lone i18n source tokens (WEAK). `_(` is DROPPED entirely (ID-03b — too noisy).
279
+ const I18N_WEAK_TOKEN_RES = [
280
+ { re: /\bi18n\.t\s*\(/, label: 'i18n.t(' },
281
+ { re: /(?<![\w.])t\s*\(\s*['"]/, label: 't(' },
282
+ { re: /\bgettext\s*\(/, label: 'gettext(' },
283
+ { re: /\bngettext\s*\(/, label: 'ngettext(' },
284
+ { re: /\bNSLocalizedString\s*\(/, label: 'NSLocalizedString(' },
285
+ { re: /\bR\.string\./, label: 'R.string.' },
286
+ { re: /\bString\.localized\b/, label: 'String.localized' },
287
+ { re: /#\[derive\(Localize\)\]/, label: '#[derive(Localize)]' },
288
+ ];
289
+ /**
290
+ * Scan one source blob for i18n tokens. A `t(`/`i18n.t(` token is STRONG when
291
+ * an i18n import is present in the SAME blob; otherwise WEAK (ID-03b).
292
+ *
293
+ * @param {string} detailLabel where the blob came from (file path or "diff")
294
+ * @param {string} text
295
+ * @returns {Array<{kind:string, detail:string, tier:string}>}
296
+ */
297
+ function scanSourceForI18nTokens(detailLabel, text) {
298
+ const found = [];
299
+ const hasI18nImport = I18N_IMPORT_HINTS.some((h) => text.includes(h));
300
+ for (const { re, label } of I18N_WEAK_TOKEN_RES) {
301
+ if (re.test(text)) {
302
+ const corroborated = (label === 't(' || label === 'i18n.t(') && hasI18nImport;
303
+ found.push({
304
+ kind: 'i18n-token',
305
+ detail: corroborated
306
+ ? `${label} in ${detailLabel} (i18n import present)`
307
+ : `${label} in ${detailLabel} (no i18n import)`,
308
+ tier: corroborated ? 'strong' : 'weak',
309
+ });
310
+ }
311
+ }
312
+ return found;
313
+ }
314
+ // ── xplatform matchers ──────────────────────────────────────────────────────
315
+ // Cross-platform app frameworks (STRONG) matched as manifest substrings.
316
+ const XPL_FRAMEWORK_SUBSTRINGS = [
317
+ 'electron',
318
+ '@tauri-apps',
319
+ 'react-native',
320
+ 'flutter',
321
+ '@capacitor',
322
+ 'nativescript',
323
+ 'maui',
324
+ 'xamarin',
325
+ 'pyqt',
326
+ 'pyside',
327
+ 'kivy',
328
+ ];
329
+ /**
330
+ * Scan a manifest for cross-platform framework substrings.
331
+ *
332
+ * @param {string} manifestName
333
+ * @param {string} text
334
+ * @returns {Array<{kind:string, detail:string, tier:string}>}
335
+ */
336
+ function scanManifestForXplatform(manifestName, text) {
337
+ const found = [];
338
+ const lower = text.toLowerCase();
339
+ for (const fw of XPL_FRAMEWORK_SUBSTRINGS) {
340
+ if (lower.includes(fw)) {
341
+ found.push({
342
+ kind: 'xplatform-framework',
343
+ detail: `${fw} in ${manifestName}`,
344
+ tier: 'strong',
345
+ });
346
+ }
347
+ }
348
+ // Qt via a generic `qt` substring is too noisy for a manifest scan; PyQt /
349
+ // PySide above are the unambiguous Python Qt markers. (Qt C++ projects are
350
+ // caught by the compile-guard / *.ui paths instead.)
351
+ return found;
352
+ }
353
+ // Compile-time platform guards (STRONG) — source-token regexes spanning
354
+ // Rust / Go / C/C++ / CMake / Swift.
355
+ const XPL_COMPILE_GUARD_RES = [
356
+ { re: /#\[cfg\(target_os/, label: '#[cfg(target_os' },
357
+ { re: /\bcfg!\s*\(\s*windows\b/, label: 'cfg!(windows)' },
358
+ { re: /\/\/go:build\s+(windows|darwin|linux)/, label: '//go:build <os>' },
359
+ { re: /\bruntime\.GOOS\b/, label: 'runtime.GOOS' },
360
+ { re: /#ifdef\s+_WIN32/, label: '#ifdef _WIN32' },
361
+ { re: /#ifdef\s+__APPLE__/, label: '#ifdef __APPLE__' },
362
+ { re: /#ifdef\s+__linux__/, label: '#ifdef __linux__' },
363
+ { re: /\bif\s*\(\s*WIN32\s*\)/, label: 'if(WIN32)' },
364
+ { re: /\bif\s*\(\s*APPLE\s*\)/, label: 'if(APPLE)' },
365
+ { re: /\bif\s*\(\s*UNIX\s*\)/, label: 'if(UNIX)' },
366
+ { re: /#if\s+os\s*\(/, label: '#if os(' },
367
+ ];
368
+ // Lone runtime platform checks (WEAK).
369
+ const XPL_WEAK_TOKEN_RES = [
370
+ { re: /\bprocess\.platform\b/, label: 'process.platform' },
371
+ { re: /\bos\.platform\s*\(/, label: 'os.platform()' },
372
+ { re: /\bsys\.platform\b/, label: 'sys.platform' },
373
+ { re: /\bplatform\.system\s*\(/, label: 'platform.system()' },
374
+ { re: /\bRuntimeInformation\.IsOSPlatform\b/, label: 'RuntimeInformation.IsOSPlatform' },
375
+ { re: /System\.getProperty\s*\(\s*["']os\.name["']/, label: 'System.getProperty("os.name")' },
376
+ { re: /\bPlatform\.OS\b/, label: 'Platform.OS' },
377
+ { re: /\bPlatform\.isAndroid\b/, label: 'Platform.isAndroid' },
378
+ ];
379
+ /**
380
+ * Scan one source blob for xplatform compile-guard (STRONG) + lone runtime
381
+ * (WEAK) tokens.
382
+ *
383
+ * @param {string} detailLabel
384
+ * @param {string} text
385
+ * @returns {Array<{kind:string, detail:string, tier:string}>}
386
+ */
387
+ function scanSourceForXplatformTokens(detailLabel, text) {
388
+ const found = [];
389
+ for (const { re, label } of XPL_COMPILE_GUARD_RES) {
390
+ if (re.test(text)) {
391
+ found.push({
392
+ kind: 'xplatform-guard',
393
+ detail: `${label} in ${detailLabel}`,
394
+ tier: 'strong',
395
+ });
396
+ }
397
+ }
398
+ for (const { re, label } of XPL_WEAK_TOKEN_RES) {
399
+ if (re.test(text)) {
400
+ found.push({
401
+ kind: 'xplatform-runtime',
402
+ detail: `${label} in ${detailLabel}`,
403
+ tier: 'weak',
404
+ });
405
+ }
406
+ }
407
+ return found;
408
+ }
409
+ const OS_TOKEN_GROUPS = [
410
+ ['ubuntu', 'linux'],
411
+ ['macos', 'darwin', 'macos-latest'],
412
+ ['windows', 'windows-latest'],
413
+ ];
414
+ // Per-family recognized RUNNER value regexes for a YAML list-item branch — these
415
+ // must match the WHOLE list-item value (anchored), and stay INDEX-ALIGNED with
416
+ // OS_TOKEN_GROUPS (linux, macos, windows). A list item like `- windows-build-tools`
417
+ // or `- macos-codesign-helper` (e.g. under a `packages:` key) is NOT a recognized
418
+ // runner and must NOT inflate the multi-OS family count, while real block-list
419
+ // runners `- ubuntu-latest` / `- macos-latest` / `- windows-latest` / `- ubuntu-22.04`
420
+ // still match.
421
+ const OS_RUNNER_RES = [
422
+ /^(ubuntu(-(latest|\d[\w.-]*))?|linux)$/,
423
+ /^(macos(-(latest|\d[\w.-]*))?|darwin)$/,
424
+ /^(windows(-(latest|\d[\w.-]*))?)$/,
425
+ ];
426
+ /**
427
+ * Detect a multi-OS CI matrix in a workflow's raw text. No YAML parse (R2) —
428
+ * substring scan of `runs-on:`/`os:` lines for >1 distinct OS family.
429
+ *
430
+ * @param {string} workflowName
431
+ * @param {string} text
432
+ * @returns {{kind:string, detail:string, tier:string} | null}
433
+ */
434
+ function detectCiMatrix(workflowName, text) {
435
+ const lines = text.split('\n');
436
+ const osFamilies = new Set();
437
+ for (const line of lines) {
438
+ // R2: only OS-bearing config lines count — a `runs-on:` / `os:` key line,
439
+ // or a YAML list-item line (e.g. `- ubuntu-latest`) that sits under an
440
+ // `os:` matrix key without repeating the key. Scanning EVERY line would
441
+ // let an unrelated dep/comment/path mentioning an OS substring (e.g.
442
+ // `npm install windows-build-tools` under a single `runs-on: ubuntu`)
443
+ // inflate the family count into a phantom multi-OS matrix — exactly the
444
+ // false-positive the STRONG/WEAK tier model exists to prevent.
445
+ const isKeyLine = /\b(runs-on|os)\s*:/i.test(line);
446
+ const listItemMatch = line.match(/^\s*-\s+['"]?([\w.-]+)['"]?\s*$/);
447
+ if (!isKeyLine && !listItemMatch)
448
+ continue;
449
+ if (isKeyLine) {
450
+ // Key-line (`runs-on:` / `os:`) substring branch — UNCHANGED.
451
+ const lower = line.toLowerCase();
452
+ for (let gi = 0; gi < OS_TOKEN_GROUPS.length; gi++) {
453
+ if (OS_TOKEN_GROUPS[gi].some((tok) => lower.includes(tok)))
454
+ osFamilies.add(gi);
455
+ }
456
+ }
457
+ else if (listItemMatch) {
458
+ // List-item branch — count an OS family ONLY when the WHOLE list-item
459
+ // value is a recognized runner token (anchored OS_RUNNER_RES). This
460
+ // stops a non-`os:` list like `- windows-build-tools` /
461
+ // `- macos-codesign-helper` (e.g. under a `packages:` key) from
462
+ // inflating the multi-OS family count.
463
+ const value = listItemMatch[1].toLowerCase();
464
+ for (let gi = 0; gi < OS_RUNNER_RES.length; gi++) {
465
+ if (OS_RUNNER_RES[gi].test(value))
466
+ osFamilies.add(gi);
467
+ }
468
+ }
469
+ }
470
+ if (osFamilies.size > 1) {
471
+ return {
472
+ kind: 'xplatform-ci-matrix',
473
+ detail: `multi-OS CI matrix in ${workflowName} (${osFamilies.size} OS families)`,
474
+ tier: 'strong',
475
+ };
476
+ }
477
+ return null;
478
+ }
479
+ /**
480
+ * Detect platform-conditional directory siblings (STRONG) over a list of
481
+ * repo-relative paths: windows/ + macos/ + linux/ co-present, or
482
+ * *.xcodeproj + build.gradle co-presence.
483
+ *
484
+ * @param {string[]} relPaths POSIX-normalized
485
+ * @returns {Array<{kind:string, detail:string, tier:string}>}
486
+ */
487
+ function detectPlatformDirSiblings(relPaths) {
488
+ const found = [];
489
+ const hasWindowsDir = relPaths.some((p) => p.split('/').includes('windows'));
490
+ const hasMacosDir = relPaths.some((p) => p.split('/').includes('macos'));
491
+ const hasLinuxDir = relPaths.some((p) => p.split('/').includes('linux'));
492
+ if (hasWindowsDir && hasMacosDir && hasLinuxDir) {
493
+ found.push({
494
+ kind: 'xplatform-dir-siblings',
495
+ detail: 'platform dir siblings windows/ + macos/ + linux/ co-present',
496
+ tier: 'strong',
497
+ });
498
+ }
499
+ const hasXcodeproj = relPaths.some((p) => p.endsWith('.xcodeproj') || p.includes('.xcodeproj/'));
500
+ const hasGradle = relPaths.some((p) => {
501
+ const base = p.split('/').pop();
502
+ return base === 'build.gradle' || base === 'build.gradle.kts';
503
+ });
504
+ if (hasXcodeproj && hasGradle) {
505
+ found.push({
506
+ kind: 'xplatform-dir-siblings',
507
+ detail: '*.xcodeproj + build.gradle co-presence',
508
+ tier: 'strong',
509
+ });
510
+ }
511
+ return found;
512
+ }
513
+ /**
514
+ * Lone weak file signals over a path list: a *.plist or *.rc file.
515
+ *
516
+ * @param {string[]} relPaths
517
+ * @returns {Array<{kind:string, detail:string, tier:string}>}
518
+ */
519
+ function detectWeakXplatformFiles(relPaths) {
520
+ const found = [];
521
+ for (const p of relPaths) {
522
+ const ext = extname(p.toLowerCase());
523
+ if (ext === '.plist')
524
+ found.push({ kind: 'xplatform-file', detail: `plist ${p}`, tier: 'weak' });
525
+ else if (ext === '.rc')
526
+ found.push({ kind: 'xplatform-file', detail: `rc ${p}`, tier: 'weak' });
527
+ }
528
+ return found;
529
+ }
530
+ // ── Diff parsing ─────────────────────────────────────────────────────────────
531
+ /**
532
+ * Parse a unified diff into { touchedPaths: string[], addedText: string }.
533
+ * `touchedPaths` are POSIX-normalized repo-relative paths from the `+++ b/...`
534
+ * (and `--- a/...` for deletions) headers; `addedText` concatenates added
535
+ * lines (those starting with `+` but not `+++`).
536
+ *
537
+ * @param {string} patch
538
+ * @returns {{ touchedPaths: string[], addedText: string }}
539
+ */
540
+ export function parseUnifiedDiff(patch) {
541
+ const touched = new Set();
542
+ const added = [];
543
+ for (const line of patch.split('\n')) {
544
+ if (line.startsWith('+++ ')) {
545
+ const p = line.slice(4).trim();
546
+ if (p && p !== '/dev/null')
547
+ touched.add(stripDiffPrefix(p));
548
+ }
549
+ else if (line.startsWith('--- ')) {
550
+ const p = line.slice(4).trim();
551
+ if (p && p !== '/dev/null')
552
+ touched.add(stripDiffPrefix(p));
553
+ }
554
+ else if (line.startsWith('diff --git ')) {
555
+ // `diff --git a/x b/x` — capture both sides defensively.
556
+ const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
557
+ if (m) {
558
+ touched.add(m[1]);
559
+ touched.add(m[2]);
560
+ }
561
+ }
562
+ else if (line.startsWith('+') && !line.startsWith('+++')) {
563
+ added.push(line.slice(1));
564
+ }
565
+ }
566
+ return { touchedPaths: [...touched], addedText: added.join('\n') };
567
+ }
568
+ function stripDiffPrefix(p) {
569
+ // Strip a/ b/ prefixes git emits, and any timestamp suffix after a tab.
570
+ let out = p.split('\t')[0];
571
+ if (out.startsWith('a/') || out.startsWith('b/'))
572
+ out = out.slice(2);
573
+ return out.split(sep).join('/');
574
+ }
575
+ // ── Engagement decision ──────────────────────────────────────────────────────
576
+ /**
577
+ * Decide engage from a signal list + reason string per ID-03a, after scoping.
578
+ *
579
+ * @param {Signal[]} signals
580
+ * @returns {SpecialistDecision}
581
+ */
582
+ function decideFromSignals(signals) {
583
+ const strong = signals.filter((s) => s.tier === 'strong');
584
+ const weak = signals.filter((s) => s.tier === 'weak');
585
+ if (strong.length >= 1) {
586
+ return {
587
+ engage: true,
588
+ signals,
589
+ reason: `strong: ${strong.map((s) => s.detail).join('; ')}`,
590
+ };
591
+ }
592
+ if (weak.length >= 2) {
593
+ return {
594
+ engage: true,
595
+ signals,
596
+ reason: `weak x${weak.length}: ${weak.map((s) => s.detail).join(' + ')}`,
597
+ };
598
+ }
599
+ if (weak.length === 1) {
600
+ return {
601
+ engage: false,
602
+ signals,
603
+ reason: `weak x1 uncorroborated: ${weak[0].detail} — not engaged`,
604
+ };
605
+ }
606
+ return { engage: false, signals, reason: 'no signals' };
607
+ }
608
+ // Common monorepo container dirs: a path under one of these is owned by its
609
+ // SECOND segment (the package name), not the container itself — so
610
+ // `packages/a/...` and `packages/b/...` are in DIFFERENT owning packages.
611
+ const MONOREPO_CONTAINERS = new Set(['packages', 'apps', 'crates', 'libs', 'modules', 'services']);
612
+ /**
613
+ * The owning directory of a repo-relative POSIX path. Used by the `--scope
614
+ * diff` rule to decide whether a baseline signal "shares an owning directory"
615
+ * with a diff-touched path. For a path under a monorepo container (e.g.
616
+ * `packages/a/...`) the owning dir is `packages/a` (the package), so two
617
+ * sibling packages don't collapse to the shared container. Otherwise it is the
618
+ * first path segment.
619
+ *
620
+ * @param {string} relPath
621
+ * @returns {string} owning dir, or '' for a top-level file
622
+ */
623
+ function owningDir(relPath) {
624
+ const segments = relPath.split('/');
625
+ if (segments.length <= 1)
626
+ return '';
627
+ if (MONOREPO_CONTAINERS.has(segments[0]) && segments.length >= 3) {
628
+ return `${segments[0]}/${segments[1]}`;
629
+ }
630
+ return segments[0];
631
+ }
632
+ /**
633
+ * Extract a repo-relative path out of a signal `detail` string when one is
634
+ * present (locale files, tokens-in-file, weak files all embed a path). Returns
635
+ * null for path-less signals (manifest deps, CI matrices, dir-siblings) — those
636
+ * are treated as repo-wide and are NOT subject to diff-scoping path matching.
637
+ *
638
+ * @param {{detail:string}} signal
639
+ * @returns {string | null}
640
+ */
641
+ function signalPath(signal) {
642
+ const d = signal.detail;
643
+ // All file/token details carry the path as the last whitespace-delimited
644
+ // token that contains a '/' or a recognized file extension, but we encode
645
+ // them as "<label> <path>" / "<label> in <path> (...)". Pull the path out.
646
+ const inMatch = d.match(/ in ([^\s(]+)/);
647
+ if (inMatch && inMatch[1].includes('/'))
648
+ return inMatch[1];
649
+ const tailMatch = d.match(/([^\s]+\/[^\s]+)$/);
650
+ if (tailMatch)
651
+ return tailMatch[1];
652
+ // "<label> <path>" form (e.g. "locale-file src/locales/fr.json").
653
+ const parts = d.split(' ');
654
+ const last = parts[parts.length - 1];
655
+ if (last?.includes('/'))
656
+ return last;
657
+ return null;
658
+ }
659
+ /**
660
+ * Apply --scope. Returns the signal subset that counts for engagement.
661
+ *
662
+ * @param {Array} baselineSignals
663
+ * @param {Array} diffSignals
664
+ * @param {string[]} diffTouchedPaths
665
+ * @param {'repo'|'diff'} scope
666
+ * @returns {{ counted: Array, scopingNote: string | null }}
667
+ */
668
+ function applyScope(baselineSignals, diffSignals, diffTouchedPaths, scope) {
669
+ if (scope === 'repo') {
670
+ return { counted: [...baselineSignals, ...diffSignals], scopingNote: null };
671
+ }
672
+ // scope === 'diff': a baseline signal only counts when it shares an owning
673
+ // dir with a diff-touched path (or is path-less → repo-wide manifest/CI
674
+ // marker, which we conservatively still count). Otherwise drop it.
675
+ const touchedOwningDirs = new Set(diffTouchedPaths.map(owningDir));
676
+ const counted = [...diffSignals];
677
+ const dropped = [];
678
+ for (const sig of baselineSignals) {
679
+ const p = signalPath(sig);
680
+ if (p === null) {
681
+ // Path-less repo-wide marker (manifest dep / CI matrix / dir-sibling)
682
+ // — keep counting it; --scope diff only constrains path-bearing
683
+ // baseline signals.
684
+ counted.push(sig);
685
+ continue;
686
+ }
687
+ if (touchedOwningDirs.has(owningDir(p))) {
688
+ counted.push(sig);
689
+ }
690
+ else {
691
+ dropped.push(sig);
692
+ }
693
+ }
694
+ let scopingNote = null;
695
+ if (dropped.length > 0 && counted.length === diffSignals.length) {
696
+ // All baseline path-bearing signals were dropped as unrelated.
697
+ const first = dropped[0];
698
+ const fp = signalPath(first);
699
+ const touched = diffTouchedPaths.slice(0, 3).join(', ') || '(none)';
700
+ scopingNote = `baseline ${first.kind} signal in unrelated package ${fp}; diff touches only ${touched} (scope=diff)`;
701
+ }
702
+ return { counted, scopingNote };
703
+ }
704
+ /**
705
+ * Read `.aperant/config.json:pr_review.specialists` (best-effort).
706
+ *
707
+ * @param {string} root
708
+ * @returns {{ i18n?: string, xplatform?: string }}
709
+ */
710
+ function readSpecialistConfig(root) {
711
+ try {
712
+ const path = join(resolve(root), '.aperant', 'config.json');
713
+ if (!existsSync(path))
714
+ return {};
715
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
716
+ const spec = parsed?.pr_review?.specialists;
717
+ return spec && typeof spec === 'object' ? spec : {};
718
+ }
719
+ catch {
720
+ return {};
721
+ }
722
+ }
723
+ /**
724
+ * Normalize a config value to auto|always|never (default auto for absent /
725
+ * invalid).
726
+ *
727
+ * @param {unknown} v
728
+ * @returns {'auto'|'always'|'never'}
729
+ */
730
+ function normalizeMode(v) {
731
+ return v === 'always' || v === 'never' ? v : 'auto';
732
+ }
733
+ /**
734
+ * The core detector. Pure over its inputs (reads the filesystem at `root` and
735
+ * the supplied `diffText`); returns the full
736
+ * `{ i18n, xplatform }` decision object.
737
+ *
738
+ * @param {object} opts
739
+ * @param {string} opts.root absolute project dir
740
+ * @param {string} [opts.diffText] unified-diff text (optional)
741
+ * @param {'repo'|'diff'} [opts.scope] default 'repo'
742
+ * @param {{i18n?:string, xplatform?:string}} [opts.config] override config
743
+ * (read from .aperant/config.json when omitted)
744
+ * @returns {{ i18n: SpecialistDecision, xplatform: SpecialistDecision }}
745
+ */
746
+ export function detectSpecialists(opts) {
747
+ const root = resolve(opts.root);
748
+ const scope = opts.scope === 'diff' ? 'diff' : 'repo';
749
+ const config = opts.config ?? readSpecialistConfig(root);
750
+ const diffText = opts.diffText ?? '';
751
+ const i18nMode = normalizeMode(config.i18n);
752
+ const xplatformMode = normalizeMode(config.xplatform);
753
+ // Parse diff once (shared by both specialists).
754
+ const diff = diffText ? parseUnifiedDiff(diffText) : { touchedPaths: [], addedText: '' };
755
+ // ── i18n ──────────────────────────────────────────────────────────────
756
+ const i18n = decideSpecialist({
757
+ mode: i18nMode,
758
+ root,
759
+ diff,
760
+ scope,
761
+ collectBaseline: collectI18nBaseline,
762
+ collectDiff: collectI18nDiff,
763
+ });
764
+ // ── xplatform ───────────────────────────────────────────────────────────
765
+ const xplatform = decideSpecialist({
766
+ mode: xplatformMode,
767
+ root,
768
+ diff,
769
+ scope,
770
+ collectBaseline: collectXplatformBaseline,
771
+ collectDiff: collectXplatformDiff,
772
+ });
773
+ return { i18n, xplatform };
774
+ }
775
+ /**
776
+ * Run the full ordered decision for one specialist: config short-circuit (1) →
777
+ * collect baseline ∪ diff (2) → scope (3) → tier decision (4).
778
+ *
779
+ * @returns {SpecialistDecision}
780
+ */
781
+ function decideSpecialist({ mode, root, diff, scope, collectBaseline, collectDiff }) {
782
+ // (1) Config check FIRST and absolute (ID-01a).
783
+ if (mode === 'always') {
784
+ return { engage: true, signals: [], reason: 'config override: always' };
785
+ }
786
+ if (mode === 'never') {
787
+ return { engage: false, signals: [], reason: 'config override: never' };
788
+ }
789
+ // (2) Collect baseline ∪ diff signals.
790
+ const baselineSignals = collectBaseline(root);
791
+ const diffSignals = collectDiff(diff);
792
+ // (3) Apply scope.
793
+ const { counted, scopingNote } = applyScope(baselineSignals, diffSignals, diff.touchedPaths, scope);
794
+ // (4) Tier decision.
795
+ const decision = decideFromSignals(counted);
796
+ if (!decision.engage && scopingNote && counted.length === 0) {
797
+ decision.reason = scopingNote;
798
+ }
799
+ else if (!decision.engage && scopingNote && decision.reason === 'no signals') {
800
+ decision.reason = scopingNote;
801
+ }
802
+ return decision;
803
+ }
804
+ // ── Per-specialist collectors ────────────────────────────────────────────────
805
+ function collectI18nBaseline(root) {
806
+ const signals = [];
807
+ const files = walkFiles(root);
808
+ for (const rel of files) {
809
+ const detail = i18nLocaleFileDetail(rel);
810
+ if (detail)
811
+ signals.push({ kind: 'i18n-locale-file', detail, tier: 'strong' });
812
+ }
813
+ for (const manifest of MANIFEST_FILES) {
814
+ const abs = join(root, manifest);
815
+ if (!existsSync(abs))
816
+ continue;
817
+ const text = readSafe(abs);
818
+ if (text)
819
+ signals.push(...scanManifestForI18n(manifest, text));
820
+ }
821
+ return dedupeSignals(signals);
822
+ }
823
+ function collectI18nDiff(diff) {
824
+ const signals = [];
825
+ for (const rel of diff.touchedPaths) {
826
+ const detail = i18nLocaleFileDetail(rel);
827
+ if (detail)
828
+ signals.push({ kind: 'i18n-locale-file', detail: `${detail} (diff)`, tier: 'strong' });
829
+ }
830
+ if (diff.addedText) {
831
+ // A newly-added i18n library import counts as a manifest-equivalent
832
+ // strong signal when the added text mentions an unambiguous lib.
833
+ const lowerAdded = diff.addedText.toLowerCase();
834
+ for (const lib of I18N_LIB_SUBSTRINGS) {
835
+ if (lowerAdded.includes(lib.toLowerCase())) {
836
+ signals.push({ kind: 'i18n-lib', detail: `${lib} (diff added line)`, tier: 'strong' });
837
+ }
838
+ }
839
+ signals.push(...scanSourceForI18nTokens('diff', diff.addedText));
840
+ }
841
+ return dedupeSignals(signals);
842
+ }
843
+ function collectXplatformBaseline(root) {
844
+ const signals = [];
845
+ const files = walkFiles(root);
846
+ // Manifest frameworks.
847
+ for (const manifest of MANIFEST_FILES) {
848
+ const abs = join(root, manifest);
849
+ if (!existsSync(abs))
850
+ continue;
851
+ const text = readSafe(abs);
852
+ if (text)
853
+ signals.push(...scanManifestForXplatform(manifest, text));
854
+ }
855
+ // pubspec.yaml present → Flutter (its presence alone is the flutter marker).
856
+ if (existsSync(join(root, 'pubspec.yaml'))) {
857
+ signals.push({
858
+ kind: 'xplatform-framework',
859
+ detail: 'flutter (pubspec.yaml present)',
860
+ tier: 'strong',
861
+ });
862
+ }
863
+ // CI matrices — .github/workflows/*.yml + standalone CI files.
864
+ const workflowsDir = join(root, '.github', 'workflows');
865
+ if (existsSync(workflowsDir)) {
866
+ try {
867
+ for (const entry of readdirSync(workflowsDir)) {
868
+ if (!/\.ya?ml$/i.test(entry))
869
+ continue;
870
+ const text = readSafe(join(workflowsDir, entry));
871
+ const m = detectCiMatrix(`.github/workflows/${entry}`, text);
872
+ if (m)
873
+ signals.push(m);
874
+ }
875
+ }
876
+ catch {
877
+ /* unreadable */
878
+ }
879
+ }
880
+ for (const ci of CI_FILES) {
881
+ const abs = join(root, ci);
882
+ if (!existsSync(abs))
883
+ continue;
884
+ const m = detectCiMatrix(ci, readSafe(abs));
885
+ if (m)
886
+ signals.push(m);
887
+ }
888
+ // Compile-time guards in source files (scan a bounded set of source files).
889
+ for (const rel of files) {
890
+ const ext = extname(rel.toLowerCase());
891
+ if (['.rs', '.go', '.c', '.cc', '.cpp', '.h', '.hpp', '.swift', '.m', '.mm'].includes(ext) ||
892
+ rel.split('/').pop()?.toLowerCase() === 'cmakelists.txt') {
893
+ const text = readSafe(join(root, rel));
894
+ if (text) {
895
+ for (const { re, label } of XPL_COMPILE_GUARD_RES) {
896
+ if (re.test(text)) {
897
+ signals.push({ kind: 'xplatform-guard', detail: `${label} in ${rel}`, tier: 'strong' });
898
+ }
899
+ }
900
+ }
901
+ }
902
+ }
903
+ // Platform dir siblings + weak files.
904
+ signals.push(...detectPlatformDirSiblings(files));
905
+ signals.push(...detectWeakXplatformFiles(files));
906
+ return dedupeSignals(signals);
907
+ }
908
+ function collectXplatformDiff(diff) {
909
+ const signals = [];
910
+ if (diff.addedText) {
911
+ const lowerAdded = diff.addedText.toLowerCase();
912
+ for (const fw of XPL_FRAMEWORK_SUBSTRINGS) {
913
+ if (lowerAdded.includes(fw)) {
914
+ signals.push({
915
+ kind: 'xplatform-framework',
916
+ detail: `${fw} (diff added line)`,
917
+ tier: 'strong',
918
+ });
919
+ }
920
+ }
921
+ signals.push(...scanSourceForXplatformTokens('diff', diff.addedText));
922
+ }
923
+ // CI matrix introduced in the diff.
924
+ for (const rel of diff.touchedPaths) {
925
+ if (/\.ya?ml$/i.test(rel) && (rel.includes('.github/workflows/') || CI_FILES.includes(rel))) {
926
+ const m = detectCiMatrix(`${rel} (diff)`, diff.addedText);
927
+ if (m)
928
+ signals.push(m);
929
+ }
930
+ }
931
+ signals.push(...detectWeakXplatformFiles(diff.touchedPaths));
932
+ return dedupeSignals(signals);
933
+ }
934
+ /** De-dupe signals by `detail` (a path/token can match more than one rule). */
935
+ function dedupeSignals(signals) {
936
+ const seen = new Set();
937
+ const out = [];
938
+ for (const s of signals) {
939
+ if (seen.has(s.detail))
940
+ continue;
941
+ seen.add(s.detail);
942
+ out.push(s);
943
+ }
944
+ return out;
945
+ }
946
+ /**
947
+ * CLI subcommand handler: `apt-tools pr-review detect-specialists <dir>
948
+ * [--diff <patch>] [--scope diff|repo]`.
949
+ *
950
+ * @param {string} projectDir
951
+ * @param {string[]} extraArgs
952
+ * @returns {import('../../util/result.mjs').CommandResult}
953
+ */
954
+ export function cmdDetectSpecialists(projectDir, extraArgs = []) {
955
+ if (!projectDir) {
956
+ return err('Usage: apt-tools pr-review detect-specialists <project-dir> [--diff <patch>] [--scope diff|repo]');
957
+ }
958
+ const root = resolve(projectDir);
959
+ if (!existsSync(root))
960
+ return err(`Directory does not exist: ${root}`);
961
+ const flags = parseFlags(extraArgs);
962
+ const diffFlag = flags.get('diff');
963
+ let diffText = '';
964
+ if (typeof diffFlag === 'string') {
965
+ if (!existsSync(diffFlag))
966
+ return err(`Diff file not found: ${diffFlag}`);
967
+ diffText = readSafe(resolve(diffFlag));
968
+ }
969
+ const scopeFlag = flags.get('scope');
970
+ const scope = scopeFlag === 'diff' ? 'diff' : 'repo';
971
+ const result = detectSpecialists({ root, diffText, scope });
972
+ return ok({
973
+ status: 'ok',
974
+ command: 'pr-review-detect-specialists',
975
+ scope,
976
+ i18n: result.i18n,
977
+ xplatform: result.xplatform,
978
+ });
979
+ }
980
+ //# sourceMappingURL=detect-specialists.mjs.map