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