@topogram/cli 0.3.76 → 0.3.77

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.76",
3
+ "version": "0.3.77",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -741,7 +741,18 @@ export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact
741
741
  mapping_suggestions: [{
742
742
  type: "manual_project_config_review",
743
743
  id: seam.id_hint || seam.seam_id,
744
- reason: "Review the inferred database migration strategy before editing topogram.project.json."
744
+ reason: "Review the inferred database migration strategy before editing topogram.project.json.",
745
+ project_config_target: seam.project_config_target || {
746
+ file: "topogram.project.json",
747
+ path: "topology.runtimes[].migration"
748
+ },
749
+ proposed_runtime_migration: seam.proposed_runtime_migration || null,
750
+ missing_decisions: seam.missing_decisions || [],
751
+ evidence: seam.evidence || seam.provenance || [],
752
+ manual_next_steps: seam.manual_next_steps || [
753
+ "Review evidence and missing decisions.",
754
+ "Manually copy the accepted migration strategy into topogram.project.json."
755
+ ]
745
756
  }],
746
757
  available_actions: ADOPTION_STATE_VOCABULARY
747
758
  }));
@@ -115,6 +115,7 @@ export function importCandidateCounts(summary) {
115
115
  apiRoutes: candidates.api?.routes?.length || 0,
116
116
  uiScreens: candidates.ui?.screens?.length || 0,
117
117
  uiRoutes: candidates.ui?.routes?.length || 0,
118
+ uiFlows: candidates.ui?.flows?.length || 0,
118
119
  uiWidgets: candidates.ui?.widgets?.length || candidates.ui?.components?.length || 0,
119
120
  uiShapes: candidates.ui?.shapes?.length || 0,
120
121
  cliCommands: candidates.cli?.commands?.length || 0,
package/src/cli.js CHANGED
@@ -63,18 +63,18 @@ const inputPath = commandArgs && Object.prototype.hasOwnProperty.call(commandArg
63
63
  const cliOptions = parseCliOptions(args, commandArgs);
64
64
 
65
65
  try {
66
- process.exit(await runCliDispatch({
66
+ process.exitCode = await runCliDispatch({
67
67
  args,
68
68
  commandArgs,
69
69
  inputPath,
70
70
  cliOptions,
71
71
  executablePath: path.resolve(process.argv[1] || fileURLToPath(import.meta.url))
72
- }));
72
+ });
73
73
  } catch (error) {
74
74
  if (error.validation) {
75
75
  console.error(formatValidationErrors(error.validation));
76
76
  } else {
77
77
  console.error(error.message);
78
78
  }
79
- process.exit(1);
79
+ process.exitCode = 1;
80
80
  }
@@ -284,6 +284,7 @@ export function normalizeCandidatesForTrack(track, candidates) {
284
284
  screens: dedupeCandidateRecords(candidates.screens || [], idHint),
285
285
  routes: dedupeCandidateRecords(candidates.routes || [], idHint),
286
286
  actions: dedupeCandidateRecords(candidates.actions || [], idHint),
287
+ flows: dedupeCandidateRecords(candidates.flows || [], idHint),
287
288
  widgets,
288
289
  shapes: dedupeCandidateRecords([...(candidates.shapes || []), ...eventShapes], idHint),
289
290
  stacks: [...new Set(candidates.stacks || [])].sort()
@@ -330,6 +331,7 @@ export function enrichUiWidgetDataSources(uiCandidates, allCandidates) {
330
331
  const { components, ...canonicalCandidates } = uiCandidates;
331
332
  return {
332
333
  ...canonicalCandidates,
334
+ flows: dedupeCandidateRecords(canonicalCandidates.flows || [], (/** @type {any} */ flow) => flow.id_hint),
333
335
  widgets: widgets.map((widget) => {
334
336
  const dataSource = inferredDataSourceForWidget(widget, allCandidates);
335
337
  const dataProp = widget.data_prop || "rows";
@@ -10,9 +10,12 @@ import { uiWidgetCandidates } from "./candidates.js";
10
10
  */
11
11
  export function reportMarkdown(track, candidates) {
12
12
  if (track === "db") {
13
- const seamLines = (candidates.maintained_seams || []).map((/** @type {any} */ seam) =>
14
- `- \`${seam.id_hint}\` tool \`${seam.tool}\` confidence ${seam.confidence || "unknown"} schema \`${seam.schemaPath || "none"}\` migrations \`${seam.migrationsPath || "review-required"}\` missing decisions ${(seam.missing_decisions || []).length}`
15
- );
13
+ const seamLines = (candidates.maintained_seams || []).flatMap((/** @type {any} */ seam) => [
14
+ `- \`${seam.id_hint}\` tool \`${seam.tool}\` confidence ${seam.confidence || "unknown"} schema \`${seam.schemaPath || "none"}\` migrations \`${seam.migrationsPath || "review-required"}\` missing decisions ${(seam.missing_decisions || []).length}`,
15
+ ` - project config target: \`${seam.project_config_target?.path || "topology.runtimes[].migration"}\``,
16
+ ` - evidence: ${(seam.evidence || []).slice(0, 3).map((/** @type {string} */ item) => `\`${item}\``).join(", ") || "_none_"}`,
17
+ ` - manual next: ${(seam.manual_next_steps || []).slice(0, 2).join(" ")}`
18
+ ]);
16
19
  return ensureTrailingNewline(
17
20
  `# DB Import Report\n\n- Entities: ${candidates.entities.length}\n- Enums: ${candidates.enums.length}\n- Relations: ${candidates.relations.length}\n- Indexes: ${candidates.indexes.length}\n- Maintained DB migration seams: ${(candidates.maintained_seams || []).length}\n\n## Maintained DB Migration Seam Candidates\n\n${seamLines.length ? seamLines.join("\n") : "- none"}\n`
18
21
  );
@@ -25,11 +28,15 @@ export function reportMarkdown(track, candidates) {
25
28
  if (track === "ui") {
26
29
  const widgets = uiWidgetCandidates(candidates);
27
30
  const shapes = candidates.shapes || [];
31
+ const flows = candidates.flows || [];
28
32
  const widgetLines = widgets.map((widget) =>
29
33
  `- \`${widget.id_hint}\` confidence ${widget.confidence || "unknown"} pattern \`${widget.pattern || widget.inferred_pattern || "unknown"}\` region \`${widget.region || widget.inferred_region || "unknown"}\` events ${(widget.inferred_events || []).length} evidence ${(widget.evidence || widget.provenance || []).length} missing decisions ${(widget.missing_decisions || []).length}`
30
34
  );
35
+ const flowLines = flows.map((/** @type {any} */ flow) =>
36
+ `- \`${flow.id_hint}\` type \`${flow.flow_type}\` confidence ${flow.confidence || "unknown"} routes ${(flow.route_paths || []).map((/** @type {string} */ route) => `\`${route}\``).join(", ") || "_none_"} missing decisions ${(flow.missing_decisions || []).length}`
37
+ );
31
38
  return ensureTrailingNewline(
32
- `# UI Import Report\n\n- Screens: ${candidates.screens.length}\n- Routes: ${candidates.routes.length}\n- Actions: ${candidates.actions.length}\n- Widgets: ${widgets.length}\n- Event payload shapes: ${shapes.length}\n- Stacks: ${candidates.stacks.length ? candidates.stacks.join(", ") : "none"}\n\n## Widget Candidates\n\n${widgetLines.length ? widgetLines.join("\n") : "- none"}\n\n## Next Validation\n\n- Review candidates under \`topo/candidates/app/ui/drafts/widgets/**\`.\n- Run \`topogram import plan <path>\` before adoption.\n- After adoption, run \`topogram check <path>\`, \`topogram widget check <path>\`, and \`topogram widget behavior <path>\`.\n`
39
+ `# UI Import Report\n\n- Screens: ${candidates.screens.length}\n- Routes: ${candidates.routes.length}\n- Actions: ${candidates.actions.length}\n- Flow candidates: ${flows.length}\n- Widgets: ${widgets.length}\n- Event payload shapes: ${shapes.length}\n- Stacks: ${candidates.stacks.length ? candidates.stacks.join(", ") : "none"}\n\n## Flow Candidates\n\n${flowLines.length ? flowLines.join("\n") : "- none"}\n\n## Widget Candidates\n\n${widgetLines.length ? widgetLines.join("\n") : "- none"}\n\n## Next Validation\n\n- Review flow candidates in \`topo/candidates/app/ui/candidates.json\` before adding shared UI contract behavior.\n- Review candidates under \`topo/candidates/app/ui/drafts/widgets/**\`.\n- Run \`topogram import plan <path>\` before adoption.\n- After adoption, run \`topogram check <path>\`, \`topogram widget check <path>\`, and \`topogram widget behavior <path>\`.\n`
33
40
  );
34
41
  }
35
42
  if (track === "cli") {
@@ -57,6 +64,6 @@ export function reportMarkdown(track, candidates) {
57
64
  */
58
65
  export function appReportMarkdown(candidates, tracks) {
59
66
  return ensureTrailingNewline(
60
- `# App Import Report\n\nTracks: ${tracks.join(", ")}\n\n## DB\n\n- Entities: ${candidates.db?.entities?.length || 0}\n- Enums: ${candidates.db?.enums?.length || 0}\n- Relations: ${candidates.db?.relations?.length || 0}\n- Maintained DB migration seams: ${candidates.db?.maintained_seams?.length || 0}\n\n## API\n\n- Capabilities: ${candidates.api?.capabilities?.length || 0}\n- Routes: ${candidates.api?.routes?.length || 0}\n- Stacks: ${candidates.api?.stacks?.length ? candidates.api.stacks.join(", ") : "none"}\n\n## UI\n\n- Screens: ${candidates.ui?.screens?.length || 0}\n- Routes: ${candidates.ui?.routes?.length || 0}\n- Actions: ${candidates.ui?.actions?.length || 0}\n- Widgets: ${uiWidgetCandidates(candidates.ui).length}\n- Event payload shapes: ${candidates.ui?.shapes?.length || 0}\n- Stacks: ${candidates.ui?.stacks?.length ? candidates.ui.stacks.join(", ") : "none"}\n\n## CLI\n\n- Commands: ${candidates.cli?.commands?.length || 0}\n- Capabilities: ${candidates.cli?.capabilities?.length || 0}\n- CLI surfaces: ${candidates.cli?.surfaces?.length || 0}\n\n## Workflows\n\n- Workflows: ${candidates.workflows?.workflows?.length || 0}\n- States: ${candidates.workflows?.workflow_states?.length || 0}\n- Transitions: ${candidates.workflows?.workflow_transitions?.length || 0}\n\n## Verification\n\n- Verifications: ${candidates.verification?.verifications?.length || 0}\n- Scenarios: ${candidates.verification?.scenarios?.length || 0}\n- Frameworks: ${candidates.verification?.frameworks?.length ? candidates.verification.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.verification?.scripts?.length || 0}\n`
67
+ `# App Import Report\n\nTracks: ${tracks.join(", ")}\n\n## DB\n\n- Entities: ${candidates.db?.entities?.length || 0}\n- Enums: ${candidates.db?.enums?.length || 0}\n- Relations: ${candidates.db?.relations?.length || 0}\n- Maintained DB migration seams: ${candidates.db?.maintained_seams?.length || 0}\n\n## API\n\n- Capabilities: ${candidates.api?.capabilities?.length || 0}\n- Routes: ${candidates.api?.routes?.length || 0}\n- Stacks: ${candidates.api?.stacks?.length ? candidates.api.stacks.join(", ") : "none"}\n\n## UI\n\n- Screens: ${candidates.ui?.screens?.length || 0}\n- Routes: ${candidates.ui?.routes?.length || 0}\n- Actions: ${candidates.ui?.actions?.length || 0}\n- Flow candidates: ${candidates.ui?.flows?.length || 0}\n- Widgets: ${uiWidgetCandidates(candidates.ui).length}\n- Event payload shapes: ${candidates.ui?.shapes?.length || 0}\n- Stacks: ${candidates.ui?.stacks?.length ? candidates.ui.stacks.join(", ") : "none"}\n\n## CLI\n\n- Commands: ${candidates.cli?.commands?.length || 0}\n- Capabilities: ${candidates.cli?.capabilities?.length || 0}\n- CLI surfaces: ${candidates.cli?.surfaces?.length || 0}\n\n## Workflows\n\n- Workflows: ${candidates.workflows?.workflows?.length || 0}\n- States: ${candidates.workflows?.workflow_states?.length || 0}\n- Transitions: ${candidates.workflows?.workflow_transitions?.length || 0}\n\n## Verification\n\n- Verifications: ${candidates.verification?.verifications?.length || 0}\n- Scenarios: ${candidates.verification?.scenarios?.length || 0}\n- Frameworks: ${candidates.verification?.frameworks?.length ? candidates.verification.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.verification?.scripts?.length || 0}\n`
61
68
  );
62
69
  }
@@ -102,7 +102,7 @@ function initialCandidatesForTrack(track) {
102
102
  return { capabilities: [], routes: [], stacks: [] };
103
103
  }
104
104
  if (track === "ui") {
105
- return { screens: [], routes: [], actions: [], stacks: [] };
105
+ return { screens: [], routes: [], actions: [], flows: [], stacks: [] };
106
106
  }
107
107
  if (track === "cli") {
108
108
  return { commands: [], capabilities: [], surfaces: [] };
@@ -6,12 +6,17 @@ import { relativeTo } from "../../../path-helpers.js";
6
6
  export const DEFAULT_IGNORED_DIRS = new Set([
7
7
  ".git",
8
8
  ".next",
9
+ ".tmp",
9
10
  ".turbo",
10
11
  ".yarn",
12
+ ".cache",
13
+ ".parcel-cache",
14
+ ".svelte-kit",
11
15
  "build",
12
16
  "coverage",
13
17
  "dist",
14
18
  "node_modules",
19
+ "out",
15
20
  "tmp"
16
21
  ]);
17
22
 
@@ -82,6 +87,54 @@ export function normalizeImportRelativePath(paths, filePath) {
82
87
  return relativeTo(paths.repoRoot, filePath);
83
88
  }
84
89
 
90
+ /**
91
+ * @param {import("./types.d.ts").ImportPaths} paths
92
+ * @param {string} filePath
93
+ * @returns {string}
94
+ */
95
+ export function importSourcePathRelativeToWorkspace(paths, filePath) {
96
+ return relativeTo(paths.workspaceRoot, filePath).replaceAll(path.sep, "/");
97
+ }
98
+
99
+ /**
100
+ * @param {import("./types.d.ts").ImportPaths} paths
101
+ * @param {string} filePath
102
+ * @returns {"runtime_source"|"parser_config"|"docs"|"tests"|"fixtures"|"generated_output"}
103
+ */
104
+ export function classifyImportSourcePath(paths, filePath) {
105
+ const relativePath = importSourcePathRelativeToWorkspace(paths, filePath);
106
+ const basename = path.basename(relativePath).toLowerCase();
107
+ if (/(^|\/)(\.tmp|tmp|temp|dist|build|coverage|out|generated|docs-generated|snapshots?)(\/|$)/i.test(relativePath)) {
108
+ return "generated_output";
109
+ }
110
+ if (/(^|\/)[^/]*templates?[^/]*(\/|$)/i.test(relativePath)) {
111
+ return "fixtures";
112
+ }
113
+ if (/(^|\/)(test|tests|__tests__|spec|specs|mocks?)(\/|$)|\.(test|spec)\.(js|jsx|ts|tsx|mjs|cjs)$/i.test(relativePath)) {
114
+ return "tests";
115
+ }
116
+ if (/(^|\/)(fixture|fixtures|examples?)(\/|$)/i.test(relativePath)) {
117
+ return "fixtures";
118
+ }
119
+ if (/(^|\/)(doc|docs|documentation|guides?)(\/|$)|^(readme|changelog|contributing|license)(\.[a-z0-9]+)?$/i.test(relativePath) || /^(readme|changelog|contributing|license)(\.[a-z0-9]+)?$/i.test(basename)) {
120
+ return "docs";
121
+ }
122
+ if (/(^|\/)(package\.json|openapi\.(json|ya?ml)|swagger\.(json|ya?ml)|drizzle\.config\.(ts|js|mjs|cjs)|tsconfig\.json)$/i.test(relativePath)) {
123
+ return "parser_config";
124
+ }
125
+ return "runtime_source";
126
+ }
127
+
128
+ /**
129
+ * @param {import("./types.d.ts").ImportPaths} paths
130
+ * @param {string} filePath
131
+ * @returns {boolean}
132
+ */
133
+ export function isPrimaryImportSource(paths, filePath) {
134
+ const sourceType = classifyImportSourcePath(paths, filePath);
135
+ return sourceType === "runtime_source" || sourceType === "parser_config";
136
+ }
137
+
85
138
  /**
86
139
  * @param {import("./types.d.ts").ImportPaths} paths
87
140
  * @param {string} filePath
@@ -130,6 +183,9 @@ export function canonicalSourceRank(paths, filePath, kind) {
130
183
  rank += penalty.weight;
131
184
  }
132
185
  }
186
+ if (!isPrimaryImportSource(paths, filePath)) {
187
+ rank += 1000;
188
+ }
133
189
  return rank;
134
190
  }
135
191
 
@@ -10,7 +10,7 @@ import {
10
10
  normalizeEndpointPathForMatch,
11
11
  normalizeOpenApiPath
12
12
  } from "./api-routes.js";
13
- import { entityIdForRoute, screenIdForRoute, screenKindForRoute, uiCapabilityHintsForRoute } from "./ui-routes.js";
13
+ import { entityIdForRoute, inferNonResourceUiFlow, screenIdForRoute, screenKindForRoute, uiCapabilityHintsForRoute } from "./ui-routes.js";
14
14
 
15
15
  /**
16
16
  * @param {string} rootDir
@@ -48,7 +48,7 @@ export function inferNextAppRoutes(rootDir) {
48
48
  */
49
49
  export function nextScreenKindForRoute(routePath) {
50
50
  const normalized = String(routePath || "");
51
- if (/\/(login|register|setup)$/.test(normalized)) return "flow";
51
+ if (inferNonResourceUiFlow(normalized) || /\/(login|register|setup)$/.test(normalized)) return "flow";
52
52
  return screenKindForRoute(routePath);
53
53
  }
54
54
 
@@ -5,6 +5,51 @@ import { relativeTo } from "../../../path-helpers.js";
5
5
  import { canonicalCandidateTerm } from "./candidates.js";
6
6
  import { listFilesRecursive, readTextIfExists } from "./files.js";
7
7
 
8
+ const NON_RESOURCE_FLOW_DEFINITIONS = [
9
+ {
10
+ flow_type: "auth",
11
+ concept_id: "flow_auth",
12
+ pattern: /\/(login|log-in|signin|sign-in|sign_in|logout|log-out|register|signup|sign-up|forgot-password|reset-password|password-reset)(\/|$)/i,
13
+ confidence: "medium",
14
+ missing_decisions: ["confirm auth provider and session lifecycle", "confirm success and failure destinations"]
15
+ },
16
+ {
17
+ flow_type: "onboarding_wizard",
18
+ concept_id: "flow_onboarding",
19
+ pattern: /\/(onboarding|setup|welcome|get-started|getting-started|wizard|stepper)(\/|$)/i,
20
+ confidence: "medium",
21
+ missing_decisions: ["confirm step order and completion criteria", "confirm resumability and exit behavior"]
22
+ },
23
+ {
24
+ flow_type: "settings_preferences",
25
+ concept_id: "flow_settings",
26
+ pattern: /\/(settings|preferences|profile|account|billing|security)(\/|$)/i,
27
+ confidence: "medium",
28
+ missing_decisions: ["confirm settings ownership boundary", "confirm save, validation, and permission behavior"]
29
+ },
30
+ {
31
+ flow_type: "dashboard_reporting",
32
+ concept_id: "flow_dashboard",
33
+ pattern: /\/(dashboard|reports|reporting|analytics|insights|metrics)(\/|$)/i,
34
+ confidence: "medium",
35
+ missing_decisions: ["confirm reporting data sources", "confirm refresh cadence and empty states"]
36
+ },
37
+ {
38
+ flow_type: "search_filter",
39
+ concept_id: "flow_search",
40
+ pattern: /\/(search|results|explore|browse|discover)(\/|$)/i,
41
+ confidence: "medium",
42
+ missing_decisions: ["confirm searchable resources", "confirm filter, sorting, and pagination behavior"]
43
+ },
44
+ {
45
+ flow_type: "bulk_review",
46
+ concept_id: "flow_bulk_review",
47
+ pattern: /\/(review|reviews|approvals|approval|moderation|queue|bulk)(\/|$)/i,
48
+ confidence: "medium",
49
+ missing_decisions: ["confirm bulk action eligibility", "confirm review outcomes and audit requirements"]
50
+ }
51
+ ];
52
+
8
53
  /**
9
54
  * @param {string} routePath
10
55
  * @returns {any}
@@ -23,12 +68,72 @@ export function routeSegments(routePath) {
23
68
  export function screenKindForRoute(routePath) {
24
69
  const normalized = String(routePath || "");
25
70
  const segments = routeSegments(normalized);
71
+ if (inferNonResourceUiFlow(normalized)) return "flow";
26
72
  if (/\/new$/.test(normalized)) return "form";
27
73
  if (/\/:?[A-Za-z0-9_]+\/edit$/.test(normalized)) return "form";
28
74
  if (segments.length >= 2 && !/\/new$/.test(normalized) && !/\/edit$/.test(normalized)) return "detail";
29
75
  return "list";
30
76
  }
31
77
 
78
+ /**
79
+ * @param {string} routePath
80
+ * @returns {any}
81
+ */
82
+ export function inferNonResourceUiFlow(routePath) {
83
+ const normalized = String(routePath || "");
84
+ for (const definition of NON_RESOURCE_FLOW_DEFINITIONS) {
85
+ if (definition.pattern.test(normalized)) {
86
+ return {
87
+ flow_type: definition.flow_type,
88
+ concept_id: definition.concept_id,
89
+ confidence: definition.confidence,
90
+ missing_decisions: definition.missing_decisions
91
+ };
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * @param {string} routePath
99
+ * @param {string} screenId
100
+ * @returns {any}
101
+ */
102
+ export function uiFlowIdForRoute(routePath, screenId) {
103
+ const flow = inferNonResourceUiFlow(routePath);
104
+ if (!flow) return null;
105
+ return `flow_${canonicalCandidateTerm(screenId || flow.flow_type).replace(/-/g, "_")}`;
106
+ }
107
+
108
+ /**
109
+ * @param {string} routePath
110
+ * @param {string} screenId
111
+ * @param {string} screenKind
112
+ * @returns {any}
113
+ */
114
+ export function proposedUiContractAdditionsForFlow(routePath, screenId, screenKind) {
115
+ return {
116
+ projection_type: "ui_contract",
117
+ screens: [
118
+ {
119
+ id: screenId,
120
+ kind: screenKind || "flow",
121
+ title: screenId
122
+ }
123
+ ],
124
+ screen_routes: [
125
+ {
126
+ screen: screenId,
127
+ path: routePath
128
+ }
129
+ ],
130
+ notes: [
131
+ "Review-only flow recovery proposal.",
132
+ "Promote into the shared UI contract only after confirming behavior, state, and ownership decisions."
133
+ ]
134
+ };
135
+ }
136
+
32
137
  /**
33
138
  * @param {string} routePath
34
139
  * @returns {any}
@@ -37,6 +142,7 @@ export function screenIdForRoute(routePath) {
37
142
  const segments = routeSegments(routePath);
38
143
  const resource = canonicalCandidateTerm(segments[0] || "home");
39
144
  const kind = screenKindForRoute(routePath);
145
+ if (kind === "flow") return canonicalCandidateTerm(segments.join("_") || "home").replace(/-/g, "_");
40
146
  if (kind === "form" && /\/new$/.test(routePath)) return `${resource}_create`;
41
147
  if (kind === "form" && /\/edit$/.test(routePath)) return `${resource}_edit`;
42
148
  if (kind === "detail") return `${resource}_detail`;
@@ -19,6 +19,9 @@ export {
19
19
  listFilesRecursive,
20
20
  importSearchRoots,
21
21
  normalizeImportRelativePath,
22
+ importSourcePathRelativeToWorkspace,
23
+ classifyImportSourcePath,
24
+ isPrimaryImportSource,
22
25
  canonicalSourceRank,
23
26
  selectPreferredImportFiles,
24
27
  findImportFiles
@@ -38,6 +41,9 @@ export {
38
41
  screenIdForRoute,
39
42
  uiCapabilityHintsForRoute,
40
43
  entityIdForRoute,
44
+ inferNonResourceUiFlow,
45
+ uiFlowIdForRoute,
46
+ proposedUiContractAdditionsForFlow,
41
47
  inferReactRoutes,
42
48
  inferSvelteRoutes,
43
49
  inferNavigationStructure,
@@ -1,4 +1,4 @@
1
- import { findImportFiles, inferRouteAuthHint, inferRouteCapabilityId, inferRouteQueryParams, makeCandidateRecord, normalizeOpenApiPath, relativeTo } from "../../core/shared.js";
1
+ import { findImportFiles, inferRouteAuthHint, inferRouteCapabilityId, inferRouteQueryParams, isPrimaryImportSource, makeCandidateRecord, normalizeOpenApiPath, relativeTo } from "../../core/shared.js";
2
2
 
3
3
  function extractHandlerContext(text, handlerName) {
4
4
  if (!handlerName) return "";
@@ -19,6 +19,7 @@ function inferServerRoutes(context) {
19
19
  context.paths,
20
20
  (filePath) =>
21
21
  /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath) &&
22
+ isPrimaryImportSource(context.paths, filePath) &&
22
23
  /(server|api|routes|src)/i.test(filePath)
23
24
  );
24
25
  for (const filePath of routeFiles) {
@@ -1,4 +1,4 @@
1
- import { findImportFiles, makeCandidateRecord, normalizeOpenApiPath, relativeTo, selectPreferredImportFiles, slugify, titleCase } from "../../core/shared.js";
1
+ import { findImportFiles, isPrimaryImportSource, makeCandidateRecord, normalizeOpenApiPath, relativeTo, selectPreferredImportFiles, slugify, titleCase } from "../../core/shared.js";
2
2
 
3
3
  function openApiRefName(ref) {
4
4
  return typeof ref === "string" ? ref.split("/").pop() || null : null;
@@ -196,7 +196,7 @@ export const openApiExtractor = {
196
196
  detect(context) {
197
197
  const files = selectPreferredImportFiles(
198
198
  context.paths,
199
- findImportFiles(context.paths, (filePath) => /(openapi|swagger)\.(json|ya?ml)$/i.test(filePath)),
199
+ findImportFiles(context.paths, (filePath) => /(openapi|swagger)\.(json|ya?ml)$/i.test(filePath) && isPrimaryImportSource(context.paths, filePath)),
200
200
  "openapi"
201
201
  );
202
202
  return {
@@ -207,7 +207,7 @@ export const openApiExtractor = {
207
207
  extract(context) {
208
208
  const openApiFiles = selectPreferredImportFiles(
209
209
  context.paths,
210
- findImportFiles(context.paths, (filePath) => /(openapi|swagger)\.(json|ya?ml)$/i.test(filePath)),
210
+ findImportFiles(context.paths, (filePath) => /(openapi|swagger)\.(json|ya?ml)$/i.test(filePath) && isPrimaryImportSource(context.paths, filePath)),
211
211
  "openapi"
212
212
  );
213
213
  const findings = [];
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import {
6
6
  findImportFiles,
7
7
  idHintify,
8
+ isPrimaryImportSource,
8
9
  makeCandidateRecord,
9
10
  normalizeImportRelativePath,
10
11
  readJsonIfExists,
@@ -33,7 +34,7 @@ function normalizePath(value) {
33
34
  */
34
35
  function isAuthoritativeCliSource(paths, filePath) {
35
36
  const normalized = normalizePath(normalizeImportRelativePath(paths, filePath));
36
- return !NON_AUTHORITATIVE_CLI_PATH_PATTERN.test(normalized);
37
+ return isPrimaryImportSource(paths, filePath) && !NON_AUTHORITATIVE_CLI_PATH_PATTERN.test(normalized);
37
38
  }
38
39
 
39
40
  /**
@@ -5,6 +5,7 @@ import {
5
5
  dedupeCandidateRecords,
6
6
  findImportFiles,
7
7
  idHintify,
8
+ isPrimaryImportSource,
8
9
  makeCandidateRecord,
9
10
  relativeTo,
10
11
  slugify,
@@ -167,7 +168,10 @@ function parseDrizzleTables(schemaText) {
167
168
  }
168
169
 
169
170
  function drizzleConfigFiles(context) {
170
- return findImportFiles(context.paths, (filePath) => /drizzle\.config\.(ts|js|mjs|cjs)$/i.test(path.basename(filePath)));
171
+ return findImportFiles(context.paths, (filePath) =>
172
+ /drizzle\.config\.(ts|js|mjs|cjs)$/i.test(path.basename(filePath)) &&
173
+ isPrimaryImportSource(context.paths, filePath)
174
+ );
171
175
  }
172
176
 
173
177
  function configuredSchemaFiles(context, configFiles) {
@@ -184,15 +188,21 @@ function configuredSchemaFiles(context, configFiles) {
184
188
  return files;
185
189
  }
186
190
 
191
+ function isDrizzleSchemaSource(context, filePath) {
192
+ const text = context.helpers.readTextIfExists(filePath) || "";
193
+ return /\b(?:pgTable|sqliteTable|mysqlTable)\s*\(/.test(text);
194
+ }
195
+
187
196
  function findDrizzleSchemaFiles(context) {
188
197
  const configFiles = drizzleConfigFiles(context);
189
198
  const conventionalSchemaFiles = findImportFiles(context.paths, (filePath) =>
190
- /(?:^|\/)(?:src\/db\/schema|src\/schema|db\/schema|schema)\.(ts|js|mjs|cjs)$/i.test(relativeTo(context.paths.workspaceRoot, filePath).replaceAll(path.sep, "/"))
199
+ /(?:^|\/)(?:src\/db\/schema|src\/schema|db\/schema|schema)\.(ts|js|mjs|cjs)$/i.test(relativeTo(context.paths.workspaceRoot, filePath).replaceAll(path.sep, "/")) &&
200
+ isPrimaryImportSource(context.paths, filePath)
191
201
  );
192
202
  return [...new Set([
193
203
  ...configuredSchemaFiles(context, configFiles),
194
204
  ...conventionalSchemaFiles
195
- ])].sort();
205
+ ])].filter((filePath) => isDrizzleSchemaSource(context, filePath)).sort();
196
206
  }
197
207
 
198
208
  export const drizzleExtractor = {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import path from "node:path";
4
4
 
5
- import { findImportFiles, makeCandidateRecord, relativeTo } from "../../core/shared.js";
5
+ import { findImportFiles, isPrimaryImportSource, makeCandidateRecord, relativeTo } from "../../core/shared.js";
6
6
 
7
7
  /** @param {string} value @returns {string} */
8
8
  function toPosix(value) {
@@ -86,6 +86,13 @@ function maintainedDbSeamCandidate(context, options) {
86
86
  ...(options.migrationsPath ? { migrationsPath: options.migrationsPath } : {})
87
87
  };
88
88
  const idHint = `seam_${options.tool}_db_migrations`;
89
+ const manualNextSteps = [
90
+ "Review evidence, match_reasons, and missing_decisions before accepting this seam.",
91
+ `Confirm database runtime '${runtimeId}' and projection '${projectionId}' are the right topology targets for the maintained app.`,
92
+ "If accepted, copy proposed_runtime_migration into the matching database runtime's migration block in topogram.project.json.",
93
+ "Keep ownership 'maintained' and apply 'never'; import must not apply migrations or patch maintained app files.",
94
+ "After editing topogram.project.json, run topogram check . --json and the maintained app's migration verification."
95
+ ];
89
96
 
90
97
  return makeCandidateRecord({
91
98
  kind: "maintained_db_migration_seam",
@@ -112,6 +119,13 @@ function maintainedDbSeamCandidate(context, options) {
112
119
  match_reasons: options.matchReasons,
113
120
  missing_decisions: options.missingDecisions,
114
121
  proposed_runtime_migration: proposedRuntimeMigration,
122
+ manual_next_steps: manualNextSteps,
123
+ project_config_target: {
124
+ file: "topogram.project.json",
125
+ path: `topology.runtimes[id=${runtimeId}].migration`,
126
+ runtime_id: runtimeId,
127
+ projection_id: projectionId
128
+ },
115
129
  maintained_modules: [options.schemaPath, options.migrationsPath].filter(Boolean),
116
130
  emitted_dependencies: [snapshotPath, projectionId],
117
131
  allowed_change_classes: ["proposal_only"],
@@ -125,7 +139,10 @@ export function inferPrismaMaintainedDbSeams(context, prismaFiles) {
125
139
  return [];
126
140
  }
127
141
  const schemaPath = appRelativePath(context, prismaFiles[0]);
128
- const migrationFiles = /** @type {string[]} */ (findImportFiles(context.paths, /** @param {string} filePath */ (filePath) => toPosix(filePath).includes("/prisma/migrations/")));
142
+ const migrationFiles = /** @type {string[]} */ (findImportFiles(context.paths, /** @param {string} filePath */ (filePath) =>
143
+ toPosix(filePath).includes("/prisma/migrations/") &&
144
+ isPrimaryImportSource(context.paths, filePath)
145
+ ));
129
146
  const migrationsPath = firstMarkedDirectory(context, migrationFiles, [["prisma", "migrations"]]);
130
147
  return [
131
148
  maintainedDbSeamCandidate(context, {
@@ -150,8 +167,14 @@ export function inferDrizzleMaintainedDbSeams(context, schemaFiles) {
150
167
  if (!schemaFiles.length) {
151
168
  return [];
152
169
  }
153
- const configFiles = /** @type {string[]} */ (findImportFiles(context.paths, /** @param {string} filePath */ (filePath) => /drizzle\.config\.(ts|js|mjs|cjs)$/i.test(path.basename(filePath))));
154
- const drizzleFiles = /** @type {string[]} */ (findImportFiles(context.paths, /** @param {string} filePath */ (filePath) => appRelativePath(context, filePath).startsWith("drizzle/")));
170
+ const configFiles = /** @type {string[]} */ (findImportFiles(context.paths, /** @param {string} filePath */ (filePath) =>
171
+ /drizzle\.config\.(ts|js|mjs|cjs)$/i.test(path.basename(filePath)) &&
172
+ isPrimaryImportSource(context.paths, filePath)
173
+ ));
174
+ const drizzleFiles = /** @type {string[]} */ (findImportFiles(context.paths, /** @param {string} filePath */ (filePath) =>
175
+ appRelativePath(context, filePath).startsWith("drizzle/") &&
176
+ isPrimaryImportSource(context.paths, filePath)
177
+ ));
155
178
  const configuredOutPath = drizzleOutPathFromConfig(context, configFiles);
156
179
  const migrationsPath = configuredOutPath ||
157
180
  firstMarkedDirectory(context, drizzleFiles, [["drizzle"]]);
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  findImportFiles,
3
+ isPrimaryImportSource,
3
4
  makeCandidateRecord,
4
5
  normalizePrismaType,
5
6
  relativeTo,
@@ -113,7 +114,10 @@ export const prismaExtractor = {
113
114
  id: "db.prisma",
114
115
  track: "db",
115
116
  detect(context) {
116
- const files = findImportFiles(context.paths, (filePath) => filePath.endsWith("/prisma/schema.prisma") || filePath.endsWith("prisma/schema.prisma"));
117
+ const files = findImportFiles(context.paths, (filePath) =>
118
+ (filePath.endsWith("/prisma/schema.prisma") || filePath.endsWith("prisma/schema.prisma")) &&
119
+ isPrimaryImportSource(context.paths, filePath)
120
+ );
117
121
  return {
118
122
  score: files.length > 0 ? 100 : 0,
119
123
  reasons: files.length > 0 ? ["Found Prisma schema"] : []
@@ -122,7 +126,10 @@ export const prismaExtractor = {
122
126
  extract(context) {
123
127
  const prismaFiles = selectPreferredImportFiles(
124
128
  context.paths,
125
- findImportFiles(context.paths, (filePath) => filePath.endsWith("/prisma/schema.prisma") || filePath.endsWith("prisma/schema.prisma")),
129
+ findImportFiles(context.paths, (filePath) =>
130
+ (filePath.endsWith("/prisma/schema.prisma") || filePath.endsWith("prisma/schema.prisma")) &&
131
+ isPrimaryImportSource(context.paths, filePath)
132
+ ),
126
133
  "prisma"
127
134
  );
128
135
  const findings = [];
@@ -1,4 +1,4 @@
1
- import { canonicalCandidateTerm, findImportFiles, makeCandidateRecord, relativeTo, selectPreferredImportFiles, slugify, titleCase, idHintify } from "../../core/shared.js";
1
+ import { canonicalCandidateTerm, findImportFiles, isPrimaryImportSource, makeCandidateRecord, relativeTo, selectPreferredImportFiles, slugify, titleCase, idHintify } from "../../core/shared.js";
2
2
  import { inferSqlMaintainedDbSeams } from "./maintained-seams.js";
3
3
 
4
4
  function parseTableConstraint(line, tableName) {
@@ -105,14 +105,14 @@ export const sqlExtractor = {
105
105
  id: "db.sql",
106
106
  track: "db",
107
107
  detect(context) {
108
- const files = findImportFiles(context.paths, (filePath) => filePath.endsWith(".sql"));
108
+ const files = findImportFiles(context.paths, (filePath) => filePath.endsWith(".sql") && isPrimaryImportSource(context.paths, filePath));
109
109
  return {
110
110
  score: files.length > 0 ? 80 : 0,
111
111
  reasons: files.length > 0 ? ["Found SQL schema or migration files"] : []
112
112
  };
113
113
  },
114
114
  extract(context) {
115
- const allSqlFiles = findImportFiles(context.paths, (filePath) => filePath.endsWith(".sql"));
115
+ const allSqlFiles = findImportFiles(context.paths, (filePath) => filePath.endsWith(".sql") && isPrimaryImportSource(context.paths, filePath));
116
116
  const schemaSqlFiles = allSqlFiles.filter((filePath) => !/migration/i.test(filePath) && !/\/src\/test\//i.test(filePath));
117
117
  const migrationSqlFiles = allSqlFiles.filter((filePath) => /migration/i.test(filePath));
118
118
  const sqlFiles =
@@ -1,4 +1,4 @@
1
- import { dedupeCandidateRecords, inferNextAppRoutes, makeCandidateRecord, nextScreenIdForRoute, nextScreenKindForRoute, uiCapabilityHintsForNextRoute, entityIdForNextRoute, conceptIdForNextRoute, relativeTo, titleCase, idHintify } from "../../core/shared.js";
1
+ import { dedupeCandidateRecords, inferNextAppRoutes, inferNonResourceUiFlow, makeCandidateRecord, nextScreenIdForRoute, nextScreenKindForRoute, proposedUiContractAdditionsForFlow, uiCapabilityHintsForNextRoute, entityIdForNextRoute, conceptIdForNextRoute, relativeTo, titleCase, idHintify, uiFlowIdForRoute } from "../../core/shared.js";
2
2
 
3
3
  export const nextAppRouterUiExtractor = {
4
4
  id: "ui.next-app-router",
@@ -13,7 +13,7 @@ export const nextAppRouterUiExtractor = {
13
13
  extract(context) {
14
14
  const routes = inferNextAppRoutes(context.paths.workspaceRoot, context.helpers);
15
15
  const findings = [];
16
- const candidates = { screens: [], routes: [], actions: [], stacks: [] };
16
+ const candidates = { screens: [], routes: [], actions: [], flows: [], stacks: [] };
17
17
  if (routes.length > 0) {
18
18
  findings.push({
19
19
  kind: "next_app_routes",
@@ -26,9 +26,10 @@ export const nextAppRouterUiExtractor = {
26
26
  const provenance = `${relativeTo(context.paths.repoRoot, route.file)}#${route.path}`;
27
27
  const screenId = nextScreenIdForRoute(route.path);
28
28
  const screenKind = nextScreenKindForRoute(route.path);
29
- const capabilityHints = uiCapabilityHintsForNextRoute(route.path);
30
- const entityId = entityIdForNextRoute(route.path);
31
- const conceptId = conceptIdForNextRoute(route.path);
29
+ const flow = inferNonResourceUiFlow(route.path);
30
+ const capabilityHints = flow ? { load: null, submit: null, primary_action: null } : uiCapabilityHintsForNextRoute(route.path);
31
+ const entityId = flow ? null : entityIdForNextRoute(route.path);
32
+ const conceptId = flow?.concept_id || conceptIdForNextRoute(route.path);
32
33
  candidates.screens.push(makeCandidateRecord({
33
34
  kind: "screen",
34
35
  idHint: screenId,
@@ -56,6 +57,25 @@ export const nextAppRouterUiExtractor = {
56
57
  concept_id: conceptId,
57
58
  path: route.path
58
59
  }));
60
+ if (flow) {
61
+ candidates.flows.push(makeCandidateRecord({
62
+ kind: "ui_flow",
63
+ idHint: uiFlowIdForRoute(route.path, screenId),
64
+ label: `${titleCase(flow.flow_type)} Flow`,
65
+ confidence: flow.confidence,
66
+ sourceKind: "route_code",
67
+ sourceOfTruth: "candidate",
68
+ provenance,
69
+ track: "ui",
70
+ flow_type: flow.flow_type,
71
+ concept_id: flow.concept_id,
72
+ screen_ids: [screenId],
73
+ route_paths: [route.path],
74
+ evidence: [provenance],
75
+ missing_decisions: flow.missing_decisions,
76
+ proposed_ui_contract_additions: proposedUiContractAdditionsForFlow(route.path, screenId, screenKind)
77
+ }));
78
+ }
59
79
  if (capabilityHints.primary_action) {
60
80
  candidates.actions.push(makeCandidateRecord({
61
81
  kind: "ui_action",
@@ -77,6 +97,7 @@ export const nextAppRouterUiExtractor = {
77
97
  candidates.screens = dedupeCandidateRecords(candidates.screens, (record) => record.id_hint);
78
98
  candidates.routes = dedupeCandidateRecords(candidates.routes, (record) => record.id_hint);
79
99
  candidates.actions = dedupeCandidateRecords(candidates.actions, (record) => record.id_hint);
100
+ candidates.flows = dedupeCandidateRecords(candidates.flows, (record) => record.id_hint);
80
101
  candidates.stacks = [...new Set(candidates.stacks)].sort();
81
102
  return { findings, candidates };
82
103
  }
@@ -3,9 +3,12 @@ import path from "node:path";
3
3
  import {
4
4
  dedupeCandidateRecords,
5
5
  findImportFiles,
6
+ inferNonResourceUiFlow,
6
7
  makeCandidateRecord,
8
+ proposedUiContractAdditionsForFlow,
7
9
  relativeTo,
8
10
  titleCase,
11
+ uiFlowIdForRoute,
9
12
  idHintify
10
13
  } from "../../core/shared.js";
11
14
 
@@ -70,7 +73,7 @@ export const nextPagesRouterUiExtractor = {
70
73
  const pageFiles = findImportFiles(context.paths, (filePath) => /src\/pages\/.+\.(tsx|ts|jsx|js|mdx)$/i.test(filePath))
71
74
  .filter((filePath) => !/\/api\//.test(filePath) && !/\/_(app|document|error)\./.test(filePath) && !/\/404\./.test(filePath));
72
75
  const findings = [];
73
- const candidates = { screens: [], routes: [], actions: [], stacks: [] };
76
+ const candidates = { screens: [], routes: [], actions: [], flows: [], stacks: [] };
74
77
  if (pageFiles.length > 0) {
75
78
  const routes = [];
76
79
  for (const filePath of pageFiles) {
@@ -78,6 +81,7 @@ export const nextPagesRouterUiExtractor = {
78
81
  const text = context.helpers.readTextIfExists(filePath) || "";
79
82
  const hooks = inferTrpcHooks(text);
80
83
  const screen = screenFromRouteAndHooks(routePath, hooks);
84
+ const flow = inferNonResourceUiFlow(routePath);
81
85
  const provenance = `${relativeTo(context.paths.repoRoot, filePath)}#${routePath}`;
82
86
  routes.push(routePath);
83
87
  candidates.screens.push(makeCandidateRecord({
@@ -88,11 +92,11 @@ export const nextPagesRouterUiExtractor = {
88
92
  sourceKind: "route_code",
89
93
  provenance,
90
94
  track: "ui",
91
- entity_id: screen.entity,
92
- concept_id: screen.concept,
93
- screen_kind: screen.kind,
95
+ entity_id: flow ? null : screen.entity,
96
+ concept_id: flow?.concept_id || screen.concept,
97
+ screen_kind: flow ? "flow" : screen.kind,
94
98
  route_path: routePath,
95
- capability_hints: hooks.map((hook) => capabilityHint(hook.router, hook.procedure))
99
+ capability_hints: flow ? [] : hooks.map((hook) => capabilityHint(hook.router, hook.procedure))
96
100
  }));
97
101
  candidates.routes.push(makeCandidateRecord({
98
102
  kind: "ui_route",
@@ -103,10 +107,29 @@ export const nextPagesRouterUiExtractor = {
103
107
  provenance,
104
108
  track: "ui",
105
109
  screen_id: screen.id,
106
- entity_id: screen.entity,
107
- concept_id: screen.concept,
110
+ entity_id: flow ? null : screen.entity,
111
+ concept_id: flow?.concept_id || screen.concept,
108
112
  path: routePath
109
113
  }));
114
+ if (flow) {
115
+ candidates.flows.push(makeCandidateRecord({
116
+ kind: "ui_flow",
117
+ idHint: uiFlowIdForRoute(routePath, screen.id),
118
+ label: `${titleCase(flow.flow_type)} Flow`,
119
+ confidence: flow.confidence,
120
+ sourceKind: "route_code",
121
+ sourceOfTruth: "candidate",
122
+ provenance,
123
+ track: "ui",
124
+ flow_type: flow.flow_type,
125
+ concept_id: flow.concept_id,
126
+ screen_ids: [screen.id],
127
+ route_paths: [routePath],
128
+ evidence: [provenance],
129
+ missing_decisions: flow.missing_decisions,
130
+ proposed_ui_contract_additions: proposedUiContractAdditionsForFlow(routePath, screen.id, "flow")
131
+ }));
132
+ }
110
133
  for (const hook of hooks.filter((hook) => hook.hook === "Mutation")) {
111
134
  const capability = capabilityHint(hook.router, hook.procedure);
112
135
  candidates.actions.push(makeCandidateRecord({
@@ -135,6 +158,7 @@ export const nextPagesRouterUiExtractor = {
135
158
  candidates.screens = dedupeCandidateRecords(candidates.screens, (record) => record.id_hint);
136
159
  candidates.routes = dedupeCandidateRecords(candidates.routes, (record) => record.id_hint);
137
160
  candidates.actions = dedupeCandidateRecords(candidates.actions, (record) => record.id_hint);
161
+ candidates.flows = dedupeCandidateRecords(candidates.flows, (record) => record.id_hint);
138
162
  candidates.stacks = [...new Set(candidates.stacks)].sort();
139
163
  return { findings, candidates };
140
164
  }
@@ -2,14 +2,17 @@ import {
2
2
  dedupeCandidateRecords,
3
3
  detectUiPresentationFeatures,
4
4
  entityIdForRoute,
5
+ inferNonResourceUiFlow,
5
6
  inferNavigationStructure,
6
7
  inferReactRoutes,
7
8
  makeCandidateRecord,
8
9
  navigationPatternsFromStructure,
10
+ proposedUiContractAdditionsForFlow,
9
11
  relativeTo,
10
12
  shellKindFromNavigation,
11
13
  screenIdForRoute,
12
14
  screenKindForRoute,
15
+ uiFlowIdForRoute,
13
16
  uiCapabilityHintsForRoute,
14
17
  titleCase,
15
18
  idHintify
@@ -29,7 +32,7 @@ export const reactRouterUiExtractor = {
29
32
  },
30
33
  extract(context) {
31
34
  const findings = [];
32
- const candidates = { screens: [], routes: [], actions: [], stacks: [] };
35
+ const candidates = { screens: [], routes: [], actions: [], flows: [], stacks: [] };
33
36
  const roots = [
34
37
  path.join(context.paths.workspaceRoot, "apps", "web"),
35
38
  path.join(context.paths.workspaceRoot, "examples", "maintained", "proof-app")
@@ -75,16 +78,20 @@ export const reactRouterUiExtractor = {
75
78
  for (const routePath of routes) {
76
79
  const screenId = screenIdForRoute(routePath);
77
80
  const screenKind = screenKindForRoute(routePath);
78
- const capabilityHints = uiCapabilityHintsForRoute(routePath);
81
+ const flow = inferNonResourceUiFlow(routePath);
82
+ const capabilityHints = flow ? { load: null, submit: null, primary_action: null } : uiCapabilityHintsForRoute(routePath);
83
+ const entityId = flow ? null : entityIdForRoute(routePath);
84
+ const routeProvenance = `${provenance}#${routePath}`;
79
85
  candidates.screens.push(makeCandidateRecord({
80
86
  kind: "screen",
81
87
  idHint: screenId,
82
88
  label: titleCase(screenId),
83
89
  confidence: "medium",
84
90
  sourceKind: "route_code",
85
- provenance: `${provenance}#${routePath}`,
91
+ provenance: routeProvenance,
86
92
  track: "ui",
87
- entity_id: entityIdForRoute(routePath),
93
+ entity_id: entityId,
94
+ concept_id: flow?.concept_id || entityId,
88
95
  screen_kind: screenKind,
89
96
  route_path: routePath,
90
97
  capability_hints: capabilityHints
@@ -95,12 +102,32 @@ export const reactRouterUiExtractor = {
95
102
  label: routePath,
96
103
  confidence: "medium",
97
104
  sourceKind: "route_code",
98
- provenance: `${provenance}#${routePath}`,
105
+ provenance: routeProvenance,
99
106
  track: "ui",
100
107
  screen_id: screenId,
101
- entity_id: entityIdForRoute(routePath),
108
+ entity_id: entityId,
109
+ concept_id: flow?.concept_id || entityId,
102
110
  path: routePath
103
111
  }));
112
+ if (flow) {
113
+ candidates.flows.push(makeCandidateRecord({
114
+ kind: "ui_flow",
115
+ idHint: uiFlowIdForRoute(routePath, screenId),
116
+ label: `${titleCase(flow.flow_type)} Flow`,
117
+ confidence: flow.confidence,
118
+ sourceKind: "route_code",
119
+ sourceOfTruth: "candidate",
120
+ provenance: routeProvenance,
121
+ track: "ui",
122
+ flow_type: flow.flow_type,
123
+ concept_id: flow.concept_id,
124
+ screen_ids: [screenId],
125
+ route_paths: [routePath],
126
+ evidence: [routeProvenance],
127
+ missing_decisions: flow.missing_decisions,
128
+ proposed_ui_contract_additions: proposedUiContractAdditionsForFlow(routePath, screenId, screenKind)
129
+ }));
130
+ }
104
131
  if (capabilityHints.primary_action) {
105
132
  candidates.actions.push(makeCandidateRecord({
106
133
  kind: "ui_action",
@@ -133,6 +160,7 @@ export const reactRouterUiExtractor = {
133
160
  candidates.screens = dedupeCandidateRecords(candidates.screens, (record) => record.id_hint);
134
161
  candidates.routes = dedupeCandidateRecords(candidates.routes, (record) => record.id_hint);
135
162
  candidates.actions = dedupeCandidateRecords(candidates.actions, (record) => record.id_hint);
163
+ candidates.flows = dedupeCandidateRecords(candidates.flows, (record) => record.id_hint);
136
164
  candidates.stacks = [...new Set(candidates.stacks)].sort();
137
165
  return { findings, candidates };
138
166
  }
@@ -2,14 +2,17 @@ import {
2
2
  dedupeCandidateRecords,
3
3
  detectUiPresentationFeatures,
4
4
  entityIdForRoute,
5
+ inferNonResourceUiFlow,
5
6
  inferNavigationStructure,
6
7
  inferSvelteRoutes,
7
8
  makeCandidateRecord,
8
9
  navigationPatternsFromStructure,
10
+ proposedUiContractAdditionsForFlow,
9
11
  relativeTo,
10
12
  shellKindFromNavigation,
11
13
  screenIdForRoute,
12
14
  screenKindForRoute,
15
+ uiFlowIdForRoute,
13
16
  uiCapabilityHintsForRoute,
14
17
  titleCase
15
18
  } from "../../core/shared.js";
@@ -28,7 +31,7 @@ export const svelteKitUiExtractor = {
28
31
  },
29
32
  extract(context) {
30
33
  const findings = [];
31
- const candidates = { screens: [], routes: [], actions: [], stacks: [] };
34
+ const candidates = { screens: [], routes: [], actions: [], flows: [], stacks: [] };
32
35
  const roots = [
33
36
  path.join(context.paths.workspaceRoot, "apps", "web-sveltekit"),
34
37
  path.join(context.paths.workspaceRoot, "apps", "local-stack", "web")
@@ -74,16 +77,20 @@ export const svelteKitUiExtractor = {
74
77
  for (const routePath of routes) {
75
78
  const screenId = screenIdForRoute(routePath);
76
79
  const screenKind = screenKindForRoute(routePath);
77
- const capabilityHints = uiCapabilityHintsForRoute(routePath);
80
+ const flow = inferNonResourceUiFlow(routePath);
81
+ const capabilityHints = flow ? { load: null, submit: null, primary_action: null } : uiCapabilityHintsForRoute(routePath);
82
+ const entityId = flow ? null : entityIdForRoute(routePath);
83
+ const routeProvenance = `${provenance}#${routePath}`;
78
84
  candidates.screens.push(makeCandidateRecord({
79
85
  kind: "screen",
80
86
  idHint: screenId,
81
87
  label: titleCase(screenId),
82
88
  confidence: "medium",
83
89
  sourceKind: "route_code",
84
- provenance: `${provenance}#${routePath}`,
90
+ provenance: routeProvenance,
85
91
  track: "ui",
86
- entity_id: entityIdForRoute(routePath),
92
+ entity_id: entityId,
93
+ concept_id: flow?.concept_id || entityId,
87
94
  screen_kind: screenKind,
88
95
  route_path: routePath,
89
96
  capability_hints: capabilityHints
@@ -94,12 +101,32 @@ export const svelteKitUiExtractor = {
94
101
  label: routePath,
95
102
  confidence: "medium",
96
103
  sourceKind: "route_code",
97
- provenance: `${provenance}#${routePath}`,
104
+ provenance: routeProvenance,
98
105
  track: "ui",
99
106
  screen_id: screenId,
100
- entity_id: entityIdForRoute(routePath),
107
+ entity_id: entityId,
108
+ concept_id: flow?.concept_id || entityId,
101
109
  path: routePath
102
110
  }));
111
+ if (flow) {
112
+ candidates.flows.push(makeCandidateRecord({
113
+ kind: "ui_flow",
114
+ idHint: uiFlowIdForRoute(routePath, screenId),
115
+ label: `${titleCase(flow.flow_type)} Flow`,
116
+ confidence: flow.confidence,
117
+ sourceKind: "route_code",
118
+ sourceOfTruth: "candidate",
119
+ provenance: routeProvenance,
120
+ track: "ui",
121
+ flow_type: flow.flow_type,
122
+ concept_id: flow.concept_id,
123
+ screen_ids: [screenId],
124
+ route_paths: [routePath],
125
+ evidence: [routeProvenance],
126
+ missing_decisions: flow.missing_decisions,
127
+ proposed_ui_contract_additions: proposedUiContractAdditionsForFlow(routePath, screenId, screenKind)
128
+ }));
129
+ }
103
130
  }
104
131
  for (const feature of features) {
105
132
  candidates.actions.push(makeCandidateRecord({
@@ -117,6 +144,7 @@ export const svelteKitUiExtractor = {
117
144
  candidates.screens = dedupeCandidateRecords(candidates.screens, (record) => record.id_hint);
118
145
  candidates.routes = dedupeCandidateRecords(candidates.routes, (record) => record.id_hint);
119
146
  candidates.actions = dedupeCandidateRecords(candidates.actions, (record) => record.id_hint);
147
+ candidates.flows = dedupeCandidateRecords(candidates.flows, (record) => record.id_hint);
120
148
  candidates.stacks = [...new Set(candidates.stacks)].sort();
121
149
  return { findings, candidates };
122
150
  }
@@ -3,6 +3,7 @@ import {
3
3
  dedupeCandidateRecords,
4
4
  findImportFiles,
5
5
  idHintify,
6
+ isPrimaryImportSource,
6
7
  makeCandidateRecord,
7
8
  relativeTo,
8
9
  titleCase
@@ -117,7 +118,7 @@ export const swiftUiExtractor = {
117
118
  id: "ui.swiftui",
118
119
  track: "ui",
119
120
  detect(context) {
120
- const files = findImportFiles(context.paths, (filePath) => /\.swift$/i.test(filePath))
121
+ const files = findImportFiles(context.paths, (filePath) => /\.swift$/i.test(filePath) && isPrimaryImportSource(context.paths, filePath))
121
122
  .filter((filePath) => /:\s*View\b/.test(context.helpers.readTextIfExists(filePath) || ""));
122
123
  return {
123
124
  score: files.length > 0 ? 84 : 0,
@@ -125,7 +126,7 @@ export const swiftUiExtractor = {
125
126
  };
126
127
  },
127
128
  extract(context) {
128
- const files = findImportFiles(context.paths, (filePath) => /\.swift$/i.test(filePath))
129
+ const files = findImportFiles(context.paths, (filePath) => /\.swift$/i.test(filePath) && isPrimaryImportSource(context.paths, filePath))
129
130
  .filter((filePath) => /:\s*View\b/.test(context.helpers.readTextIfExists(filePath) || ""));
130
131
  const findings = [];
131
132
  const candidates = { screens: [], routes: [], actions: [], stacks: [] };
@@ -39,6 +39,7 @@ export function getOrCreateCandidateBundle(bundles, conceptId, label) {
39
39
  screens: [],
40
40
  uiRoutes: [],
41
41
  uiActions: [],
42
+ uiFlows: [],
42
43
  workflows: [],
43
44
  verifications: [],
44
45
  workflowStates: [],
@@ -337,6 +338,7 @@ export function renderCandidateBundleReadme(bundle, proposalSurfaces = []) {
337
338
  `Screens: ${bundle.screens.length}`,
338
339
  `UI routes: ${bundle.uiRoutes.length}`,
339
340
  `UI actions: ${bundle.uiActions.length}`,
341
+ `UI flows: ${(bundle.uiFlows || []).length}`,
340
342
  `Workflows: ${bundle.workflows.length}`,
341
343
  `Verifications: ${bundle.verifications.length}`,
342
344
  `Workflow states: ${bundle.workflowStates.length}`,
@@ -455,6 +457,16 @@ export function renderCandidateBundleReadme(bundle, proposalSurfaces = []) {
455
457
  lines.push(` - label ${candidate.label}`);
456
458
  lines.push(` - kind ${candidate.kind}`);
457
459
  lines.push(` - why matched ${candidate.match_reasons.length ? candidate.match_reasons.join("; ") : "dependency overlap with maintained seam evidence"}`);
460
+ lines.push(` - missing decisions ${(candidate.missing_decisions || []).length ? candidate.missing_decisions.join("; ") : "none"}`);
461
+ lines.push(` - project config target \`${candidate.project_config_target?.path || "topology.runtimes[].migration"}\``);
462
+ lines.push(` - evidence ${(candidate.evidence || []).slice(0, 3).map((/** @type {string} */ item) => `\`${item}\``).join(", ") || "_none_"}`);
463
+ lines.push(" - proposed runtime migration");
464
+ lines.push(" ```json");
465
+ lines.push(` ${JSON.stringify(candidate.proposed_runtime_migration || {}, null, 2).replace(/\n/g, "\n ")}`);
466
+ lines.push(" ```");
467
+ for (const step of candidate.manual_next_steps || []) {
468
+ lines.push(` - manual next: ${step}`);
469
+ }
458
470
  }
459
471
  }
460
472
  }
@@ -554,6 +566,13 @@ export function renderCandidateBundleReadme(bundle, proposalSurfaces = []) {
554
566
  lines.push(`- \`${entry.id_hint}\` ${entry.screen_kind} at \`${entry.route_path}\``);
555
567
  }
556
568
  }
569
+ if ((bundle.uiFlows || []).length > 0) {
570
+ lines.push("", "## UI Flow Evidence", "");
571
+ for (const entry of bundle.uiFlows) {
572
+ lines.push(`- \`${entry.id_hint}\` ${entry.flow_type || "flow"} routes ${(entry.route_paths || []).map((/** @type {string} */ item) => `\`${item}\``).join(", ") || "_none_"}`);
573
+ lines.push(` - missing decisions ${(entry.missing_decisions || []).length ? entry.missing_decisions.join("; ") : "none"}`);
574
+ }
575
+ }
557
576
  if (bundle.workflows.length > 0) {
558
577
  lines.push("", "## Workflow Evidence", "");
559
578
  for (const entry of bundle.workflows) {
@@ -578,7 +597,7 @@ export function renderMaintainedSeamCandidatesInline(bundle) {
578
597
  return entries
579
598
  .map((/** @type {any} */ surface) => {
580
599
  const seams = (surface.maintained_seam_candidates || [])
581
- .map((/** @type {any} */ candidate) => `\`${candidate.seam_id}\` (${candidate.status}, ${candidate.ownership_class}, confidence=${candidate.confidence})`)
600
+ .map((/** @type {any} */ candidate) => `\`${candidate.seam_id}\` (${candidate.status}, ${candidate.ownership_class}, confidence=${candidate.confidence}, missing decisions=${(candidate.missing_decisions || []).length})`)
582
601
  .join(", ");
583
602
  return `${surface.id}: ${seams}`;
584
603
  })
@@ -22,6 +22,7 @@ import {
22
22
  renderCandidateEntity,
23
23
  renderCandidateEnum,
24
24
  renderCandidateShape,
25
+ renderCandidateUiFlowDoc,
25
26
  renderCandidateUiReportDoc,
26
27
  renderCandidateVerification,
27
28
  renderCandidateWidget,
@@ -83,8 +84,9 @@ export function bestContextBundleForCandidate(bundles, candidate) {
83
84
  export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
84
85
  const dbCandidates = appImport.candidates.db || { entities: [], enums: [], maintained_seams: [] };
85
86
  const apiCandidates = appImport.candidates.api || { capabilities: [] };
86
- const uiCandidates = appImport.candidates.ui || { screens: [], routes: [], actions: [], widgets: [], shapes: [] };
87
+ const uiCandidates = appImport.candidates.ui || { screens: [], routes: [], actions: [], flows: [], widgets: [], shapes: [] };
87
88
  const uiWidgetCandidates = uiCandidates.widgets || uiCandidates.components || [];
89
+ const uiFlowCandidates = uiCandidates.flows || [];
88
90
  const uiShapeCandidates = uiCandidates.shapes || [];
89
91
  const uiShapeCandidatesById = new Map(uiShapeCandidates.map((/** @type {any} */ shape) => [shape.id || shape.id_hint, shape]));
90
92
  const workflowCandidates = appImport.candidates.workflows || { workflows: [], workflow_states: [], workflow_transitions: [] };
@@ -209,6 +211,11 @@ export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
209
211
  const bundle = getOrCreateCandidateBundle(bundles, conceptId, bundleLabelFromConceptId(conceptId || entry.screen_id || entry.id_hint));
210
212
  bundle.uiActions.push(entry);
211
213
  }
214
+ for (const entry of uiFlowCandidates) {
215
+ const conceptId = entry.concept_id || `flow_${canonicalCandidateTerm(entry.flow_type || entry.id_hint)}`;
216
+ const bundle = getOrCreateCandidateBundle(bundles, conceptId, bundleLabelFromConceptId(conceptId || entry.id_hint));
217
+ bundle.uiFlows.push(entry);
218
+ }
212
219
  /** @param {WorkflowRecord} entry @returns {any} */
213
220
  function widgetConceptId(entry) {
214
221
  if (entry.entity_id || entry.concept_id) {
@@ -334,6 +341,7 @@ export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
334
341
  bundle.screens.length > 0 ||
335
342
  bundle.uiRoutes.length > 0 ||
336
343
  bundle.uiActions.length > 0 ||
344
+ bundle.uiFlows.length > 0 ||
337
345
  bundle.workflows.length > 0 ||
338
346
  bundle.verifications.length > 0 ||
339
347
  bundle.workflowStates.length > 0 ||
@@ -354,6 +362,7 @@ export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
354
362
  screens: bundle.screens.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
355
363
  uiRoutes: bundle.uiRoutes.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
356
364
  uiActions: bundle.uiActions.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
365
+ uiFlows: bundle.uiFlows.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
357
366
  workflows: bundle.workflows.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
358
367
  verifications: bundle.verifications.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
359
368
  workflowStates: bundle.workflowStates.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
@@ -499,6 +508,9 @@ export function buildCandidateModelFiles(graph, appImport, topogramRoot) {
499
508
  const actions = bundle.uiActions.filter((/** @type {any} */ action) => action.screen_id === screen.id_hint);
500
509
  files[`${bundleRoot}/docs/reports/ui-${screen.id_hint}.md`] = renderCandidateUiReportDoc(screen, routes, actions);
501
510
  }
511
+ for (const flow of bundle.uiFlows || []) {
512
+ files[`${bundleRoot}/docs/reports/ui-flow-${flow.id_hint}.md`] = renderCandidateUiFlowDoc(flow);
513
+ }
502
514
  for (const patch of bundle.projectionPatches || []) {
503
515
  files[`${bundleRoot}/${patch.patch_rel_path}`] = renderProjectionPatchDoc(patch);
504
516
  }
@@ -156,6 +156,19 @@ export function buildBundleAdoptionPlan(bundle, canonicalShapeIndex) {
156
156
  canonical_rel_path: `docs/reports/ui-${screen.id_hint}.md`
157
157
  });
158
158
  }
159
+ for (const flow of bundle.uiFlows || []) {
160
+ steps.push({
161
+ action: "promote_ui_flow_report",
162
+ item: `ui_flow_${flow.id_hint}`,
163
+ target: null,
164
+ confidence: flow.confidence || "medium",
165
+ inference_summary: `Review non-resource UI flow candidate ${flow.id_hint}.`,
166
+ source_kind: flow.source_kind || "route_code",
167
+ source_path: `candidates/reconcile/model/bundles/${bundle.slug}/docs/reports/ui-flow-${flow.id_hint}.md`,
168
+ canonical_rel_path: `docs/reports/ui-flow-${flow.id_hint}.md`,
169
+ track: "ui"
170
+ });
171
+ }
159
172
  for (const patch of bundle.projectionPatches || []) {
160
173
  for (const hint of patch.missing_auth_permissions || []) {
161
174
  steps.push({
@@ -283,6 +283,39 @@ export function renderCandidateUiReportDoc(screen, routes, actions) {
283
283
  return renderMarkdownDoc(metadata, body);
284
284
  }
285
285
 
286
+ /** @param {WorkflowRecord} flow @returns {any} */
287
+ export function renderCandidateUiFlowDoc(flow) {
288
+ /** @type {WorkflowRecord} */
289
+ const metadata = {
290
+ id: `ui_flow_${flow.id_hint}`,
291
+ kind: "report",
292
+ title: `${flow.label || flow.id_hint} Review`,
293
+ status: "inferred",
294
+ source_of_truth: "imported",
295
+ confidence: flow.confidence || "medium",
296
+ review_required: true,
297
+ provenance: flow.provenance || flow.evidence || [],
298
+ tags: ["import", "ui", "flow"]
299
+ };
300
+ const body = [
301
+ "Candidate non-resource UI flow imported from brownfield route evidence.",
302
+ "",
303
+ `Flow: \`${flow.id_hint}\` (${flow.flow_type || "unknown"})`,
304
+ `Screens: ${(flow.screen_ids || []).map((/** @type {string} */ item) => `\`${item}\``).join(", ") || "_none_"}`,
305
+ `Routes: ${(flow.route_paths || []).map((/** @type {string} */ item) => `\`${item}\``).join(", ") || "_none_"}`,
306
+ `Missing decisions: ${(flow.missing_decisions || []).length ? flow.missing_decisions.join("; ") : "none"}`,
307
+ "",
308
+ "Proposed UI contract additions:",
309
+ "",
310
+ "```json",
311
+ JSON.stringify(flow.proposed_ui_contract_additions || {}, null, 2),
312
+ "```",
313
+ "",
314
+ "Review this flow before promoting it into shared UI contract behavior."
315
+ ].join("\n");
316
+ return renderMarkdownDoc(metadata, body);
317
+ }
318
+
286
319
  /** @param {WorkflowRecord} widget @returns {any} */
287
320
  export function renderCandidateWidget(widget) {
288
321
  const propName = widget.data_prop || "rows";
@@ -262,6 +262,7 @@ export function reconcileWorkflow(inputPath, options = {}) {
262
262
  widgets: bundle.widgets.map((/** @type {any} */ entry) => entry.id_hint),
263
263
  cli_surfaces: (bundle.cliSurfaces || []).map((/** @type {any} */ entry) => entry.id_hint),
264
264
  screens: bundle.screens.map((/** @type {any} */ entry) => entry.id_hint),
265
+ ui_flows: (bundle.uiFlows || []).map((/** @type {any} */ entry) => entry.id_hint),
265
266
  workflows: bundle.workflows.map((/** @type {any} */ entry) => entry.id_hint),
266
267
  docs: bundle.docs.map((/** @type {any} */ entry) => entry.id),
267
268
  maintained_seam_candidates: (agentAdoptionPlan.imported_proposal_surfaces || [])
@@ -284,7 +285,7 @@ export function reconcileWorkflow(inputPath, options = {}) {
284
285
  : "## Promoted Canonical Items";
285
286
  files["candidates/reconcile/report.json"] = `${stableStringify(report)}\n`;
286
287
  const candidateModelBundlesMarkdown = report.candidate_model_bundles.length
287
- ? report.candidate_model_bundles.map((/** @type {any} */ bundle) => `- \`${bundle.slug}\` (${bundle.actors.length} actors, ${bundle.roles.length} roles, ${bundle.entities.length} entities, ${bundle.enums.length} enums, ${bundle.capabilities.length} capabilities, ${bundle.shapes.length} shapes, ${bundle.widgets.length} widgets, ${bundle.cli_surfaces.length} CLI surfaces, ${bundle.screens.length} screens, ${bundle.workflows.length} workflows, ${bundle.docs.length} docs)
288
+ ? report.candidate_model_bundles.map((/** @type {any} */ bundle) => `- \`${bundle.slug}\` (${bundle.actors.length} actors, ${bundle.roles.length} roles, ${bundle.entities.length} entities, ${bundle.enums.length} enums, ${bundle.capabilities.length} capabilities, ${bundle.shapes.length} shapes, ${bundle.widgets.length} widgets, ${bundle.cli_surfaces.length} CLI surfaces, ${bundle.screens.length} screens, ${(bundle.ui_flows || []).length} UI flows, ${bundle.workflows.length} workflows, ${bundle.docs.length} docs)
288
289
  - primary concept \`${bundle.operator_summary.primaryConcept}\`${bundle.operator_summary.primaryEntityId ? `, primary entity \`${bundle.operator_summary.primaryEntityId}\`` : ""}
289
290
  - participants ${bundle.operator_summary.participants.label}
290
291
  - main capabilities ${summarizeBundleSurface(bundle, bundle.operator_summary.capabilityIds)}