@aarwitz/tapp 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 (77) hide show
  1. package/AGENTS.md +123 -0
  2. package/Harness/OCQAHarness/AppDelegate.swift +21 -0
  3. package/Harness/OCQAHarness/Info.plist +26 -0
  4. package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
  5. package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
  6. package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
  7. package/Harness/OCQAHarnessUITests/Info.plist +22 -0
  8. package/Harness/generate-harness-xcodeproj.rb +254 -0
  9. package/LICENSE +21 -0
  10. package/README.md +374 -0
  11. package/bin/tapp.js +1382 -0
  12. package/browser/app.css +227 -0
  13. package/browser/app.js +675 -0
  14. package/browser/index.html +195 -0
  15. package/browser/product-contract.js +25 -0
  16. package/browser/view-model.js +16 -0
  17. package/docs/BROWSER-PRODUCT.md +72 -0
  18. package/docs/PRODUCT-ENGINE.md +102 -0
  19. package/docs/application-model.md +276 -0
  20. package/docs/scenarios.md +95 -0
  21. package/mcp-server/src/android-driver.js +287 -0
  22. package/mcp-server/src/android-explorer.js +197 -0
  23. package/mcp-server/src/android-flow.js +89 -0
  24. package/mcp-server/src/application-model.js +1597 -0
  25. package/mcp-server/src/browser-product.js +659 -0
  26. package/mcp-server/src/browser-workspaces.js +234 -0
  27. package/mcp-server/src/ci-report.js +557 -0
  28. package/mcp-server/src/ci-setup.js +359 -0
  29. package/mcp-server/src/contract-authoring.js +10 -0
  30. package/mcp-server/src/enrich.js +57 -0
  31. package/mcp-server/src/flow-runtime.js +127 -0
  32. package/mcp-server/src/html-report.js +124 -0
  33. package/mcp-server/src/index.js +3775 -0
  34. package/mcp-server/src/maintenance-proposal.js +178 -0
  35. package/mcp-server/src/managed-operation.js +61 -0
  36. package/mcp-server/src/pr-selection.js +841 -0
  37. package/mcp-server/src/product-execution.js +155 -0
  38. package/mcp-server/src/product-operations.js +526 -0
  39. package/mcp-server/src/project-config.js +101 -0
  40. package/mcp-server/src/release-contract.d.ts +81 -0
  41. package/mcp-server/src/release-contract.js +226 -0
  42. package/mcp-server/src/report.js +363 -0
  43. package/mcp-server/src/scenario-runtime.js +139 -0
  44. package/mcp-server/src/static-server.js +44 -0
  45. package/mcp-server/src/task-runtime.js +266 -0
  46. package/mcp-server/src/ui-map.js +661 -0
  47. package/mcp-server/src/web-explorer.js +493 -0
  48. package/mcp-server/src/web-flow.js +238 -0
  49. package/package.json +82 -0
  50. package/scripts/android-corpus-e2e.sh +30 -0
  51. package/scripts/ci-gate.sh +323 -0
  52. package/scripts/cleanup-xcode.sh +157 -0
  53. package/scripts/compile-contract.js +27 -0
  54. package/scripts/compile-flow.js +18 -0
  55. package/scripts/corpus-apps.txt +9 -0
  56. package/scripts/corpus-sweep.sh +121 -0
  57. package/scripts/coverage-eval.sh +92 -0
  58. package/scripts/coverage_eval_parse.py +95 -0
  59. package/scripts/deploy-and-build.sh +99 -0
  60. package/scripts/flow-platform.js +18 -0
  61. package/scripts/flow_ai_judge.py +102 -0
  62. package/scripts/flow_lib.py +154 -0
  63. package/scripts/mutation-recall-desktop.sh +186 -0
  64. package/scripts/mutation-recall.sh +121 -0
  65. package/scripts/mutation_lib.py +128 -0
  66. package/scripts/mutation_operators.py +144 -0
  67. package/scripts/platform-gate.js +186 -0
  68. package/scripts/pr-plan.js +68 -0
  69. package/scripts/quick-capture.sh +419 -0
  70. package/scripts/run-android-flow.js +27 -0
  71. package/scripts/run-flow.sh +90 -0
  72. package/scripts/run-web-flow.js +28 -0
  73. package/scripts/run-web-scenario.js +23 -0
  74. package/scripts/validation-matrix.sh +146 -0
  75. package/scripts/vision-fp-eval.sh +206 -0
  76. package/scripts/vision_escalation_responder.py +147 -0
  77. package/scripts/vision_fp_probe.py +221 -0
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env python3
2
+ """Mutation operators for the mutation-recall benchmark (scripts/mutation-recall.sh).
3
+
4
+ SKETCH / WIP — grounded in the real corpus (SwiftUI source in <App>/Sources/*.swift) and the
5
+ real detector taxonomy (the OCQA_ISSUE `type` values the harness emits). Each operator injects
6
+ ONE seeded fault and declares the ground truth: which detector *class* it should trip (or None
7
+ if no detector exists for it). That in/out-of-taxonomy split is the whole point — see
8
+ docs/COMPETITIVE-MAP.md §0.
9
+
10
+ LIMITATIONS (honest):
11
+ - Regex/line transforms, not a real Swift AST. Robust version: swiftsyntax, one mutant per
12
+ (operator, matched node). Here we take the FIRST matching site per (operator, file).
13
+ - Screen attribution is heuristic (nearest .navigationTitle above the site). The fiddliest
14
+ part of honest matching; a wrong screen name shows up as a coverage miss, not a detect miss.
15
+
16
+ Usage:
17
+ mutation_operators.py list <App/Sources> -> JSON array of applicable sites
18
+ mutation_operators.py apply <file> <op_id> (in place; caller backs up + restores)
19
+ """
20
+ import sys, re, json, glob, os
21
+ from dataclasses import dataclass
22
+ from typing import Callable, Optional
23
+
24
+
25
+ @dataclass
26
+ class Op:
27
+ id: str
28
+ bug_class: str # human label for the fault
29
+ expected_type: Optional[str] # OCQA_ISSUE `type` a detector should emit; None => out-of-taxonomy
30
+ in_taxonomy: bool
31
+ find: str # regex that identifies an applicable site
32
+ apply: Callable[[str], str] # src -> mutated src (mutates the FIRST match only)
33
+
34
+
35
+ def _sub_first(pattern, repl, src, flags=re.S):
36
+ return re.sub(pattern, repl, src, count=1, flags=flags)
37
+
38
+
39
+ OPS = [
40
+ # ---------- IN-TAXONOMY: a detector class exists; measures "do we catch what we claim?" ----------
41
+ # Skips labels the harness's isSelectionOrValueControl deliberately EXCLUDES from dead-control
42
+ # detection (increment/decrement — steppers legitimately no-op at boundaries). Measured: a
43
+ # gutted 'Decrement' is invisible 4/4 runs BY DESIGN (the FP guard), so mutating it measures
44
+ # the documented precision/recall trade, not the detector. This operator measures what the
45
+ # detector claims to catch; a future dead_stepper op can price the known gap explicitly.
46
+ Op("dead_button", "button action gutted (dead control)", "unresponsive_element", True,
47
+ r'Button\("(?![^"]*(?:ncrement|ecrement))([^"]+)"\)\s*\{.*?\}',
48
+ lambda s: _sub_first(r'(Button\("(?![^"]*(?:ncrement|ecrement))[^"]+"\)\s*\{).*?(\})',
49
+ r'\1 /* MUT: no-op */ \2', s)),
50
+
51
+ Op("infinite_spinner", "loading flag never cleared (perpetual spinner)", "app_hang", True,
52
+ r'(isLoading|loaded|isReady)\s*=\s*false', # the line that would END loading
53
+ lambda s: _sub_first(r'((?:isLoading|loaded|isReady)\s*=\s*false)', r'/* MUT */ // \1', s)),
54
+
55
+ # NOTE: must attach to a view that actually RENDERS. The first sketch inserted
56
+ # `let _mut = Text(...)` after `var body {` — a local never added to the view tree, so the
57
+ # "fault" was invisible to the a11y tree and the benchmark misread it as a detector miss.
58
+ # Overlaying the .navigationTitle-bearing view guarantees the error text is really on screen.
59
+ Op("inject_error", "always-visible error surface", "error_surface", True,
60
+ r'\.navigationTitle\("[^"]+"\)',
61
+ lambda s: _sub_first(r'(\.navigationTitle\("[^"]+"\))',
62
+ r'\1.overlay(Text("Something went wrong. Error 500.")) /* MUT */', s)),
63
+
64
+ Op("broken_nav", "NavigationLink destination blanked", "blank_screen", True,
65
+ r'NavigationLink\s*\{[^}]*\}',
66
+ lambda s: _sub_first(r'(NavigationLink\s*\{)[^}]*(\})', r'\1 EmptyView() /* MUT */ \2', s)),
67
+
68
+ # Same inert-let lesson as inject_error: fatalError must live inside a closure that FIRES.
69
+ # Injecting into an existing .task/.onAppear guarantees it runs when the screen appears.
70
+ Op("crash_on_appear", "fatalError on screen appear", "crash", True,
71
+ r'\.(?:task|onAppear)\s*\{',
72
+ lambda s: _sub_first(r'(\.(?:task|onAppear)\s*\{)',
73
+ r'\1 fatalError("MUT crash"); ', s)),
74
+
75
+ # ---------- OUT-OF-TAXONOMY: no detector; measures the CEILING of deterministic detection ----------
76
+ # These should score ~0. That number is the argument for regression-diff + oracle/Flows.
77
+ Op("mislabel_button", "button label typo (still works, wrong text)", None, False,
78
+ r'Button\("[A-Za-z]{4,}"\)',
79
+ lambda s: _sub_first(r'Button\("([A-Za-z])([A-Za-z])([A-Za-z]+)"\)',
80
+ r'Button("\2\1\3")', s)), # transpose first two letters
81
+
82
+ Op("wrong_arithmetic", "arithmetic operator flipped (wrong number, UI fine)", None, False,
83
+ r'\w+\s*\+\s*\w+',
84
+ lambda s: _sub_first(r'(\w+)\s*\+\s*(\w+)', r'\1 - \2', s)),
85
+
86
+ # Skip `Enum.allCases` sites (static pickers/segments) so the mutation lands on real DATA
87
+ # (e.g. ForEach(filteredItems)) — dropping the last row of a data list is the silent-loss
88
+ # bug this operator exists to model; dropping the last segment of a picker is much weaker.
89
+ Op("off_by_one", "list drops its last row (silent data loss)", None, False,
90
+ r'ForEach\((?![A-Za-z_][\w\.]*\.allCases\))([A-Za-z_][\w\.]*)\)',
91
+ lambda s: _sub_first(r'ForEach\((?![A-Za-z_][\w\.]*\.allCases\))([A-Za-z_][\w\.]*)\)',
92
+ r'ForEach(\1.dropLast())', s)),
93
+
94
+ Op("swap_fields", "two field bindings swapped (wrong wiring)", None, False,
95
+ r'TextField\(', # NOTE: real swap needs 2 sites; sketch marks it, apply is a TODO
96
+ lambda s: s), # TODO: locate two adjacent TextField bindings and swap $values
97
+ ]
98
+
99
+ OPS_BY_ID = {o.id: o for o in OPS}
100
+
101
+
102
+ def infer_screen(src: str, upto: int) -> str:
103
+ """Nearest .navigationTitle("X") at or above char offset `upto` — the harness names screens
104
+ by nav title, so this is our ground-truth screen for match. Fallback: last title in file."""
105
+ titles = [(m.start(), m.group(1)) for m in re.finditer(r'\.navigationTitle\("([^"]+)"\)', src)]
106
+ above = [t for pos, t in titles if pos <= upto] or [t for _, t in titles]
107
+ return above[-1] if above else "?"
108
+
109
+
110
+ def list_sites(sources_dir: str):
111
+ sites = []
112
+ for path in sorted(glob.glob(os.path.join(sources_dir, "*.swift"))):
113
+ src = open(path, encoding="utf-8", errors="replace").read()
114
+ for op in OPS:
115
+ m = re.search(op.find, src, flags=re.S)
116
+ if not m:
117
+ continue
118
+ sites.append({
119
+ "file": path, "op": op.id, "bug_class": op.bug_class,
120
+ "expected_type": op.expected_type, "in_taxonomy": op.in_taxonomy,
121
+ "screen": infer_screen(src, m.start()),
122
+ })
123
+ return sites
124
+
125
+
126
+ def apply(path: str, op_id: str) -> bool:
127
+ op = OPS_BY_ID[op_id]
128
+ src = open(path, encoding="utf-8", errors="replace").read()
129
+ mutated = op.apply(src)
130
+ if mutated == src:
131
+ return False # no-op (e.g. swap_fields TODO) — caller skips
132
+ open(path, "w", encoding="utf-8").write(mutated)
133
+ return True
134
+
135
+
136
+ if __name__ == "__main__":
137
+ cmd = sys.argv[1] if len(sys.argv) > 1 else ""
138
+ if cmd == "list":
139
+ print(json.dumps(list_sites(sys.argv[2]), indent=2))
140
+ elif cmd == "apply":
141
+ ok = apply(sys.argv[2], sys.argv[3])
142
+ sys.exit(0 if ok else 3)
143
+ else:
144
+ print(__doc__); sys.exit(2)
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ // CI orchestration for the platform drivers that do not need Xcode. The report,
3
+ // baseline, Flow, and merge-gate behavior is the same ci-report.js used by iOS.
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { spawnSync } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+ import { runQaAndroid, runQaWeb, startManagedWebTarget, stopManagedWebTarget } from "../mcp-server/src/index.js";
10
+ import { runAndroidFlow } from "../mcp-server/src/android-flow.js";
11
+ import { inferFlowPlatform, loadFlowFile } from "../mcp-server/src/flow-runtime.js";
12
+ import { runWebFlow } from "../mcp-server/src/web-flow.js";
13
+ import { runWebScenario, validateScenario } from "../mcp-server/src/scenario-runtime.js";
14
+ import { compileReleaseContract, loadReleaseContractFile } from "../mcp-server/src/release-contract.js";
15
+ import { prExplorationTargetsFromPlan } from "../mcp-server/src/pr-selection.js";
16
+
17
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
18
+ const args = { flows: [], scenarios: [], contracts: [], actions: 40, timeout: 600, failOn: "gate" };
19
+ for (let i = 2; i < process.argv.length; i += 1) {
20
+ const key = process.argv[i];
21
+ const value = process.argv[++i];
22
+ if (key === "--flow") args.flows.push(value);
23
+ else if (key === "--scenario") args.scenarios.push(value);
24
+ else if (key === "--contract") args.contracts.push(value);
25
+ else if (key === "--platform") args.platform = value;
26
+ else if (key === "--url") args.url = value;
27
+ else if (key === "--project-dir") args.projectDir = value;
28
+ else if (key === "--web-target") args.webTarget = value;
29
+ else if (key === "--target-key") args.targetKey = value;
30
+ else if (key === "--app-id") args.appId = value;
31
+ else if (key === "--apk") args.apk = value;
32
+ else if (key === "--serial") args.serial = value;
33
+ else if (key === "--actions") args.actions = Number(value);
34
+ else if (key === "--timeout") args.timeout = Number(value);
35
+ else if (key === "--baseline") args.baseline = value;
36
+ else if (key === "--fail-on") args.failOn = value;
37
+ else if (key === "--json-out") args.jsonOut = value;
38
+ else if (key === "--md-out") args.mdOut = value;
39
+ else if (key === "--pr-plan") args.prPlan = value;
40
+ else throw new Error(`Unknown argument: ${key}`);
41
+ }
42
+ if (!["web", "android"].includes(args.platform)) throw new Error("--platform must be web|android");
43
+ if (args.platform === "web" && !args.url && !args.projectDir) throw new Error("Web gate requires --url or --project-dir for managed build/start");
44
+ if (args.platform === "android" && !args.appId) throw new Error("Android gate requires --app-id");
45
+ if (args.projectDir) {
46
+ args.projectDir = fs.realpathSync(path.resolve(args.projectDir));
47
+ if (!fs.statSync(args.projectDir).isDirectory()) throw new Error(`Project directory not found: ${args.projectDir}`);
48
+ }
49
+
50
+ // Parse and platform-filter before launching a browser/device so a typo cannot
51
+ // spend the entire CI budget and then silently run zero committed tests.
52
+ const selectedFlows = args.flows.map((flowPath) => ({ flowPath, flow: loadFlowFile(flowPath) }))
53
+ .filter(({ flow }) => inferFlowPlatform(flow) === args.platform);
54
+ if (args.flows.length && selectedFlows.length === 0) {
55
+ console.error(`❌ None of the supplied Flows target platform '${args.platform}'`);
56
+ process.exit(2);
57
+ }
58
+ const selectedScenarios = args.scenarios.map((scenarioPath) => ({ scenarioPath, scenario: loadFlowFile(scenarioPath) }));
59
+ for (const { scenarioPath, scenario } of selectedScenarios) {
60
+ const errors = validateScenario({ ...scenario, platform: scenario.platform || args.platform });
61
+ if (errors.length) {
62
+ console.error(`❌ Invalid Scenario ${scenarioPath}: ${errors.join("; ")}`);
63
+ process.exit(2);
64
+ }
65
+ if (args.platform !== "web") {
66
+ console.error(`❌ Multi-actor Scenario ${scenarioPath} requires platform 'web'; ${args.platform} actor isolation is not implemented`);
67
+ process.exit(2);
68
+ }
69
+ }
70
+ const selectedContracts = [];
71
+ for (const contractPath of args.contracts) {
72
+ let contract;
73
+ try { contract = await loadReleaseContractFile(contractPath); }
74
+ catch (error) { console.error(`❌ Invalid Release Contract ${contractPath}: ${error.message}`); process.exit(2); }
75
+ if (!contract.platforms.includes(args.platform)) continue;
76
+ try {
77
+ selectedContracts.push({ contractPath, contract, execution: compileReleaseContract(contract, { platform: args.platform, sourcePath: contractPath }) });
78
+ } catch (error) {
79
+ console.error(`❌ Could not compile Release Contract ${contractPath}: ${error.message}`);
80
+ process.exit(2);
81
+ }
82
+ }
83
+
84
+ let prPlan = null;
85
+ let prExplorationTargets = [];
86
+ if (args.prPlan) {
87
+ try {
88
+ prPlan = JSON.parse(fs.readFileSync(path.resolve(args.prPlan), "utf8"));
89
+ prExplorationTargets = prExplorationTargetsFromPlan(prPlan, args.platform);
90
+ } catch (error) {
91
+ console.error(`❌ Could not read PR plan ${args.prPlan}: ${error.message || error}`);
92
+ process.exit(2);
93
+ }
94
+ }
95
+
96
+ let managedRuntime = null;
97
+ let exitCode = 1;
98
+ try {
99
+ if (args.platform === "web" && !args.url) {
100
+ const started = await startManagedWebTarget({
101
+ root: args.projectDir,
102
+ requestedTarget: args.webTarget || "",
103
+ timeout: args.timeout,
104
+ onStatus: (status) => console.error(`⏳ ${status}`),
105
+ });
106
+ if (started.error) {
107
+ console.error(`❌ ${started.error}`);
108
+ if (started.details?.remediation) console.error(` ${started.details.remediation}`);
109
+ process.exitCode = 1;
110
+ } else {
111
+ managedRuntime = started;
112
+ args.url = started.url;
113
+ console.error(`🎯 Managed web target ready at ${args.url}; it will be stopped after the gate.`);
114
+ }
115
+ }
116
+ if (args.platform === "web" && !args.url) {
117
+ exitCode = 1;
118
+ } else {
119
+ const qa = args.platform === "web"
120
+ ? await runQaWeb({ url: args.url, maxActions: args.actions, timeout: args.timeout, testEmail: process.env.OCQA_TEST_EMAIL, testPassword: process.env.OCQA_TEST_PASSWORD, seedTargets: prExplorationTargets })
121
+ : await runQaAndroid({ appId: args.appId, apkPath: args.apk, serial: args.serial, maxActions: args.actions, timeout: args.timeout,
122
+ testEmail: process.env.OCQA_TEST_EMAIL, testPassword: process.env.OCQA_TEST_PASSWORD, seedTargets: prExplorationTargets });
123
+ if (qa.error) {
124
+ console.error(`❌ ${qa.error}`);
125
+ exitCode = 1;
126
+ } else {
127
+ const captureDir = qa.structured.capture.path;
128
+ const markers = path.join(captureDir, "ocqa-markers.txt");
129
+ const flowLogs = [];
130
+ for (const { flowPath, flow } of selectedFlows) {
131
+ const logPath = path.join(os.tmpdir(), `tapp-ci-${args.platform}-${path.basename(flowPath).replace(/\.ya?ml$/i, "")}-${Date.now()}.log`);
132
+ const evidenceDir = path.join(captureDir, "flows", path.basename(flowPath).replace(/\.ya?ml$/i, ""));
133
+ try {
134
+ if (args.platform === "web") await runWebFlow({ flow, url: args.url, logPath, screenshotDir: evidenceDir });
135
+ else await runAndroidFlow({ flow, appId: args.appId, apkPath: undefined, serial: args.serial, logPath, screenshotDir: evidenceDir });
136
+ } catch (error) {
137
+ fs.writeFileSync(logPath, `OCQA_FLOW_RESULT:${JSON.stringify({ passed: false, total: flow.steps.length, failed: 1, error: error.message || String(error) })}\n`);
138
+ }
139
+ flowLogs.push({ kind: "flow", path: logPath });
140
+ }
141
+ for (const { scenarioPath, scenario } of selectedScenarios) {
142
+ const logPath = path.join(os.tmpdir(), `tapp-ci-scenario-${path.basename(scenarioPath).replace(/\.ya?ml$/i, "")}-${Date.now()}.log`);
143
+ const evidenceDir = path.join(captureDir, "scenarios", path.basename(scenarioPath).replace(/\.ya?ml$/i, ""));
144
+ try {
145
+ await runWebScenario({ scenario, url: args.url, logPath, screenshotDir: evidenceDir });
146
+ } catch (error) {
147
+ fs.writeFileSync(logPath, `OCQA_FLOW_RESULT:${JSON.stringify({ passed: false, name: scenario.name, kind: "scenario", total: scenario.steps.length, executed: 0, failed: 1, error: error.message || String(error) })}\n`);
148
+ }
149
+ flowLogs.push({ kind: "scenario", path: logPath });
150
+ }
151
+ for (const { contractPath, contract, execution } of selectedContracts) {
152
+ const stem = path.basename(contractPath).replace(/\.contract\.(?:ts|mts|mjs|js|json)$/i, "");
153
+ const logPath = path.join(os.tmpdir(), `tapp-ci-contract-${stem}-${Date.now()}.log`);
154
+ const evidenceDir = path.join(captureDir, "contracts", stem);
155
+ try {
156
+ if (execution.kind === "scenario") await runWebScenario({ scenario: execution, url: args.url, logPath, screenshotDir: evidenceDir });
157
+ else if (args.platform === "web") await runWebFlow({ flow: execution, url: args.url, logPath, screenshotDir: evidenceDir });
158
+ else await runAndroidFlow({ flow: execution, appId: args.appId, apkPath: undefined, serial: args.serial, logPath, screenshotDir: evidenceDir });
159
+ } catch (error) {
160
+ fs.writeFileSync(logPath, `OCQA_FLOW_RESULT:${JSON.stringify({ passed: false, name: contract.title, kind: "release-contract", contract: contract.name, criticality: contract.criticality, total: execution.steps.length, executed: 0, failed: 1, error: error.message || String(error) })}\n`);
161
+ }
162
+ flowLogs.push({ kind: "contract", path: logPath });
163
+ }
164
+
165
+ const reportArgs = [path.join(root, "mcp-server", "src", "ci-report.js"), "--markers", markers, "--platform", args.platform,
166
+ "--fail-on", args.failOn, "--html-dir", captureDir, "--label", args.url || args.appId];
167
+ if (args.targetKey) reportArgs.push("--target-key", args.targetKey);
168
+ if (args.baseline) reportArgs.push("--baseline", args.baseline);
169
+ if (args.jsonOut) reportArgs.push("--json-out", args.jsonOut);
170
+ if (args.mdOut) reportArgs.push("--md-out", args.mdOut);
171
+ if (args.prPlan) reportArgs.push("--pr-plan", args.prPlan);
172
+ if (args.prPlan && args.platform === "web" && args.projectDir) {
173
+ reportArgs.push("--project-dir", args.projectDir, "--maintenance-url", args.url);
174
+ }
175
+ for (const log of flowLogs) reportArgs.push(log.kind === "contract" ? "--contract-log" : log.kind === "scenario" ? "--scenario-log" : "--flow-log", log.path);
176
+ const report = spawnSync(process.execPath, reportArgs, { stdio: "inherit" });
177
+ exitCode = report.status ?? 1;
178
+ }
179
+ }
180
+ } finally {
181
+ if (managedRuntime) {
182
+ await stopManagedWebTarget(managedRuntime);
183
+ console.error("🧹 Managed web target stopped.");
184
+ }
185
+ }
186
+ process.exit(exitCode);
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ // Portable PR planning entrypoint used by the local gate and GitHub Action.
3
+ // It never invokes a shell, edits repository artifacts, or guesses ownership.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { buildPrContractPlan, changedFilesFromGit, changedSymbolEvidenceFromGit, prExplorationTargetsFromPlan, readChangedFilesFile } from "../mcp-server/src/pr-selection.js";
7
+
8
+ const args = { contracts: [], head: "HEAD" };
9
+ for (let index = 2; index < process.argv.length; index += 1) {
10
+ const key = process.argv[index];
11
+ const value = process.argv[++index];
12
+ if (key === "--project-dir") args.projectDir = value;
13
+ else if (key === "--platform") args.platform = value;
14
+ else if (key === "--base") args.base = value;
15
+ else if (key === "--head") args.head = value;
16
+ else if (key === "--changed-files-file") args.changedFilesFile = value;
17
+ else if (key === "--map") args.mapPath = value;
18
+ else if (key === "--contract") args.contracts.push(value);
19
+ else if (key === "--json-out") args.jsonOut = value;
20
+ else if (key === "--selection-out") args.selectionOut = value;
21
+ else if (key === "--exploration-target-out") args.explorationTargetOut = value;
22
+ else throw new Error(`Unknown argument: ${key}`);
23
+ }
24
+ if (!args.projectDir || !args.platform || !args.jsonOut) {
25
+ console.error("usage: pr-plan.js --project-dir DIR --platform ios|android|web (--base REF [--head REF] | --changed-files-file FILE) --json-out FILE [--contract FILE ...]");
26
+ process.exit(2);
27
+ }
28
+ try {
29
+ const changedFiles = args.changedFilesFile
30
+ ? readChangedFilesFile(args.changedFilesFile)
31
+ : changedFilesFromGit({ projectDir: args.projectDir, base: args.base, head: args.head });
32
+ const changedSymbolEvidence = args.changedFilesFile
33
+ ? []
34
+ : changedSymbolEvidenceFromGit({ projectDir: args.projectDir, base: args.base, head: args.head });
35
+ const plan = await buildPrContractPlan({
36
+ projectDir: args.projectDir,
37
+ platform: args.platform,
38
+ changedFiles,
39
+ changedSymbolEvidence,
40
+ mapPath: args.mapPath || "",
41
+ contractPaths: args.contracts,
42
+ discoverContracts: args.contracts.length === 0,
43
+ });
44
+ const output = path.resolve(args.jsonOut);
45
+ fs.mkdirSync(path.dirname(output), { recursive: true });
46
+ fs.writeFileSync(output, JSON.stringify(plan, null, 2) + "\n");
47
+ if (args.selectionOut) {
48
+ const root = fs.realpathSync(path.resolve(args.projectDir));
49
+ const selectedPaths = plan.selected.map((item) => {
50
+ const candidate = fs.realpathSync(path.resolve(root, item.path));
51
+ if (candidate !== root && !candidate.startsWith(root + path.sep)) throw new Error(`selected contract escapes project directory: ${item.path}`);
52
+ return candidate;
53
+ });
54
+ const selectionOutput = path.resolve(args.selectionOut);
55
+ fs.mkdirSync(path.dirname(selectionOutput), { recursive: true });
56
+ fs.writeFileSync(selectionOutput, JSON.stringify(selectedPaths, null, 2) + "\n");
57
+ }
58
+ if (args.explorationTargetOut) {
59
+ const targetOutput = path.resolve(args.explorationTargetOut);
60
+ fs.mkdirSync(path.dirname(targetOutput), { recursive: true });
61
+ const target = prExplorationTargetsFromPlan(plan, args.platform)[0] || null;
62
+ fs.writeFileSync(targetOutput, JSON.stringify(target, null, 2) + "\n");
63
+ }
64
+ console.log(`PR plan: ${plan.selected.length} selected, ${plan.skipped.length} skipped, ${plan.uncoveredChangedFiles.length} unknown file(s), ${plan.uncoveredUiMap.nodes.length + plan.uncoveredUiMap.edges.length} mapped coverage gap(s)`);
65
+ } catch (error) {
66
+ console.error(error.message || String(error));
67
+ process.exit(2);
68
+ }