@aperant/framework 0.13.0-next.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.
- package/CHANGELOG.md +89 -3
- package/README.md +11 -1
- package/agents/apt-pr-review-i18n.md +190 -0
- package/agents/apt-pr-review-xplatform.md +189 -0
- package/dist/cli/commands/audit-branch-current.d.mts +27 -5
- package/dist/cli/commands/audit-branch-current.d.mts.map +1 -1
- package/dist/cli/commands/audit-branch-current.mjs +3 -3
- package/dist/cli/commands/audit-branch-current.mjs.map +1 -1
- package/dist/cli/commands/event.d.mts.map +1 -1
- package/dist/cli/commands/event.mjs +6 -4
- package/dist/cli/commands/event.mjs.map +1 -1
- package/dist/cli/commands/fan-out.d.mts.map +1 -1
- package/dist/cli/commands/fan-out.mjs +3 -1
- package/dist/cli/commands/fan-out.mjs.map +1 -1
- package/dist/cli/commands/features-audit.d.mts.map +1 -1
- package/dist/cli/commands/features-audit.mjs +6 -1
- package/dist/cli/commands/features-audit.mjs.map +1 -1
- package/dist/cli/commands/followups.d.mts +5 -0
- package/dist/cli/commands/followups.d.mts.map +1 -0
- package/dist/cli/commands/followups.mjs +266 -0
- package/dist/cli/commands/followups.mjs.map +1 -0
- package/dist/cli/commands/health-check.d.mts.map +1 -1
- package/dist/cli/commands/health-check.mjs +3 -1
- package/dist/cli/commands/health-check.mjs.map +1 -1
- package/dist/cli/commands/merge-detect.d.mts +29 -0
- package/dist/cli/commands/merge-detect.d.mts.map +1 -0
- package/dist/cli/commands/merge-detect.mjs +196 -0
- package/dist/cli/commands/merge-detect.mjs.map +1 -0
- package/dist/cli/commands/merge-integrate.d.mts +28 -0
- package/dist/cli/commands/merge-integrate.d.mts.map +1 -0
- package/dist/cli/commands/merge-integrate.mjs +383 -0
- package/dist/cli/commands/merge-integrate.mjs.map +1 -0
- package/dist/cli/commands/pr-review/detect-specialists.d.mts +58 -0
- package/dist/cli/commands/pr-review/detect-specialists.d.mts.map +1 -0
- package/dist/cli/commands/pr-review/detect-specialists.mjs +980 -0
- package/dist/cli/commands/pr-review/detect-specialists.mjs.map +1 -0
- package/dist/cli/commands/pr-review.d.mts.map +1 -1
- package/dist/cli/commands/pr-review.mjs +11 -2
- package/dist/cli/commands/pr-review.mjs.map +1 -1
- package/dist/cli/commands/precompact-hook.d.mts.map +1 -1
- package/dist/cli/commands/precompact-hook.mjs +10 -6
- package/dist/cli/commands/precompact-hook.mjs.map +1 -1
- package/dist/cli/commands/roadmap.d.mts.map +1 -1
- package/dist/cli/commands/roadmap.mjs +24 -11
- package/dist/cli/commands/roadmap.mjs.map +1 -1
- package/dist/cli/commands/seed.d.mts.map +1 -1
- package/dist/cli/commands/seed.mjs +3 -1
- package/dist/cli/commands/seed.mjs.map +1 -1
- package/dist/cli/commands/state.d.mts.map +1 -1
- package/dist/cli/commands/state.mjs +9 -3
- package/dist/cli/commands/state.mjs.map +1 -1
- package/dist/cli/commands/statusline.d.mts.map +1 -1
- package/dist/cli/commands/statusline.mjs +5 -1
- package/dist/cli/commands/statusline.mjs.map +1 -1
- package/dist/cli/commands/task.d.mts.map +1 -1
- package/dist/cli/commands/task.mjs +126 -23
- package/dist/cli/commands/task.mjs.map +1 -1
- package/dist/cli/commands/worktree.d.mts +1 -1
- package/dist/cli/commands/worktree.d.mts.map +1 -1
- package/dist/cli/commands/worktree.mjs +282 -83
- package/dist/cli/commands/worktree.mjs.map +1 -1
- package/dist/cli/config/post-merge-sweep.d.mts.map +1 -1
- package/dist/cli/config/post-merge-sweep.mjs +3 -2
- package/dist/cli/config/post-merge-sweep.mjs.map +1 -1
- package/dist/cli/config/share-policy.d.mts +3 -1
- package/dist/cli/config/share-policy.d.mts.map +1 -1
- package/dist/cli/config/share-policy.mjs +16 -3
- package/dist/cli/config/share-policy.mjs.map +1 -1
- package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
- package/dist/cli/config/upgrade-gitignore.mjs +27 -5
- package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
- package/dist/cli/consistency/parse-qa.mjs +12 -1
- package/dist/cli/consistency/parse-qa.mjs.map +1 -1
- package/dist/cli/consistency/parse-review.d.mts +23 -0
- package/dist/cli/consistency/parse-review.d.mts.map +1 -1
- package/dist/cli/consistency/parse-review.mjs +36 -0
- package/dist/cli/consistency/parse-review.mjs.map +1 -1
- package/dist/cli/consistency/parse-spec.d.mts +16 -0
- package/dist/cli/consistency/parse-spec.d.mts.map +1 -1
- package/dist/cli/consistency/parse-spec.mjs +44 -0
- package/dist/cli/consistency/parse-spec.mjs.map +1 -1
- package/dist/cli/coordination/agent-identity.d.mts +5 -2
- package/dist/cli/coordination/agent-identity.d.mts.map +1 -1
- package/dist/cli/coordination/agent-identity.mjs +6 -4
- package/dist/cli/coordination/agent-identity.mjs.map +1 -1
- package/dist/cli/coordination/store.d.mts +1 -1
- package/dist/cli/dispatch.d.mts.map +1 -1
- package/dist/cli/dispatch.mjs +4 -0
- package/dist/cli/dispatch.mjs.map +1 -1
- package/dist/cli/followups/derive.d.mts +63 -0
- package/dist/cli/followups/derive.d.mts.map +1 -0
- package/dist/cli/followups/derive.mjs +115 -0
- package/dist/cli/followups/derive.mjs.map +1 -0
- package/dist/cli/followups/schema.d.mts +165 -0
- package/dist/cli/followups/schema.d.mts.map +1 -0
- package/dist/cli/followups/schema.mjs +251 -0
- package/dist/cli/followups/schema.mjs.map +1 -0
- package/dist/cli/gate/context.d.mts +1 -1
- package/dist/cli/gate/gates/followups-adjudicated.d.mts +69 -0
- package/dist/cli/gate/gates/followups-adjudicated.d.mts.map +1 -0
- package/dist/cli/gate/gates/followups-adjudicated.mjs +141 -0
- package/dist/cli/gate/gates/followups-adjudicated.mjs.map +1 -0
- package/dist/cli/gate/gates/index.mjs +1 -0
- package/dist/cli/gate/gates/index.mjs.map +1 -1
- package/dist/cli/git/materialize-env.d.mts +39 -0
- package/dist/cli/git/materialize-env.d.mts.map +1 -0
- package/dist/cli/git/materialize-env.mjs +217 -0
- package/dist/cli/git/materialize-env.mjs.map +1 -0
- package/dist/cli/help.d.mts.map +1 -1
- package/dist/cli/help.mjs +9 -1
- package/dist/cli/help.mjs.map +1 -1
- package/dist/cli/install/legacy-paths.d.mts.map +1 -1
- package/dist/cli/install/legacy-paths.mjs +4 -0
- package/dist/cli/install/legacy-paths.mjs.map +1 -1
- package/dist/cli/roadmap/lifecycle.d.mts.map +1 -1
- package/dist/cli/roadmap/lifecycle.mjs +4 -1
- package/dist/cli/roadmap/lifecycle.mjs.map +1 -1
- package/dist/cli/task/ids.d.mts +9 -0
- package/dist/cli/task/ids.d.mts.map +1 -1
- package/dist/cli/task/ids.mjs +9 -0
- package/dist/cli/task/ids.mjs.map +1 -1
- package/dist/cli/task/index-md.d.mts +5 -0
- package/dist/cli/task/index-md.d.mts.map +1 -1
- package/dist/cli/task/index-md.mjs +12 -4
- package/dist/cli/task/index-md.mjs.map +1 -1
- package/dist/cli/util/io.d.mts +6 -0
- package/dist/cli/util/io.d.mts.map +1 -1
- package/dist/cli/util/io.mjs +16 -1
- package/dist/cli/util/io.mjs.map +1 -1
- package/dist/cli/util/project.d.mts +7 -2
- package/dist/cli/util/project.d.mts.map +1 -1
- package/dist/cli/util/project.mjs +11 -14
- package/dist/cli/util/project.mjs.map +1 -1
- package/dist/cli/util/state-paths.d.mts +19 -0
- package/dist/cli/util/state-paths.d.mts.map +1 -0
- package/dist/cli/util/state-paths.mjs +45 -0
- package/dist/cli/util/state-paths.mjs.map +1 -0
- package/dist/plugin/.claude-plugin/plugin.json +4 -1
- package/dist/plugin/agents/apt-pr-review-i18n.md +190 -0
- package/dist/plugin/agents/apt-pr-review-xplatform.md +189 -0
- package/dist/plugin/skills/apt/SKILL.md +13 -6
- package/dist/plugin/skills/apt-close-task/SKILL.md +48 -0
- package/dist/plugin/skills/apt-debug/SKILL.md +19 -9
- package/dist/plugin/skills/apt-execute/SKILL.md +3 -1
- package/dist/plugin/skills/apt-fan-out/SKILL.md +65 -1
- package/dist/plugin/skills/apt-merge-conflict-resolution/SKILL.md +227 -0
- package/dist/plugin/skills/apt-pr-review/SCHEMA.md +4 -2
- package/dist/plugin/skills/apt-pr-review/SKILL.md +117 -29
- package/dist/plugin/skills/apt-quick/SKILL.md +24 -1
- package/dist/plugin/skills/apt-review/SKILL.md +6 -0
- package/dist/plugin/skills/apt-run/SKILL.md +1 -1
- package/dist/plugin/skills/apt-setup/SKILL.md +21 -3
- package/dist/plugin/skills/apt-ship/SKILL.md +32 -11
- package/dist/plugin/skills/apt-terminal/SKILL.md +8 -0
- package/dist/plugin/skills/apt-triage/SKILL.md +4 -0
- package/dist/plugin/skills/apt-verify/SKILL.md +24 -0
- package/dist/plugin/skills/apt-watch-ci/SKILL.md +3 -1
- package/dist/types/config.d.ts +33 -0
- package/dist/types/config.d.ts.map +1 -1
- package/package.json +138 -138
- package/prompts/conductor-framework-context.md +1 -1
- package/prompts/github/pr_i18n_agent.md +279 -0
- package/prompts/github/pr_xplatform_agent.md +283 -0
- package/prompts/inbox-clarification.md +18 -11
- package/skills/apt/SKILL.md +9 -3
- package/skills/apt-close-task/SKILL.md +48 -0
- package/skills/apt-debug/SKILL.md +19 -9
- package/skills/apt-execute/SKILL.md +3 -1
- package/skills/apt-fan-out/SKILL.md +65 -1
- package/skills/apt-merge-conflict-resolution/SKILL.md +227 -0
- package/skills/apt-pr-review/SCHEMA.md +4 -2
- package/skills/apt-pr-review/SKILL.md +117 -29
- package/skills/apt-quick/SKILL.md +24 -1
- package/skills/apt-review/SKILL.md +6 -0
- package/skills/apt-run/SKILL.md +1 -1
- package/skills/apt-setup/SKILL.md +21 -3
- package/skills/apt-ship/SKILL.md +32 -11
- package/skills/apt-terminal/SKILL.md +8 -0
- package/skills/apt-triage/SKILL.md +4 -0
- package/skills/apt-verify/SKILL.md +24 -0
- package/skills/apt-watch-ci/SKILL.md +3 -1
- package/src/cli/commands/audit-branch-current.mjs +3 -3
- package/src/cli/commands/event.mjs +6 -4
- package/src/cli/commands/fan-out.mjs +3 -1
- package/src/cli/commands/features-audit.mjs +6 -1
- package/src/cli/commands/followups.mjs +303 -0
- package/src/cli/commands/health-check.mjs +3 -1
- package/src/cli/commands/merge-detect.mjs +203 -0
- package/src/cli/commands/merge-integrate.mjs +392 -0
- package/src/cli/commands/pr-review/__fixtures__/android-strings/app/src/main/res/values-fr/strings.xml +3 -0
- package/src/cli/commands/pr-review/__fixtures__/babel-js/package.json +4 -0
- package/src/cli/commands/pr-review/__fixtures__/c-ifdef/src/main.c +4 -0
- package/src/cli/commands/pr-review/__fixtures__/ci-matrix/.github/workflows/ci.yml +10 -0
- package/src/cli/commands/pr-review/__fixtures__/cmake-win/CMakeLists.txt +5 -0
- package/src/cli/commands/pr-review/__fixtures__/diffs/add-arb.patch +9 -0
- package/src/cli/commands/pr-review/__fixtures__/diffs/ci-single-os-dep.patch +13 -0
- package/src/cli/commands/pr-review/__fixtures__/diffs/ci-single-os-list-item-dep.patch +17 -0
- package/src/cli/commands/pr-review/__fixtures__/diffs/lone-weak-t.patch +8 -0
- package/src/cli/commands/pr-review/__fixtures__/diffs/monorepo-b.patch +7 -0
- package/src/cli/commands/pr-review/__fixtures__/diffs/two-weak-xpl.patch +12 -0
- package/src/cli/commands/pr-review/__fixtures__/dropped-underscore/src/app.js +2 -0
- package/src/cli/commands/pr-review/__fixtures__/electron-app/package.json +4 -0
- package/src/cli/commands/pr-review/__fixtures__/i18n-locale-file/src/locales/fr.json +1 -0
- package/src/cli/commands/pr-review/__fixtures__/monorepo/packages/a/locales/fr.json +1 -0
- package/src/cli/commands/pr-review/__fixtures__/monorepo/packages/b/src/index.ts +1 -0
- package/src/cli/commands/pr-review/__fixtures__/py-babel/pyproject.toml +3 -0
- package/src/cli/commands/pr-review/__fixtures__/qt-i18n-dir/i18n/app_fr.ts +11 -0
- package/src/cli/commands/pr-review/__fixtures__/rust-cli/Cargo.toml +7 -0
- package/src/cli/commands/pr-review/__fixtures__/rust-cli/src/main.rs +1 -0
- package/src/cli/commands/pr-review/__fixtures__/ts-dir/ts/foo.ts +3 -0
- package/src/cli/commands/pr-review/detect-specialists.mjs +1023 -0
- package/src/cli/commands/pr-review.mjs +12 -2
- package/src/cli/commands/precompact-hook.mjs +10 -6
- package/src/cli/commands/roadmap.mjs +24 -11
- package/src/cli/commands/seed.mjs +3 -1
- package/src/cli/commands/state.mjs +9 -3
- package/src/cli/commands/statusline.mjs +5 -1
- package/src/cli/commands/task.mjs +135 -23
- package/src/cli/commands/worktree.mjs +298 -81
- package/src/cli/config/post-merge-sweep.mjs +3 -2
- package/src/cli/config/share-policy.mjs +16 -3
- package/src/cli/config/upgrade-gitignore.mjs +27 -5
- package/src/cli/consistency/parse-qa.mjs +11 -1
- package/src/cli/consistency/parse-review.mjs +36 -0
- package/src/cli/consistency/parse-spec.mjs +43 -0
- package/src/cli/coordination/agent-identity.mjs +6 -4
- package/src/cli/dispatch.mjs +4 -0
- package/src/cli/followups/derive.mjs +123 -0
- package/src/cli/followups/schema.mjs +264 -0
- package/src/cli/gate/gates/followups-adjudicated.mjs +158 -0
- package/src/cli/gate/gates/index.mjs +1 -0
- package/src/cli/git/materialize-env.mjs +212 -0
- package/src/cli/help.mjs +9 -1
- package/src/cli/install/legacy-paths.mjs +4 -0
- package/src/cli/roadmap/lifecycle.mjs +4 -1
- package/src/cli/task/ids.mjs +9 -0
- package/src/cli/task/index-md.mjs +12 -4
- package/src/cli/util/io.mjs +15 -1
- package/src/cli/util/project.mjs +11 -14
- package/src/cli/util/state-paths.mjs +45 -0
- 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
|