@bugmole/cli 0.4.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/.bugmole.env.example +20 -0
- package/LICENSE +7 -0
- package/README.md +293 -0
- package/TESTING.md +117 -0
- package/bugmole.config.yaml +39 -0
- package/package.json +85 -0
- package/scripts/billing/paypal-setup.mjs +121 -0
- package/scripts/bugmole-continue.ts +318 -0
- package/scripts/bugmole-init.ts +188 -0
- package/scripts/bugmole.cjs +17 -0
- package/scripts/bugmole.test.ts +344 -0
- package/scripts/bugmole.ts +657 -0
- package/scripts/ensure-maestro.cjs +79 -0
- package/scripts/ios-tunnel-keeper.sh +45 -0
- package/scripts/ios-wda-keeper.sh +66 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -0
- package/scripts/ui-parity-diff.py +65 -0
- package/scripts/ui-parity-requirements.txt +1 -0
- package/scripts/verify-manage-to-plans.mts +194 -0
- package/spec/app-ui-audit.schema.json +176 -0
- package/spec/blockers.yaml +79 -0
- package/spec/bugs.index.json +42 -0
- package/spec/design-dna.schema.json +38 -0
- package/spec/domain_rules.yaml +24 -0
- package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
- package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
- package/spec/journey_graph.yaml +126 -0
- package/spec/journeys.graph.json +2618 -0
- package/spec/plans/dashboard-smoke.flow.yaml +20 -0
- package/spec/plans/example.flow.yaml +99 -0
- package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
- package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
- package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
- package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
- package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
- package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
- package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
- package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
- package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
- package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
- package/spec/plans/login.flow.yaml +22 -0
- package/spec/plans/owner-operations.flow.yaml +20 -0
- package/spec/project_config.yaml +55 -0
- package/spec/roles.yaml +30 -0
- package/spec/schema.md +394 -0
- package/spec/test-case-results.schema.json +62 -0
- package/spec/test-cases.schema.json +85 -0
- package/spec/ui-parity-audit.schema.json +194 -0
- package/spec/ui-reverse-engineering.schema.json +94 -0
- package/src/billing/plan-catalog.test.ts +46 -0
- package/src/billing/plan-catalog.ts +199 -0
- package/src/integrations/aws-sigv4.test.ts +42 -0
- package/src/integrations/aws-sigv4.ts +72 -0
- package/src/integrations/device-farm.ts +155 -0
- package/src/integrations/github-app.test.ts +57 -0
- package/src/integrations/github-app.ts +143 -0
- package/src/integrations/gitlab.ts +81 -0
- package/src/integrations/temp-email.test.ts +123 -0
- package/src/integrations/temp-email.ts +175 -0
- package/src/integrations/testflight-feedback.test.ts +51 -0
- package/src/integrations/testflight-feedback.ts +173 -0
- package/src/integrations/webdriver-client.ts +131 -0
- package/src/mcp/server.test.ts +1220 -0
- package/src/mcp/server.ts +3064 -0
- package/src/mcp/write-test-cases.test.ts +287 -0
- package/src/registry/api-key-client.ts +39 -0
- package/src/registry/control-plane-client.ts +212 -0
- package/src/registry/migrations/0001_registry.sql +47 -0
- package/src/registry/migrations/0002_device_authorizations.sql +23 -0
- package/src/registry/migrations/0003_project_environments.sql +25 -0
- package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
- package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
- package/src/registry/migrations/0006_workspaces.sql +36 -0
- package/src/registry/migrations/0007_project_apps.sql +26 -0
- package/src/registry/migrations/0008_agent_tasks.sql +30 -0
- package/src/registry/migrations/0009_journey_revisions.sql +17 -0
- package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
- package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
- package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
- package/src/registry/migrations/0013_device_sessions.sql +22 -0
- package/src/registry/migrations/0014_agent_task_step.sql +1 -0
- package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
- package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
- package/src/registry/migrations/0017_run_targets.sql +7 -0
- package/src/registry/migrations/0018_run_fix_from.sql +4 -0
- package/src/registry/migrations/0019_workspace_flags.sql +9 -0
- package/src/registry/migrations/0020_orgs.sql +40 -0
- package/src/registry/migrations/0021_billing_core.sql +58 -0
- package/src/registry/migrations/0022_cloud_runners.sql +19 -0
- package/src/registry/migrations/0023_signup.sql +4 -0
- package/src/registry/migrations/0024_billing.sql +67 -0
- package/src/registry/migrations/0025_notifications.sql +47 -0
- package/src/registry/migrations/0026_repo_bindings.sql +28 -0
- package/src/registry/migrations/0027_feedback.sql +29 -0
- package/src/registry/migrations/0028_devices.sql +48 -0
- package/src/registry/migrations/0029_sso.sql +31 -0
- package/src/registry/migrations/0030_workspace_domains.sql +18 -0
- package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
- package/src/registry/migrations/0032_personas.sql +15 -0
- package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
- package/src/registry/task-scheduling.test.ts +100 -0
- package/src/registry/task-scheduling.ts +80 -0
- package/src/registry-worker/ai/platform-model.ts +77 -0
- package/src/registry-worker/ai/routes.test.ts +88 -0
- package/src/registry-worker/ai/routes.ts +90 -0
- package/src/registry-worker/artifacts.test.ts +98 -0
- package/src/registry-worker/artifacts.ts +85 -0
- package/src/registry-worker/billing/billing-core.test.ts +175 -0
- package/src/registry-worker/billing/checkout-routes.ts +209 -0
- package/src/registry-worker/billing/enforcement.ts +69 -0
- package/src/registry-worker/billing/entitlements.ts +108 -0
- package/src/registry-worker/billing/ledger.ts +186 -0
- package/src/registry-worker/billing/paypal/api.ts +259 -0
- package/src/registry-worker/billing/paypal/client.ts +91 -0
- package/src/registry-worker/billing/paypal/provider.ts +143 -0
- package/src/registry-worker/billing/paypal.test.ts +466 -0
- package/src/registry-worker/billing/provider.ts +114 -0
- package/src/registry-worker/billing/routes.ts +66 -0
- package/src/registry-worker/billing/subscriptions.ts +780 -0
- package/src/registry-worker/billing/thresholds.ts +107 -0
- package/src/registry-worker/core.ts +308 -0
- package/src/registry-worker/devices/devices.test.ts +185 -0
- package/src/registry-worker/devices/policy.ts +71 -0
- package/src/registry-worker/devices/routes.ts +453 -0
- package/src/registry-worker/domains/domains.test.ts +210 -0
- package/src/registry-worker/domains/routes.ts +139 -0
- package/src/registry-worker/domains.ts +88 -0
- package/src/registry-worker/email/sender.ts +75 -0
- package/src/registry-worker/env.d.ts +14716 -0
- package/src/registry-worker/features.ts +20 -0
- package/src/registry-worker/feedback/feedback.test.ts +230 -0
- package/src/registry-worker/feedback/format.ts +148 -0
- package/src/registry-worker/feedback/routes.ts +386 -0
- package/src/registry-worker/flags.ts +39 -0
- package/src/registry-worker/github/checks.test.ts +177 -0
- package/src/registry-worker/github/checks.ts +374 -0
- package/src/registry-worker/gitlab/checks.test.ts +141 -0
- package/src/registry-worker/gitlab/checks.ts +349 -0
- package/src/registry-worker/hooks.ts +54 -0
- package/src/registry-worker/index.ts +2077 -0
- package/src/registry-worker/jobs/index.ts +29 -0
- package/src/registry-worker/jobs/retention.ts +68 -0
- package/src/registry-worker/mcp/mcp.test.ts +355 -0
- package/src/registry-worker/mcp/routes.ts +215 -0
- package/src/registry-worker/mcp/token.ts +126 -0
- package/src/registry-worker/mcp/tools.ts +563 -0
- package/src/registry-worker/notifications/alerts.ts +212 -0
- package/src/registry-worker/notifications/notifications.test.ts +298 -0
- package/src/registry-worker/notifications/outbox.ts +83 -0
- package/src/registry-worker/notifications/routes.ts +280 -0
- package/src/registry-worker/notifications/secrets.ts +49 -0
- package/src/registry-worker/notifications/slack.ts +96 -0
- package/src/registry-worker/notifications/teams.ts +46 -0
- package/src/registry-worker/org/audit.ts +116 -0
- package/src/registry-worker/org/routes.test.ts +163 -0
- package/src/registry-worker/org/routes.ts +302 -0
- package/src/registry-worker/personas/personas.test.ts +78 -0
- package/src/registry-worker/personas/routes.ts +100 -0
- package/src/registry-worker/repo-triggers.ts +20 -0
- package/src/registry-worker/routes/index.ts +74 -0
- package/src/registry-worker/run-events.ts +24 -0
- package/src/registry-worker/runner/dispatch.ts +219 -0
- package/src/registry-worker/runner/jobs.ts +43 -0
- package/src/registry-worker/runner/metering.ts +82 -0
- package/src/registry-worker/runner/policy.ts +59 -0
- package/src/registry-worker/runner/routes.ts +171 -0
- package/src/registry-worker/runner/runner.test.ts +358 -0
- package/src/registry-worker/runner/tokens.ts +93 -0
- package/src/registry-worker/runs.test.ts +60 -0
- package/src/registry-worker/signup/policy.ts +57 -0
- package/src/registry-worker/signup/routes.ts +106 -0
- package/src/registry-worker/signup/signup.test.ts +81 -0
- package/src/registry-worker/sso/aegis.ts +141 -0
- package/src/registry-worker/sso/membership.ts +157 -0
- package/src/registry-worker/sso/routes.ts +458 -0
- package/src/registry-worker/sso/sso.test.ts +344 -0
- package/src/registry-worker/testing/d1-shim.ts +180 -0
- package/src/registry-worker/testing/harness.ts +137 -0
- package/src/runner-worker/index.ts +108 -0
- package/src/runtime/ai-analysis.ts +97 -0
- package/src/runtime/ai-exploration.test.ts +32 -0
- package/src/runtime/ai-exploration.ts +69 -0
- package/src/runtime/ai-repair.ts +74 -0
- package/src/runtime/ai-work.test.ts +99 -0
- package/src/runtime/android-screen-record.test.ts +75 -0
- package/src/runtime/android-screen-record.ts +192 -0
- package/src/runtime/app-understanding.test.ts +123 -0
- package/src/runtime/app-understanding.ts +201 -0
- package/src/runtime/appium-driver.test.ts +179 -0
- package/src/runtime/appium-driver.ts +295 -0
- package/src/runtime/blocker-resolution.test.ts +113 -0
- package/src/runtime/blocker-resolution.ts +111 -0
- package/src/runtime/browser-matrix.integration.test.ts +212 -0
- package/src/runtime/browser-matrix.test.ts +143 -0
- package/src/runtime/browser-matrix.ts +200 -0
- package/src/runtime/canonical-flow.test.ts +52 -0
- package/src/runtime/config-validate.ts +185 -0
- package/src/runtime/continuous-execution.ts +291 -0
- package/src/runtime/cursor-applescript.ts +573 -0
- package/src/runtime/cursor-cli-driver.test.ts +78 -0
- package/src/runtime/cursor-cli-driver.ts +156 -0
- package/src/runtime/cursor-driver-example.ts +117 -0
- package/src/runtime/cursor-driver-index.ts +65 -0
- package/src/runtime/cursor-driver-init.ts +277 -0
- package/src/runtime/cursor-driver-run.test.ts +15 -0
- package/src/runtime/cursor-driver-run.ts +323 -0
- package/src/runtime/cursor-driver.ts +332 -0
- package/src/runtime/cursor-llm-example.ts +90 -0
- package/src/runtime/cursor-llm.ts +206 -0
- package/src/runtime/cursor-mcp-monitor.ts +386 -0
- package/src/runtime/device-clouds/browserstack.ts +73 -0
- package/src/runtime/device-clouds/device-farm.ts +52 -0
- package/src/runtime/device-clouds/index.ts +92 -0
- package/src/runtime/device-clouds/kobiton.ts +70 -0
- package/src/runtime/device-clouds/targets.ts +44 -0
- package/src/runtime/device-clouds/types.ts +62 -0
- package/src/runtime/diff-proposal.ts +84 -0
- package/src/runtime/discovery-task.test.ts +29 -0
- package/src/runtime/discovery-task.ts +284 -0
- package/src/runtime/driver-recovery.ts +69 -0
- package/src/runtime/driver.ts +79 -0
- package/src/runtime/environment.test.ts +104 -0
- package/src/runtime/environment.ts +137 -0
- package/src/runtime/executor.test.ts +509 -0
- package/src/runtime/executor.ts +921 -0
- package/src/runtime/explorer.test.ts +101 -0
- package/src/runtime/explorer.ts +1013 -0
- package/src/runtime/failure-analysis.test.ts +111 -0
- package/src/runtime/failure-analysis.ts +272 -0
- package/src/runtime/fixtures/fake-maestro.sh +36 -0
- package/src/runtime/flow-language.test.ts +268 -0
- package/src/runtime/flow-language.ts +414 -0
- package/src/runtime/init-wizard.ts +354 -0
- package/src/runtime/ios-screen-record.test.ts +68 -0
- package/src/runtime/ios-screen-record.ts +155 -0
- package/src/runtime/journey-editor.ts +452 -0
- package/src/runtime/journey-evidence.test.ts +161 -0
- package/src/runtime/journey-evidence.ts +180 -0
- package/src/runtime/journey-graph.test.ts +257 -0
- package/src/runtime/journey-graph.ts +170 -0
- package/src/runtime/legacy-names.ts +32 -0
- package/src/runtime/llm-example.ts +105 -0
- package/src/runtime/llm.ts +527 -0
- package/src/runtime/local-browser.test.ts +45 -0
- package/src/runtime/local-browser.ts +48 -0
- package/src/runtime/local-registry-stub.test.ts +325 -0
- package/src/runtime/local-registry-stub.ts +803 -0
- package/src/runtime/maestro-driver.test.ts +84 -0
- package/src/runtime/maestro-driver.ts +209 -0
- package/src/runtime/mole-voice.ts +21 -0
- package/src/runtime/nav-crawl.test.ts +100 -0
- package/src/runtime/nav-crawl.ts +153 -0
- package/src/runtime/pipeline.test.ts +405 -0
- package/src/runtime/pipeline.ts +833 -0
- package/src/runtime/planner.test.ts +37 -0
- package/src/runtime/planner.ts +274 -0
- package/src/runtime/platform-ai.ts +76 -0
- package/src/runtime/playwright-driver.test.ts +93 -0
- package/src/runtime/playwright-driver.ts +620 -0
- package/src/runtime/project-spec.ts +140 -0
- package/src/runtime/record-run-verdicts.ts +68 -0
- package/src/runtime/reporter.test.ts +56 -0
- package/src/runtime/reporter.ts +158 -0
- package/src/runtime/reset.test.ts +44 -0
- package/src/runtime/reset.ts +61 -0
- package/src/runtime/reviewer.test.ts +73 -0
- package/src/runtime/reviewer.ts +158 -0
- package/src/runtime/run-job.ts +136 -0
- package/src/runtime/run-once.test.ts +207 -0
- package/src/runtime/run-once.ts +168 -0
- package/src/runtime/run.ts +132 -0
- package/src/runtime/screen-recording.ts +34 -0
- package/src/runtime/serve-gateway.test.ts +74 -0
- package/src/runtime/serve-gateway.ts +164 -0
- package/src/runtime/serve-worker.test.ts +23 -0
- package/src/runtime/serve-worker.ts +278 -0
- package/src/runtime/site-discovery.test.ts +168 -0
- package/src/runtime/site-discovery.ts +308 -0
- package/src/runtime/target-runner.ts +144 -0
- package/src/runtime/test-case-verdicts.test.ts +94 -0
- package/src/runtime/test-case-verdicts.ts +120 -0
- package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
- package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
- package/src/runtime/ui-reverse-engineering/types.ts +197 -0
- package/src/runtime/web-suite.test.ts +97 -0
- package/src/runtime/web-suite.ts +176 -0
- package/src/storage/create-object-store.ts +144 -0
- package/src/storage/keys.ts +34 -0
- package/src/storage/local-artifact-server.test.ts +314 -0
- package/src/storage/local-artifact-server.ts +357 -0
- package/src/storage/object-store.test.ts +28 -0
- package/src/storage/object-store.ts +101 -0
- package/src/storage/registry-object-store.ts +88 -0
- package/src/storage/remote-object-store.ts +104 -0
- package/src/storage/storage-directory.test.ts +43 -0
- package/src/storage/storage-directory.ts +24 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { createProjectObjectStore } from "../storage/create-object-store.js";
|
|
5
|
+
import { projectPrefix } from "../storage/keys.js";
|
|
6
|
+
import { captureJourneyScreenshots, captureUrlWithLocalBrowser } from "./journey-evidence.js";
|
|
7
|
+
import { repairJourneyGraph } from "./journey-graph.js";
|
|
8
|
+
import { getEnvironmentContract } from "./environment.js";
|
|
9
|
+
import { listReferenceInputs } from "./ui-reverse-engineering/coordinator.js";
|
|
10
|
+
import { nextJourneyTaskWithClarification } from "./journey-editor.js";
|
|
11
|
+
import { readAppUnderstanding } from "./app-understanding.js";
|
|
12
|
+
import { crawlNavigation } from "./nav-crawl.js";
|
|
13
|
+
|
|
14
|
+
interface ExploreOptions {
|
|
15
|
+
baseUrl?: string;
|
|
16
|
+
maxDepth?: number;
|
|
17
|
+
captureScreenshots?: boolean;
|
|
18
|
+
projectId?: string;
|
|
19
|
+
appId?: string;
|
|
20
|
+
environmentId?: string;
|
|
21
|
+
accessMethod?: "configured" | "handoff" | "manual" | "unknown";
|
|
22
|
+
journeyId?: string;
|
|
23
|
+
actor?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ExploreResult {
|
|
27
|
+
status: "success" | "error";
|
|
28
|
+
screensDiscovered: number;
|
|
29
|
+
actionsDiscovered: number;
|
|
30
|
+
interactiveControlsDiscovered?: number;
|
|
31
|
+
journeyGraphUpdated: boolean;
|
|
32
|
+
message?: string;
|
|
33
|
+
error?: string;
|
|
34
|
+
nextSteps?: string[];
|
|
35
|
+
nextTask?: Awaited<ReturnType<typeof nextJourneyTaskWithClarification>>;
|
|
36
|
+
journeysReviewed?: Array<{
|
|
37
|
+
id: string;
|
|
38
|
+
revision: number;
|
|
39
|
+
nodeCount: number;
|
|
40
|
+
edgeCount: number;
|
|
41
|
+
review: "preserved" | "migrated" | "retired" | "source-only";
|
|
42
|
+
}>;
|
|
43
|
+
journeysAdded?: string[];
|
|
44
|
+
journeysRetired?: string[];
|
|
45
|
+
entrancesDiscovered?: Array<{ nodeId: string; route?: string; variant?: string; reason: string }>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type RouteProbe = {
|
|
49
|
+
status: "reachable" | "blocked" | "unreachable" | "not_probed";
|
|
50
|
+
httpStatus?: number;
|
|
51
|
+
reason?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function normalizeRoute(route: string, baseRoute = "/"): string {
|
|
55
|
+
const raw = route.split(/[?#]/, 1)[0] || "/";
|
|
56
|
+
const resolved = raw.startsWith("/")
|
|
57
|
+
? raw
|
|
58
|
+
: `${baseRoute.replace(/\/+$/, "")}/${raw}`;
|
|
59
|
+
const segments = resolved.split("/").filter(Boolean)
|
|
60
|
+
.filter((segment) => !/^\([^/]+\)$/.test(segment))
|
|
61
|
+
.map((segment) => segment
|
|
62
|
+
.replace(/^\[\.\.\.(.+)\]$/, "*$1")
|
|
63
|
+
.replace(/^\[(.+)\]$/, ":$1"));
|
|
64
|
+
const normalized = `/${segments.join("/")}`;
|
|
65
|
+
return normalized === "/" ? "/" : normalized.replace(/\/+$/, "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isCatchAllPattern(part: string): boolean {
|
|
69
|
+
return part.startsWith("*") || part.startsWith(":[...");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function routePatternMatches(pattern: string, candidate: string): boolean {
|
|
73
|
+
const patternParts = pattern.split("/").filter(Boolean);
|
|
74
|
+
const candidateParts = candidate.split("/").filter(Boolean);
|
|
75
|
+
let patternIndex = 0;
|
|
76
|
+
let candidateIndex = 0;
|
|
77
|
+
|
|
78
|
+
while (patternIndex < patternParts.length) {
|
|
79
|
+
const part = patternParts[patternIndex];
|
|
80
|
+
if (isCatchAllPattern(part)) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (part.startsWith(":")) {
|
|
84
|
+
if (candidateIndex >= candidateParts.length) {
|
|
85
|
+
const remaining = patternParts.slice(patternIndex);
|
|
86
|
+
return remaining.every((segment) => segment.startsWith(":") && isCatchAllPattern(segment));
|
|
87
|
+
}
|
|
88
|
+
patternIndex += 1;
|
|
89
|
+
candidateIndex += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (candidateIndex >= candidateParts.length || part !== candidateParts[candidateIndex]) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
patternIndex += 1;
|
|
96
|
+
candidateIndex += 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return candidateIndex === candidateParts.length;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function probeRoute(baseUrl: string, route: string): Promise<RouteProbe> {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(route, baseUrl).toString();
|
|
105
|
+
const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(1500) });
|
|
106
|
+
if (response.status === 401 || response.status === 403) {
|
|
107
|
+
return { status: "blocked", httpStatus: response.status, reason: "Authentication is required." };
|
|
108
|
+
}
|
|
109
|
+
if (response.status >= 200 && response.status < 400) {
|
|
110
|
+
return { status: "reachable", httpStatus: response.status };
|
|
111
|
+
}
|
|
112
|
+
return { status: "unreachable", httpStatus: response.status, reason: `HTTP ${response.status}` };
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return {
|
|
115
|
+
status: "unreachable",
|
|
116
|
+
reason: error instanceof Error ? error.message : "Route probe failed.",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const SOURCE_EXTENSIONS = [".tsx", ".ts", ".jsx", ".js", ".vue", ".svelte"];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Repo-relative paths of the first-party components a screen imports.
|
|
125
|
+
*
|
|
126
|
+
* Only local modules count: a bare specifier is a third-party package, not
|
|
127
|
+
* a component of this app. Alias imports (`@/...`) are resolved against the
|
|
128
|
+
* conventional src root so they aren't silently dropped. Paths that don't
|
|
129
|
+
* resolve to a real file are omitted rather than recorded as a guess.
|
|
130
|
+
*/
|
|
131
|
+
export function resolveImportedComponents(
|
|
132
|
+
filePath: string,
|
|
133
|
+
projectRoot: string,
|
|
134
|
+
content: string,
|
|
135
|
+
): string[] {
|
|
136
|
+
const specifiers = [
|
|
137
|
+
...content.matchAll(/import\s+[^"';]*?from\s*["']([^"']+)["']/g),
|
|
138
|
+
...content.matchAll(/import\s*\(\s*["']([^"']+)["']\s*\)/g),
|
|
139
|
+
].map((match) => match[1]);
|
|
140
|
+
|
|
141
|
+
const resolved: string[] = [];
|
|
142
|
+
for (const specifier of specifiers) {
|
|
143
|
+
let candidate: string | null = null;
|
|
144
|
+
if (specifier.startsWith(".")) {
|
|
145
|
+
candidate = path.resolve(path.dirname(filePath), specifier);
|
|
146
|
+
} else if (specifier.startsWith("@/")) {
|
|
147
|
+
candidate = path.resolve(projectRoot, "src", specifier.slice(2));
|
|
148
|
+
}
|
|
149
|
+
if (!candidate) continue;
|
|
150
|
+
const match = [
|
|
151
|
+
candidate,
|
|
152
|
+
...SOURCE_EXTENSIONS.map((extension) => `${candidate}${extension}`),
|
|
153
|
+
...SOURCE_EXTENSIONS.map((extension) => path.join(candidate!, `index${extension}`)),
|
|
154
|
+
].find((option) => {
|
|
155
|
+
try {
|
|
156
|
+
return fs.statSync(option).isFile();
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
if (match) resolved.push(path.relative(projectRoot, match));
|
|
162
|
+
}
|
|
163
|
+
return [...new Set(resolved)];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function slugPart(value: string): string {
|
|
167
|
+
return value
|
|
168
|
+
.replace(/^\/+|\/+$/g, "")
|
|
169
|
+
.replace(/[:[\](){}]+/g, "")
|
|
170
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
171
|
+
.replace(/^-+|-+$/g, "")
|
|
172
|
+
.toLowerCase() || "screen";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function routeVariantFromSegments(segments: string[]): string | undefined {
|
|
176
|
+
const groups = segments
|
|
177
|
+
.filter((segment) => /^\([^/]+\)$/.test(segment))
|
|
178
|
+
.map((segment) => segment.slice(1, -1));
|
|
179
|
+
return groups.length ? groups.join("-") : undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function routeVariantFromTarget(target: string): string | undefined {
|
|
183
|
+
return routeVariantFromSegments(target.split("/").filter(Boolean));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function sourceInteractiveControls(
|
|
187
|
+
content: string,
|
|
188
|
+
relativePath: string,
|
|
189
|
+
node: any,
|
|
190
|
+
routeNodes: any[],
|
|
191
|
+
): Array<Record<string, unknown>> {
|
|
192
|
+
const controls: Array<Record<string, unknown>> = [];
|
|
193
|
+
const lines = content.split(/\r?\n/);
|
|
194
|
+
const interactiveLine = /onPress\s*=|onClick\s*=|href\s*=|to\s*=|router\.(?:push|replace)\s*\(|navigate\s*\(/;
|
|
195
|
+
const targetPattern = /(?:href|to)\s*=\s*["'`]([^"'`]+)["'`]|(?:router\.(?:push|replace)|navigate)\s*\(\s*["'`]([^"'`]+)["'`]/;
|
|
196
|
+
const labelPattern = /(?:accessibilityLabel|aria-label|label|title|placeholder)\s*=\s*["'`]([^"'`]+)["'`]/;
|
|
197
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
198
|
+
const line = lines[index];
|
|
199
|
+
if (!interactiveLine.test(line)) continue;
|
|
200
|
+
const context = lines.slice(Math.max(0, index - 2), Math.min(lines.length, index + 3)).join(" ");
|
|
201
|
+
const targetMatch = context.match(targetPattern);
|
|
202
|
+
const target = targetMatch?.[1] ?? targetMatch?.[2];
|
|
203
|
+
const label = context.match(labelPattern)?.[1]
|
|
204
|
+
?? (target ? `Navigate to ${target}` : `Interactive control at ${relativePath}:${index + 1}`);
|
|
205
|
+
const normalizedTarget = target && !target.startsWith("http") && !target.startsWith("#")
|
|
206
|
+
? normalizeRoute(target, node.route)
|
|
207
|
+
: undefined;
|
|
208
|
+
const targetVariant = target ? routeVariantFromTarget(target) : undefined;
|
|
209
|
+
const matchingNodes = normalizedTarget
|
|
210
|
+
? routeNodes.filter((candidate: any) => routePatternMatches(String(candidate.route), normalizedTarget))
|
|
211
|
+
: [];
|
|
212
|
+
const destination = matchingNodes.find((candidate: any) =>
|
|
213
|
+
targetVariant && candidate.variant === targetVariant)
|
|
214
|
+
?? matchingNodes.find((candidate: any) => candidate.variant === node.variant)
|
|
215
|
+
?? matchingNodes[0];
|
|
216
|
+
const id = `control:${node.id}:${index + 1}`;
|
|
217
|
+
if (controls.some((control) => control.label === label && control.destination === destination?.id)) continue;
|
|
218
|
+
controls.push({
|
|
219
|
+
id,
|
|
220
|
+
label,
|
|
221
|
+
kind: normalizedTarget ? "navigation" : "action",
|
|
222
|
+
destination: destination?.id,
|
|
223
|
+
destinationRoute: normalizedTarget,
|
|
224
|
+
source: relativePath,
|
|
225
|
+
line: index + 1,
|
|
226
|
+
evidenceStatus: "source-discovered",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return controls;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Rank a node's claim to being where a user starts.
|
|
234
|
+
*
|
|
235
|
+
* This used to accept a start only when it matched `/onboarding` with an
|
|
236
|
+
* "auth" variant or `/` with a "tabs" variant — conventions from a
|
|
237
|
+
* different (mobile) product. Neither matches a typical web app, so the
|
|
238
|
+
* code fell through to "nodes with no incoming edges", which on this
|
|
239
|
+
* dashboard meant settings pages became the entrances and the real home
|
|
240
|
+
* route was only ever reachable as a goal. Ranked signals instead, lower
|
|
241
|
+
* is better.
|
|
242
|
+
*/
|
|
243
|
+
export function entranceRank(
|
|
244
|
+
node: any,
|
|
245
|
+
context: { declaredEntranceRoutes?: Set<string>; inboundCounts?: Map<string, number> } = {},
|
|
246
|
+
): number {
|
|
247
|
+
const route = String(node?.route ?? "");
|
|
248
|
+
if (context.declaredEntranceRoutes?.has(route)) return 0;
|
|
249
|
+
// The product's own conventions stay ahead of generic guesses so the
|
|
250
|
+
// mobile app that relies on them is not regressed.
|
|
251
|
+
if (node?.variant === "auth" && route === "/onboarding") return 1;
|
|
252
|
+
if (node?.variant === "tabs" && route === "/") return 2;
|
|
253
|
+
// A web app's root is where a user lands.
|
|
254
|
+
if (route === "/") return 3;
|
|
255
|
+
const inbound = context.inboundCounts?.get(node?.id) ?? 0;
|
|
256
|
+
// Otherwise prefer whatever the app links to most: a hub beats a leaf.
|
|
257
|
+
if (inbound > 0) return 4 + 1 / (inbound + 1);
|
|
258
|
+
return 6;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function evidenceJourneyCandidates(
|
|
262
|
+
graph: { nodes: any[]; edges: any[] },
|
|
263
|
+
actor: string,
|
|
264
|
+
declaredEntranceRoutes: Set<string> = new Set(),
|
|
265
|
+
): any[] {
|
|
266
|
+
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
267
|
+
const uniqueEdges = new Map<string, any>();
|
|
268
|
+
for (const edge of graph.edges) {
|
|
269
|
+
if (!nodesById.has(edge.from) || !nodesById.has(edge.to)) continue;
|
|
270
|
+
const key = `${edge.from}->${edge.to}`;
|
|
271
|
+
if (!uniqueEdges.has(key)) uniqueEdges.set(key, edge);
|
|
272
|
+
}
|
|
273
|
+
const edges = [...uniqueEdges.values()];
|
|
274
|
+
const incoming = new Set(edges.map((edge) => edge.to));
|
|
275
|
+
const outgoing = new Map<string, any[]>();
|
|
276
|
+
for (const edge of edges) {
|
|
277
|
+
const list = outgoing.get(edge.from) ?? [];
|
|
278
|
+
list.push(edge);
|
|
279
|
+
outgoing.set(edge.from, list);
|
|
280
|
+
}
|
|
281
|
+
const inboundCounts = new Map<string, number>();
|
|
282
|
+
for (const edge of edges) inboundCounts.set(edge.to, (inboundCounts.get(edge.to) ?? 0) + 1);
|
|
283
|
+
const rankContext = { declaredEntranceRoutes, inboundCounts };
|
|
284
|
+
// A node only counts as a real entrance if some signal says so; the
|
|
285
|
+
// "nothing links to it" fallback is kept last rather than first, because
|
|
286
|
+
// on a real app that selects leaf pages.
|
|
287
|
+
const ranked = graph.nodes
|
|
288
|
+
.filter((node) => node.route !== "/callback")
|
|
289
|
+
.map((node) => ({ node, rank: entranceRank(node, rankContext) }))
|
|
290
|
+
.sort((left, right) => left.rank - right.rank);
|
|
291
|
+
const bestRank = ranked[0]?.rank ?? 6;
|
|
292
|
+
// Only the best tier starts journeys. Widening this to "anything
|
|
293
|
+
// reasonably entrance-like" makes every page in a meshed navigation an
|
|
294
|
+
// entrance, which multiplies out into hundreds of near-duplicate flows.
|
|
295
|
+
const preferred = bestRank < 6
|
|
296
|
+
? ranked.filter((entry) => entry.rank === bestRank).map((entry) => entry.node)
|
|
297
|
+
: graph.nodes.filter((node) => !incoming.has(node.id) && node.route !== "/callback");
|
|
298
|
+
// A start with nothing leading out of it cannot yield a journey. If the
|
|
299
|
+
// best-ranked entrances are all dead ends — which happens when a page
|
|
300
|
+
// renders an empty shell and its navigation gets attributed elsewhere —
|
|
301
|
+
// fall back to the nodes that do have outgoing navigation, in rank
|
|
302
|
+
// order, rather than producing no journeys at all.
|
|
303
|
+
const hasOutgoing = (node: any) => (outgoing.get(node.id) ?? []).length > 0;
|
|
304
|
+
const startNodes = preferred.some(hasOutgoing)
|
|
305
|
+
? preferred
|
|
306
|
+
: ranked.map((entry) => entry.node).filter(hasOutgoing);
|
|
307
|
+
const candidates: any[] = [];
|
|
308
|
+
|
|
309
|
+
for (const start of startNodes) {
|
|
310
|
+
const paths: Array<{ nodeIds: string[]; edgeIds: string[] }> = [];
|
|
311
|
+
const walk = (nodeId: string, nodeIds: string[], edgeIds: string[], depth: number) => {
|
|
312
|
+
const nextEdges = (outgoing.get(nodeId) ?? [])
|
|
313
|
+
.filter((edge) => !nodeIds.includes(edge.to))
|
|
314
|
+
.filter((edge) => {
|
|
315
|
+
const target = nodesById.get(edge.to);
|
|
316
|
+
return !(target?.variant === "auth" && target.route === "/onboarding")
|
|
317
|
+
|| nodesById.get(nodeId)?.variant === "auth";
|
|
318
|
+
})
|
|
319
|
+
.slice(0, 12);
|
|
320
|
+
// Every screen reached along the way is a destination in its own
|
|
321
|
+
// right. Recording only the maximal path meant that in a meshed
|
|
322
|
+
// navigation each journey wandered through six screens to reach one,
|
|
323
|
+
// even where the entrance links to it directly; pairing this with
|
|
324
|
+
// shortest-path-per-destination yields the direct route instead.
|
|
325
|
+
if (edgeIds.length) paths.push({ nodeIds, edgeIds });
|
|
326
|
+
if (!nextEdges.length || depth >= 6) return;
|
|
327
|
+
for (const edge of nextEdges) {
|
|
328
|
+
walk(edge.to, [...nodeIds, edge.to], [...edgeIds, edge.id], depth + 1);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
walk(start.id, [start.id], [], 0);
|
|
332
|
+
// One journey per destination, by the shortest route to it.
|
|
333
|
+
//
|
|
334
|
+
// A real app's navigation is a mesh — every page in the sidebar links
|
|
335
|
+
// to every other — so enumerating every distinct path produced ~200
|
|
336
|
+
// near-identical journeys with the same endpoints. What a user
|
|
337
|
+
// actually does is "from the entrance, get to X", so keep the shortest
|
|
338
|
+
// path to each destination and drop the permutations of it.
|
|
339
|
+
const uniquePaths = new Map<string, { nodeIds: string[]; edgeIds: string[] }>();
|
|
340
|
+
for (const path of paths) {
|
|
341
|
+
const destination = path.nodeIds[path.nodeIds.length - 1];
|
|
342
|
+
const existing = uniquePaths.get(destination);
|
|
343
|
+
if (!existing || path.nodeIds.length < existing.nodeIds.length) {
|
|
344
|
+
uniquePaths.set(destination, path);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
for (const path of [...uniquePaths.values()].slice(0, 24)) {
|
|
348
|
+
const nodes = path.nodeIds.map((id) => nodesById.get(id)).filter(Boolean);
|
|
349
|
+
const end = nodes[nodes.length - 1];
|
|
350
|
+
const startRoute = start.route ?? start.id;
|
|
351
|
+
const endRoute = end?.route ?? startRoute;
|
|
352
|
+
const variantSuffix = start.variant ? `-${slugPart(start.variant)}` : "";
|
|
353
|
+
const pathHash = createHash("sha1").update(path.nodeIds.join("|")).digest("hex").slice(0, 8);
|
|
354
|
+
const startLabel = start.variant === "auth"
|
|
355
|
+
? "Complete account onboarding"
|
|
356
|
+
: start.variant === "tabs"
|
|
357
|
+
? "Open the member home"
|
|
358
|
+
: semanticScreenTitle(startRoute);
|
|
359
|
+
candidates.push({
|
|
360
|
+
id: `flow:${slugPart(startRoute)}${variantSuffix}-to-${slugPart(endRoute)}-${pathHash}`,
|
|
361
|
+
name: `${startLabel} → ${semanticScreenTitle(endRoute)}`,
|
|
362
|
+
description: "User journey generated from source-discovered entry, transitions, and goal screen.",
|
|
363
|
+
actor,
|
|
364
|
+
startNodeId: start.id,
|
|
365
|
+
nodeIds: path.nodeIds,
|
|
366
|
+
edgeIds: path.edgeIds,
|
|
367
|
+
revision: 1,
|
|
368
|
+
status: "draft",
|
|
369
|
+
metadata: {
|
|
370
|
+
generatedBy: "source-exploration",
|
|
371
|
+
evidenceStatus: "source-transition",
|
|
372
|
+
flowScope: "path",
|
|
373
|
+
verificationStatus: "unverified",
|
|
374
|
+
verifiedNodeIds: [],
|
|
375
|
+
verifiedEdgeIds: [],
|
|
376
|
+
verifiedControlIds: [],
|
|
377
|
+
goalNodeId: end?.id,
|
|
378
|
+
entrance: { route: start.route, variant: start.variant },
|
|
379
|
+
goal: { route: end?.route, nodeId: end?.id },
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return candidates;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
type RoutesManifestEntry = { route: string; title?: string; source?: string };
|
|
388
|
+
type RoutesManifestLink = { from: string; to: string; action?: string };
|
|
389
|
+
type RoutesManifest = { routes?: RoutesManifestEntry[]; links?: RoutesManifestLink[] };
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Best-effort escape hatch for target apps that don't use Next.js-style
|
|
393
|
+
* `app/`/`pages/` file routing (the only convention the source-tree walk
|
|
394
|
+
* above understands). A project can declare its screens/links explicitly at
|
|
395
|
+
* `<spec_dir>/routes.manifest.json`; entries are merged in without touching
|
|
396
|
+
* file-scan discovery, so Next.js projects are unaffected either way.
|
|
397
|
+
*/
|
|
398
|
+
function loadRoutesManifest(specDir: string, projectRoot: string): RoutesManifest {
|
|
399
|
+
for (const candidate of [path.join(specDir, "routes.manifest.json"), path.join(projectRoot, "spec", "routes.manifest.json")]) {
|
|
400
|
+
if (fs.existsSync(candidate)) {
|
|
401
|
+
try {
|
|
402
|
+
return JSON.parse(fs.readFileSync(candidate, "utf8")) as RoutesManifest;
|
|
403
|
+
} catch {
|
|
404
|
+
return {};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return {};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function semanticScreenTitle(route: string): string {
|
|
412
|
+
const titles: Record<string, string> = {
|
|
413
|
+
"/": "Open the app",
|
|
414
|
+
"/onboarding": "Complete account onboarding",
|
|
415
|
+
"/discover": "Discover available clubs",
|
|
416
|
+
"/my-plan": "Review my plan",
|
|
417
|
+
"/profile": "Open my profile",
|
|
418
|
+
"/about": "Read about the app",
|
|
419
|
+
"/account-settings": "Manage account settings",
|
|
420
|
+
"/callback": "Return from authentication",
|
|
421
|
+
"/checkout-webview": "Complete checkout",
|
|
422
|
+
"/club/[clubId]": "View club details",
|
|
423
|
+
"/current-tab": "View the current tab",
|
|
424
|
+
"/item-detail": "View item details",
|
|
425
|
+
"/join-club": "Join a club",
|
|
426
|
+
"/notifications": "Review notifications",
|
|
427
|
+
"/payments": "Review payments",
|
|
428
|
+
"/search": "Search for content",
|
|
429
|
+
"/top-up-credits": "Top up credits",
|
|
430
|
+
};
|
|
431
|
+
if (titles[route]) return titles[route];
|
|
432
|
+
const readableRoute = route
|
|
433
|
+
.replace(/^\/+/, "")
|
|
434
|
+
.replace(/\[[^\]]+\]/g, "item")
|
|
435
|
+
.replace(/[()_]/g, " ")
|
|
436
|
+
.replace(/[-/]+/g, " ")
|
|
437
|
+
.replace(/\s+/g, " ")
|
|
438
|
+
.trim();
|
|
439
|
+
const label = readableRoute
|
|
440
|
+
? readableRoute.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
|
441
|
+
: "the app";
|
|
442
|
+
return `Open ${label}`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Explorer: Discovers screens and actions, builds/updates the Journey Graph
|
|
447
|
+
*/
|
|
448
|
+
export async function explore(cfg: any, options: ExploreOptions): Promise<ExploreResult> {
|
|
449
|
+
try {
|
|
450
|
+
const environmentId = options.environmentId
|
|
451
|
+
?? cfg.project?.environment_id
|
|
452
|
+
?? cfg.project?.environmentId
|
|
453
|
+
?? cfg.runtime?.environment;
|
|
454
|
+
const environment = environmentId ? await getEnvironmentContract(cfg, environmentId) : null;
|
|
455
|
+
const baseUrl = options.baseUrl ?? environment?.baseUrl;
|
|
456
|
+
if (!baseUrl?.trim()) throw new Error("Exploration requires a runnable base URL.");
|
|
457
|
+
const { maxDepth = 5 } = options;
|
|
458
|
+
const specDir = cfg.runtime.spec_dir || "./spec";
|
|
459
|
+
const graphPath = path.join(specDir, "journeys.graph.json");
|
|
460
|
+
const projectRoot = path.resolve(cfg.cursor_driver?.project_directory || process.cwd());
|
|
461
|
+
|
|
462
|
+
// Load existing graph
|
|
463
|
+
let graph: any = { version: 2, journeys: [], nodes: [], edges: [] };
|
|
464
|
+
if (fs.existsSync(graphPath)) {
|
|
465
|
+
const graphContent = fs.readFileSync(graphPath, "utf-8");
|
|
466
|
+
graph = JSON.parse(graphContent);
|
|
467
|
+
}
|
|
468
|
+
graph = repairJourneyGraph({
|
|
469
|
+
version: 2,
|
|
470
|
+
...graph,
|
|
471
|
+
journeys: Array.isArray(graph.journeys) ? graph.journeys : [],
|
|
472
|
+
nodes: Array.isArray(graph.nodes) ? graph.nodes : [],
|
|
473
|
+
edges: Array.isArray(graph.edges) ? graph.edges : [],
|
|
474
|
+
}, options.projectId ?? cfg.project?.id ?? "default");
|
|
475
|
+
const existingJourneys = Array.isArray(graph.journeys) ? graph.journeys : [];
|
|
476
|
+
const hadExistingJourneys = existingJourneys.length > 0;
|
|
477
|
+
|
|
478
|
+
const ignored = new Set([
|
|
479
|
+
"node_modules",
|
|
480
|
+
".git",
|
|
481
|
+
".next",
|
|
482
|
+
".open-next",
|
|
483
|
+
"dist",
|
|
484
|
+
"build",
|
|
485
|
+
"coverage",
|
|
486
|
+
"screenshots",
|
|
487
|
+
"bugmole-runs",
|
|
488
|
+
]);
|
|
489
|
+
const sourceFiles: string[] = [];
|
|
490
|
+
const visit = (directory: string, depth: number) => {
|
|
491
|
+
if (depth > maxDepth) return;
|
|
492
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
493
|
+
if (ignored.has(entry.name)) continue;
|
|
494
|
+
const fullPath = path.join(directory, entry.name);
|
|
495
|
+
if (entry.isDirectory()) visit(fullPath, depth + 1);
|
|
496
|
+
else if (/\.(tsx?|jsx?|vue|svelte)$/.test(entry.name)) sourceFiles.push(fullPath);
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
visit(projectRoot, 0);
|
|
500
|
+
|
|
501
|
+
// Rebuild the sitemap from the current source tree on every run. Keeping
|
|
502
|
+
// old nodes/edges made removed screens look like valid routes.
|
|
503
|
+
const discoveredNodes = new Map<string, any>();
|
|
504
|
+
const nodeBySource = new Map<string, any>();
|
|
505
|
+
const unresolvedLinks: Array<{ source: string; target: string; sourceFile: string }> = [];
|
|
506
|
+
for (const filePath of sourceFiles) {
|
|
507
|
+
const relativePath = path.relative(projectRoot, filePath);
|
|
508
|
+
const parts = relativePath.split(path.sep);
|
|
509
|
+
const appDirIndex = parts.lastIndexOf("app");
|
|
510
|
+
const pagesDirIndex = parts.lastIndexOf("pages");
|
|
511
|
+
const appIndex = Math.max(appDirIndex, pagesDirIndex);
|
|
512
|
+
if (appIndex < 0) continue;
|
|
513
|
+
const fileName = parts[parts.length - 1];
|
|
514
|
+
if (!/\.(?:tsx?|jsx?)$/.test(fileName)) continue;
|
|
515
|
+
const baseName = fileName.replace(/\.(?:tsx?|jsx?)$/, "");
|
|
516
|
+
const routeParts = parts.slice(appIndex + 1, -1);
|
|
517
|
+
// App Router (app/) and Pages Router (pages/) have fundamentally
|
|
518
|
+
// different route conventions and can't share one rule:
|
|
519
|
+
// - App Router: only page.* (a route) and route.* (an API handler)
|
|
520
|
+
// are real, navigable endpoints. Every other convention file
|
|
521
|
+
// (layout/template/loading/error/not-found/default) and, above
|
|
522
|
+
// all, any plain component that merely lives under app/ is NOT
|
|
523
|
+
// its own route. Appending its basename as a route segment
|
|
524
|
+
// invents fake screens like "/devices/devices-panel" for
|
|
525
|
+
// apps/qa/src/app/devices/devices-panel.tsx — a component
|
|
526
|
+
// imported by the real page, not a URL — so headless-Chrome
|
|
527
|
+
// capture then wastes attempts on routes that don't exist.
|
|
528
|
+
// - Pages Router: every file is a route except the framework's own
|
|
529
|
+
// reserved files, and "index" maps to the enclosing directory
|
|
530
|
+
// rather than appending its own segment.
|
|
531
|
+
if (appIndex === appDirIndex) {
|
|
532
|
+
if (!["page", "route"].includes(baseName)) continue;
|
|
533
|
+
} else if (["_app", "_document", "_error", "_middleware"].includes(baseName)) {
|
|
534
|
+
continue;
|
|
535
|
+
} else if (!["page", "route", "index", "_layout"].includes(baseName)) {
|
|
536
|
+
routeParts.push(baseName);
|
|
537
|
+
}
|
|
538
|
+
const route = normalizeRoute(`/${routeParts.join("/")}`);
|
|
539
|
+
const variant = routeVariantFromSegments(routeParts);
|
|
540
|
+
const nodeId = `screen:${route || "/"}${variant ? `@${variant}` : ""}`;
|
|
541
|
+
const existingNode = discoveredNodes.get(nodeId);
|
|
542
|
+
const node = existingNode ?? {
|
|
543
|
+
id: nodeId,
|
|
544
|
+
title: `${semanticScreenTitle(route || "/")}${variant ? ` (${variant})` : ""}`,
|
|
545
|
+
kind: "screen",
|
|
546
|
+
signature: `${projectRoot}@route:${route || "/"}`,
|
|
547
|
+
route: route || "/",
|
|
548
|
+
variant,
|
|
549
|
+
source: relativePath,
|
|
550
|
+
sourcePaths: [],
|
|
551
|
+
};
|
|
552
|
+
node.sourcePaths = [...new Set([...(node.sourcePaths ?? []), relativePath])];
|
|
553
|
+
discoveredNodes.set(nodeId, node);
|
|
554
|
+
nodeBySource.set(relativePath, node);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Manifest routes only fill in screens the file-tree walk didn't already
|
|
558
|
+
// find, so a real app/pages route always wins over a declared one.
|
|
559
|
+
const routesManifest = loadRoutesManifest(specDir, projectRoot);
|
|
560
|
+
for (const entry of routesManifest.routes ?? []) {
|
|
561
|
+
if (!entry?.route) continue;
|
|
562
|
+
const route = normalizeRoute(entry.route);
|
|
563
|
+
const nodeId = `screen:${route || "/"}`;
|
|
564
|
+
if (discoveredNodes.has(nodeId)) continue;
|
|
565
|
+
discoveredNodes.set(nodeId, {
|
|
566
|
+
id: nodeId,
|
|
567
|
+
title: entry.title ?? semanticScreenTitle(route || "/"),
|
|
568
|
+
kind: "screen",
|
|
569
|
+
signature: `${projectRoot}@route:${route || "/"}`,
|
|
570
|
+
route: route || "/",
|
|
571
|
+
source: entry.source ?? "spec/routes.manifest.json",
|
|
572
|
+
sourcePaths: entry.source ? [entry.source] : [],
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
graph.nodes = [...discoveredNodes.values()];
|
|
576
|
+
// Resolve links after all route files are known so source order cannot
|
|
577
|
+
// make a valid link look unresolved.
|
|
578
|
+
const routeNodes = graph.nodes as any[];
|
|
579
|
+
graph.edges = [];
|
|
580
|
+
for (const filePath of sourceFiles) {
|
|
581
|
+
const relativePath = path.relative(projectRoot, filePath);
|
|
582
|
+
const node = nodeBySource.get(relativePath);
|
|
583
|
+
if (!node) continue;
|
|
584
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
585
|
+
// Record which components this screen actually imports. Without a
|
|
586
|
+
// real join the dashboard could only ever say "no components
|
|
587
|
+
// detected"; guessing from names would invent relationships, so the
|
|
588
|
+
// evidence is the import statement itself.
|
|
589
|
+
node.componentPaths = [...new Set([
|
|
590
|
+
...(node.componentPaths ?? []),
|
|
591
|
+
...resolveImportedComponents(filePath, projectRoot, content),
|
|
592
|
+
])];
|
|
593
|
+
const targets = [
|
|
594
|
+
...content.matchAll(/(?:href|to)\s*=\s*["'`]([^"'`]+)["'`]/g),
|
|
595
|
+
...content.matchAll(/(?:router\.(?:push|replace)|navigate)\(\s*["'`]([^"'`]+)["'`]/g),
|
|
596
|
+
];
|
|
597
|
+
for (const [index, match] of targets.entries()) {
|
|
598
|
+
const target = match[1];
|
|
599
|
+
if (!target || target.startsWith("#") || target.startsWith("http")) continue;
|
|
600
|
+
const normalizedTarget = normalizeRoute(target, node.route);
|
|
601
|
+
const targetVariant = routeVariantFromTarget(target);
|
|
602
|
+
const matchingNodes = routeNodes.filter((candidate: any) =>
|
|
603
|
+
routePatternMatches(String(candidate.route), normalizedTarget));
|
|
604
|
+
const targetNode = matchingNodes.find((candidate: any) =>
|
|
605
|
+
targetVariant && candidate.variant === targetVariant)
|
|
606
|
+
?? matchingNodes.find((candidate: any) => candidate.variant === node.variant)
|
|
607
|
+
?? matchingNodes[0];
|
|
608
|
+
if (!targetNode) {
|
|
609
|
+
unresolvedLinks.push({ source: node.route, target: normalizedTarget, sourceFile: relativePath });
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (targetNode.id === node.id) continue;
|
|
613
|
+
graph.edges.push({
|
|
614
|
+
id: `${node.id}->${(targetNode as any).id}:${index}`,
|
|
615
|
+
from: node.id,
|
|
616
|
+
to: (targetNode as any).id,
|
|
617
|
+
action: `navigate:${normalizedTarget}`,
|
|
618
|
+
source: relativePath,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
for (const [index, link] of (routesManifest.links ?? []).entries()) {
|
|
623
|
+
if (!link?.from || !link?.to) continue;
|
|
624
|
+
const fromNode = routeNodes.find((candidate: any) =>
|
|
625
|
+
routePatternMatches(String(candidate.route), normalizeRoute(link.from)));
|
|
626
|
+
const toNode = routeNodes.find((candidate: any) =>
|
|
627
|
+
routePatternMatches(String(candidate.route), normalizeRoute(link.to)));
|
|
628
|
+
if (!fromNode || !toNode) continue;
|
|
629
|
+
const edgeId = `${fromNode.id}->${toNode.id}:manifest-${index}`;
|
|
630
|
+
if (graph.edges.some((edge: any) => edge.id === edgeId)) continue;
|
|
631
|
+
graph.edges.push({
|
|
632
|
+
id: edgeId,
|
|
633
|
+
from: fromNode.id,
|
|
634
|
+
to: toNode.id,
|
|
635
|
+
action: link.action ?? `navigate:${normalizeRoute(link.to)}`,
|
|
636
|
+
source: "spec/routes.manifest.json",
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Read what the project says about itself before crawling it. Docs
|
|
641
|
+
// state intent and priority; a crawl can only show structure. The
|
|
642
|
+
// declared flows are reconciled against the routes that actually
|
|
643
|
+
// exist, so a spec that has outlived its product is reported rather
|
|
644
|
+
// than injected as flows over screens that are not there.
|
|
645
|
+
const understanding = readAppUnderstanding(
|
|
646
|
+
projectRoot,
|
|
647
|
+
specDir,
|
|
648
|
+
routeNodes.map((node: any) => String(node.route ?? "")).filter(Boolean),
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
// Then confirm navigation against the running app. The source scan
|
|
652
|
+
// only sees link literals, so nav built from a route table is
|
|
653
|
+
// invisible to it — on this dashboard that hid the entire primary nav
|
|
654
|
+
// and left only the account dropdown, which is why exploration used to
|
|
655
|
+
// start at a settings page.
|
|
656
|
+
const crawl = await crawlNavigation(
|
|
657
|
+
baseUrl,
|
|
658
|
+
routeNodes.map((node: any) => String(node.route ?? "")).filter(Boolean),
|
|
659
|
+
);
|
|
660
|
+
for (const [fromRoute, links] of crawl.linksByRoute) {
|
|
661
|
+
const fromNode = routeNodes.find((candidate: any) =>
|
|
662
|
+
routePatternMatches(String(candidate.route), fromRoute));
|
|
663
|
+
if (!fromNode) continue;
|
|
664
|
+
for (const [index, link] of links.entries()) {
|
|
665
|
+
const toNode = routeNodes.find((candidate: any) =>
|
|
666
|
+
routePatternMatches(String(candidate.route), link.route));
|
|
667
|
+
if (!toNode || toNode.id === fromNode.id) continue;
|
|
668
|
+
// A transition already proven by source stays as it is; this only
|
|
669
|
+
// adds what the source could not show.
|
|
670
|
+
if (graph.edges.some((edge: any) => edge.from === fromNode.id && edge.to === toNode.id)) continue;
|
|
671
|
+
graph.edges.push({
|
|
672
|
+
id: `${fromNode.id}->${toNode.id}:dom-${index}`,
|
|
673
|
+
from: fromNode.id,
|
|
674
|
+
to: toNode.id,
|
|
675
|
+
action: link.label ? `navigate:${link.label}` : `navigate:${link.route}`,
|
|
676
|
+
label: link.label,
|
|
677
|
+
source: "rendered-dom",
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
for (const filePath of sourceFiles) {
|
|
683
|
+
const relativePath = path.relative(projectRoot, filePath);
|
|
684
|
+
const node = nodeBySource.get(relativePath);
|
|
685
|
+
if (!node) continue;
|
|
686
|
+
const controls = sourceInteractiveControls(
|
|
687
|
+
fs.readFileSync(filePath, "utf8"),
|
|
688
|
+
relativePath,
|
|
689
|
+
node,
|
|
690
|
+
graph.nodes,
|
|
691
|
+
);
|
|
692
|
+
if (controls.length) {
|
|
693
|
+
const existingControls = Array.isArray(node.metadata?.interactiveControls)
|
|
694
|
+
? node.metadata.interactiveControls
|
|
695
|
+
: [];
|
|
696
|
+
node.metadata = {
|
|
697
|
+
...(node.metadata ?? {}),
|
|
698
|
+
interactiveControls: [
|
|
699
|
+
...existingControls,
|
|
700
|
+
...controls.filter((control) => !existingControls.some((item: any) => item.id === control.id)),
|
|
701
|
+
],
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const actor = options.actor?.trim() || "user";
|
|
706
|
+
// Entrances declared by an adopted flow win, because that is the
|
|
707
|
+
// project stating where its own journeys begin.
|
|
708
|
+
const declaredEntranceRoutes = new Set(
|
|
709
|
+
understanding.declaredFlows.adopted
|
|
710
|
+
.map((flow) => flow.routes[0])
|
|
711
|
+
.filter((route): route is string => Boolean(route)),
|
|
712
|
+
);
|
|
713
|
+
const inboundCounts = new Map<string, number>();
|
|
714
|
+
for (const edge of graph.edges as any[]) {
|
|
715
|
+
inboundCounts.set(edge.to, (inboundCounts.get(edge.to) ?? 0) + 1);
|
|
716
|
+
}
|
|
717
|
+
const entranceReason = (node: any, rank: number): string => {
|
|
718
|
+
if (rank === 0) return "declared entrance";
|
|
719
|
+
if (rank === 1) return "authentication entry";
|
|
720
|
+
if (rank === 2) return "authenticated app entry";
|
|
721
|
+
if (rank === 3) return "application root";
|
|
722
|
+
return `most linked-to screen (${inboundCounts.get(node.id) ?? 0} inbound)`;
|
|
723
|
+
};
|
|
724
|
+
const entrances = graph.nodes
|
|
725
|
+
.filter((node: any) => node.route !== "/callback")
|
|
726
|
+
.map((node: any) => ({ node, rank: entranceRank(node, { declaredEntranceRoutes, inboundCounts }) }))
|
|
727
|
+
.filter((entry: { rank: number }) => entry.rank < 5)
|
|
728
|
+
.sort((left: { rank: number }, right: { rank: number }) => left.rank - right.rank)
|
|
729
|
+
.slice(0, 5)
|
|
730
|
+
.map(({ node, rank }: any) => ({
|
|
731
|
+
nodeId: node.id,
|
|
732
|
+
route: node.route,
|
|
733
|
+
variant: node.variant,
|
|
734
|
+
reason: entranceReason(node, rank),
|
|
735
|
+
}));
|
|
736
|
+
const candidates = evidenceJourneyCandidates(graph, actor, declaredEntranceRoutes);
|
|
737
|
+
const journeyCandidates = candidates.filter((candidate) => candidate.edgeIds.length > 0);
|
|
738
|
+
const trainingCheckpoints = graph.nodes.map((node: any) => ({
|
|
739
|
+
id: `checkpoint:${slugPart(node.route ?? node.id)}${node.variant ? `-${slugPart(node.variant)}` : ""}`,
|
|
740
|
+
nodeId: node.id,
|
|
741
|
+
route: node.route,
|
|
742
|
+
}));
|
|
743
|
+
const journeysRetired = existingJourneys
|
|
744
|
+
.filter((journey: any) =>
|
|
745
|
+
String(journey.id).startsWith("checkpoint:")
|
|
746
|
+
|| String(journey.id).startsWith("suggested:")
|
|
747
|
+
|| String(journey.id) === "discovered-flow"
|
|
748
|
+
|| String(journey.id).startsWith("explored:")
|
|
749
|
+
|| String(journey.id).startsWith("flow:")
|
|
750
|
+
&& (journey.metadata?.flowScope !== "path"))
|
|
751
|
+
.map((journey: any) => journey.id);
|
|
752
|
+
const retainedExistingJourneys = existingJourneys.filter(
|
|
753
|
+
(journey: any) => !journeysRetired.includes(journey.id),
|
|
754
|
+
);
|
|
755
|
+
const existingById = new Map<string, any>(
|
|
756
|
+
retainedExistingJourneys.map((journey: any) => [journey.id, journey]),
|
|
757
|
+
);
|
|
758
|
+
const journeysReviewed: NonNullable<ExploreResult["journeysReviewed"]> = existingJourneys.map((journey: any) => ({
|
|
759
|
+
id: journey.id,
|
|
760
|
+
revision: journey.revision ?? 1,
|
|
761
|
+
nodeCount: Array.isArray(journey.nodeIds) ? journey.nodeIds.length : 0,
|
|
762
|
+
edgeCount: Array.isArray(journey.edgeIds) ? journey.edgeIds.length : 0,
|
|
763
|
+
review: journeysRetired.includes(journey.id) ? "retired" : "preserved",
|
|
764
|
+
}));
|
|
765
|
+
const journeysAdded: string[] = [];
|
|
766
|
+
const reviewedJourneys = retainedExistingJourneys.map((journey: any) => ({
|
|
767
|
+
...journey,
|
|
768
|
+
metadata: {
|
|
769
|
+
...(journey.metadata ?? {}),
|
|
770
|
+
lastReviewedAt: new Date().toISOString(),
|
|
771
|
+
reviewStatus: "preserved",
|
|
772
|
+
...(journey.metadata?.flowScope === "path"
|
|
773
|
+
? {
|
|
774
|
+
verificationStatus: journey.metadata?.verificationStatus ?? "unverified",
|
|
775
|
+
verifiedNodeIds: Array.isArray(journey.metadata?.verifiedNodeIds)
|
|
776
|
+
? journey.metadata.verifiedNodeIds
|
|
777
|
+
: [],
|
|
778
|
+
verifiedEdgeIds: Array.isArray(journey.metadata?.verifiedEdgeIds)
|
|
779
|
+
? journey.metadata.verifiedEdgeIds
|
|
780
|
+
: [],
|
|
781
|
+
verifiedControlIds: Array.isArray(journey.metadata?.verifiedControlIds)
|
|
782
|
+
? journey.metadata.verifiedControlIds
|
|
783
|
+
: [],
|
|
784
|
+
}
|
|
785
|
+
: {}),
|
|
786
|
+
},
|
|
787
|
+
}));
|
|
788
|
+
const coveredEdges = new Set(
|
|
789
|
+
reviewedJourneys.flatMap((journey: any) => Array.isArray(journey.edgeIds) ? journey.edgeIds : []),
|
|
790
|
+
);
|
|
791
|
+
const coveredNodes = new Set(
|
|
792
|
+
reviewedJourneys.flatMap((journey: any) => Array.isArray(journey.nodeIds) ? journey.nodeIds : []),
|
|
793
|
+
);
|
|
794
|
+
const establishedStarts = new Set(
|
|
795
|
+
reviewedJourneys.map((journey: any) => journey.startNodeId).filter(Boolean),
|
|
796
|
+
);
|
|
797
|
+
const newEntranceIds = new Set(
|
|
798
|
+
journeyCandidates
|
|
799
|
+
.filter((candidate) => !establishedStarts.has(candidate.startNodeId))
|
|
800
|
+
.map((candidate) => candidate.startNodeId),
|
|
801
|
+
);
|
|
802
|
+
const generatedJourneys = [...reviewedJourneys];
|
|
803
|
+
for (const candidate of journeyCandidates) {
|
|
804
|
+
const existing = existingById.get(candidate.id);
|
|
805
|
+
if (existing) continue;
|
|
806
|
+
const addsEvidence = candidate.edgeIds.some((edgeId: string) => !coveredEdges.has(edgeId))
|
|
807
|
+
|| candidate.nodeIds.some((nodeId: string) => !coveredNodes.has(nodeId))
|
|
808
|
+
|| newEntranceIds.has(candidate.startNodeId);
|
|
809
|
+
if (!addsEvidence && hadExistingJourneys) continue;
|
|
810
|
+
generatedJourneys.push(candidate);
|
|
811
|
+
journeysAdded.push(candidate.id);
|
|
812
|
+
for (const edgeId of candidate.edgeIds) coveredEdges.add(edgeId);
|
|
813
|
+
for (const nodeId of candidate.nodeIds) coveredNodes.add(nodeId);
|
|
814
|
+
}
|
|
815
|
+
if (generatedJourneys.length === 0 && trainingCheckpoints.length) {
|
|
816
|
+
generatedJourneys.push({
|
|
817
|
+
id: "training:screen-review",
|
|
818
|
+
name: "Source screen review training",
|
|
819
|
+
description: "Review discovered screens individually because no source navigation transitions were found.",
|
|
820
|
+
actor,
|
|
821
|
+
startNodeId: trainingCheckpoints[0].nodeId,
|
|
822
|
+
nodeIds: trainingCheckpoints.map((checkpoint: { nodeId: string }) => checkpoint.nodeId),
|
|
823
|
+
edgeIds: [],
|
|
824
|
+
revision: 1,
|
|
825
|
+
status: "draft",
|
|
826
|
+
metadata: {
|
|
827
|
+
generatedBy: "source-exploration",
|
|
828
|
+
evidenceStatus: "source-only",
|
|
829
|
+
trainingCheckpoints,
|
|
830
|
+
},
|
|
831
|
+
});
|
|
832
|
+
journeysAdded.push("training:screen-review");
|
|
833
|
+
}
|
|
834
|
+
// Journeys this pass newly discovered are immediately canonical: the
|
|
835
|
+
// graph-first Overview reads only stability:"stable"+linkedToGraph
|
|
836
|
+
// journeys (journey.ts canonicalJourneys), and gating that behind a
|
|
837
|
+
// separate manual bugmole_journey_stabilize call left the flagship page
|
|
838
|
+
// empty after every real exploration run. Retained/reviewed journeys are
|
|
839
|
+
// left untouched since they may be manually-curated editor drafts
|
|
840
|
+
// (stability "discovered"/"stabilizing") that must keep requiring the
|
|
841
|
+
// manual gate.
|
|
842
|
+
graph.journeys = generatedJourneys.map((journey: any) =>
|
|
843
|
+
journeysAdded.includes(journey.id)
|
|
844
|
+
? { ...journey, sourceOfTruth: "canonical_flow", stability: "stable", linkedToGraph: true }
|
|
845
|
+
: journey
|
|
846
|
+
);
|
|
847
|
+
const primaryJourneyId =
|
|
848
|
+
options.journeyId && graph.journeys.some((journey: any) => journey.id === options.journeyId)
|
|
849
|
+
? options.journeyId
|
|
850
|
+
: graph.journeys.find((journey: any) => journey.edgeIds?.length > 0)?.id
|
|
851
|
+
?? graph.journeys[0]?.id
|
|
852
|
+
?? "training-graph";
|
|
853
|
+
graph.metadata = {
|
|
854
|
+
...(graph.metadata ?? {}),
|
|
855
|
+
// What the project says it is, in its own words, so later stages can
|
|
856
|
+
// reason about intent rather than only structure.
|
|
857
|
+
application: understanding.application,
|
|
858
|
+
declaredFlows: {
|
|
859
|
+
adopted: understanding.declaredFlows.adopted.map((flow) => ({
|
|
860
|
+
id: flow.id,
|
|
861
|
+
name: flow.name,
|
|
862
|
+
description: flow.description,
|
|
863
|
+
routes: flow.routes,
|
|
864
|
+
source: flow.source,
|
|
865
|
+
})),
|
|
866
|
+
// Surfaced, not silently dropped: a spec describing screens this
|
|
867
|
+
// app does not have is a real signal that it belongs to another
|
|
868
|
+
// product or has drifted.
|
|
869
|
+
stale: understanding.declaredFlows.stale,
|
|
870
|
+
},
|
|
871
|
+
navigationCrawl: {
|
|
872
|
+
visited: crawl.visited.length,
|
|
873
|
+
failed: crawl.failed.length,
|
|
874
|
+
// Coverage is reported so a near-empty crawl reads as "the app was
|
|
875
|
+
// not reachable" rather than "the app has no navigation".
|
|
876
|
+
failures: crawl.failed.slice(0, 10),
|
|
877
|
+
},
|
|
878
|
+
journeyReview: {
|
|
879
|
+
mode: retainedExistingJourneys.length > 0 ? "review-and-add" : "initial-training-graph",
|
|
880
|
+
reviewed: journeysReviewed,
|
|
881
|
+
added: journeysAdded,
|
|
882
|
+
retired: journeysRetired,
|
|
883
|
+
candidateCount: journeyCandidates.length,
|
|
884
|
+
trainingCheckpoints,
|
|
885
|
+
entrances,
|
|
886
|
+
},
|
|
887
|
+
};
|
|
888
|
+
graph.version = 2;
|
|
889
|
+
const routeProbeEntries = await Promise.all(graph.nodes.map(async (node: any) => [
|
|
890
|
+
node.route,
|
|
891
|
+
await probeRoute(baseUrl, node.route),
|
|
892
|
+
] as const));
|
|
893
|
+
const routeProbes = Object.fromEntries(routeProbeEntries);
|
|
894
|
+
graph.nodes = graph.nodes.map((node: any) => ({
|
|
895
|
+
...node,
|
|
896
|
+
runtimeProbe: routeProbes[node.route] ?? { status: "not_probed" },
|
|
897
|
+
}));
|
|
898
|
+
graph.projectId = options.projectId ?? cfg.project?.id ?? "default";
|
|
899
|
+
graph.appId = options.appId ?? cfg.project?.app_id ?? cfg.project?.appId;
|
|
900
|
+
graph.environmentId = options.environmentId ?? cfg.project?.environment_id ?? cfg.project?.environmentId;
|
|
901
|
+
graph.metadata = {
|
|
902
|
+
...(graph.metadata ?? {}),
|
|
903
|
+
discoveredAt: new Date().toISOString(),
|
|
904
|
+
baseUrl,
|
|
905
|
+
projectRoot,
|
|
906
|
+
sourceFiles: sourceFiles.length,
|
|
907
|
+
access: {
|
|
908
|
+
baseUrl,
|
|
909
|
+
environmentId: graph.environmentId,
|
|
910
|
+
mockUrl: environment?.mockUrl,
|
|
911
|
+
method: options.accessMethod ?? (environment?.access.transport === "adb" ? "handoff" : "configured"),
|
|
912
|
+
transport: environment?.access.transport ?? "unknown",
|
|
913
|
+
deviceLabel: environment?.access.deviceLabel,
|
|
914
|
+
startCommand: environment?.access.startCommand,
|
|
915
|
+
authState: Object.values(routeProbes).some((probe) => probe.status === "blocked")
|
|
916
|
+
? "partially_authenticated"
|
|
917
|
+
: "unknown",
|
|
918
|
+
lastVerifiedAt: new Date().toISOString(),
|
|
919
|
+
credentialPresent: false,
|
|
920
|
+
},
|
|
921
|
+
analysis: {
|
|
922
|
+
mode: "source-routing-plus-runtime-probes",
|
|
923
|
+
staticSourceEvidence: true,
|
|
924
|
+
runtimeRouteValidation: true,
|
|
925
|
+
unresolvedLinks,
|
|
926
|
+
routeProbes,
|
|
927
|
+
staleGraphEntriesRemoved: true,
|
|
928
|
+
semanticTitles: "route-derived",
|
|
929
|
+
},
|
|
930
|
+
};
|
|
931
|
+
if (options.captureScreenshots ?? cfg.journey_evidence?.capture_screenshots ?? true) {
|
|
932
|
+
const store = createProjectObjectStore(cfg);
|
|
933
|
+
graph = await captureJourneyScreenshots(graph, {
|
|
934
|
+
projectId: cfg.project?.id ?? "default",
|
|
935
|
+
journeyId: primaryJourneyId,
|
|
936
|
+
store,
|
|
937
|
+
sources: cfg.journey_evidence?.sources ?? (await listReferenceInputs({
|
|
938
|
+
project: { id: cfg.project?.id ?? "default" },
|
|
939
|
+
storage: cfg.storage,
|
|
940
|
+
runtime: { artifacts_dir: cfg.runtime.artifacts_dir },
|
|
941
|
+
})).map((input) => ({
|
|
942
|
+
kind: "reference" as const,
|
|
943
|
+
location: input.location,
|
|
944
|
+
active: input.status !== "archived",
|
|
945
|
+
related: !input.relatedJourneyIds?.length || input.relatedJourneyIds.includes(primaryJourneyId),
|
|
946
|
+
referenceId: input.id,
|
|
947
|
+
viewport: input.viewport,
|
|
948
|
+
})),
|
|
949
|
+
captureReferenceUrl: cfg.journey_evidence?.captureReferenceUrl,
|
|
950
|
+
captureRuntime: cfg.journey_evidence?.captureRuntime
|
|
951
|
+
?? ((node: any) => captureUrlWithLocalBrowser(
|
|
952
|
+
new URL(node.route ?? "/", baseUrl).toString(),
|
|
953
|
+
cfg.journey_evidence?.viewport,
|
|
954
|
+
)),
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
fs.mkdirSync(specDir, { recursive: true });
|
|
958
|
+
fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2) + "\n", "utf8");
|
|
959
|
+
const store = createProjectObjectStore(cfg);
|
|
960
|
+
await store.put(
|
|
961
|
+
`${projectPrefix(cfg.project?.id ?? "default")}/journeys/graph.json`,
|
|
962
|
+
JSON.stringify(graph, null, 2) + "\n",
|
|
963
|
+
"application/json",
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
const selectedJourneyId = primaryJourneyId;
|
|
967
|
+
const nextTask = await nextJourneyTaskWithClarification(cfg, selectedJourneyId);
|
|
968
|
+
const interactiveControlsDiscovered = graph.nodes.reduce(
|
|
969
|
+
(count: number, node: any) => count + (
|
|
970
|
+
Array.isArray(node.metadata?.interactiveControls) ? node.metadata.interactiveControls.length : 0
|
|
971
|
+
),
|
|
972
|
+
0,
|
|
973
|
+
);
|
|
974
|
+
const result: ExploreResult = {
|
|
975
|
+
status: graph.nodes.length > 0 ? "success" : "error",
|
|
976
|
+
screensDiscovered: graph.nodes.length,
|
|
977
|
+
actionsDiscovered: graph.edges.length,
|
|
978
|
+
interactiveControlsDiscovered,
|
|
979
|
+
journeyGraphUpdated: graph.nodes.length > 0,
|
|
980
|
+
journeysReviewed,
|
|
981
|
+
journeysAdded,
|
|
982
|
+
journeysRetired,
|
|
983
|
+
entrancesDiscovered: entrances,
|
|
984
|
+
message:
|
|
985
|
+
graph.nodes.length > 0
|
|
986
|
+
? `Codebase exploration discovered ${graph.nodes.length} screens, ${graph.edges.length} navigable actions, and ${interactiveControlsDiscovered} interactive controls. Review the graph and verify the returned flow task before planning.`
|
|
987
|
+
: `No application entrypoints were discovered under ${projectRoot}.`,
|
|
988
|
+
nextSteps:
|
|
989
|
+
graph.nodes.length > 0
|
|
990
|
+
? [
|
|
991
|
+
"Read bugmole://spec/journeys.graph.json and inspect the current flow",
|
|
992
|
+
`Call bugmole_journey_next with journeyId=${selectedJourneyId}`,
|
|
993
|
+
"Perform and record the returned task, then repeat until exhausted or blocked",
|
|
994
|
+
]
|
|
995
|
+
: [
|
|
996
|
+
"Inspect the repository entrypoint and application directory",
|
|
997
|
+
"Start the application if browser exploration is required",
|
|
998
|
+
"Retry bugmole_explore with the correct project directory and base URL",
|
|
999
|
+
],
|
|
1000
|
+
nextTask,
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
return result;
|
|
1004
|
+
} catch (error: any) {
|
|
1005
|
+
return {
|
|
1006
|
+
status: "error",
|
|
1007
|
+
screensDiscovered: 0,
|
|
1008
|
+
actionsDiscovered: 0,
|
|
1009
|
+
journeyGraphUpdated: false,
|
|
1010
|
+
error: error.message || "Exploration failed",
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
}
|