@topogram/cli 0.3.75 → 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.75",
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": {
@@ -637,6 +637,7 @@ function mappingSuggestionsForItem(item) {
637
637
  }
638
638
 
639
639
  export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact = null) {
640
+ const importedMaintainedDbSeams = [...(adoptionPlan?.imported_maintained_db_seams || [])];
640
641
  const items = (adoptionPlan?.items || []).map((item) => {
641
642
  const currentState = inferCurrentAdoptionState(item);
642
643
  const maintainedSeamCandidates = inferMaintainedSeamCandidates({
@@ -656,6 +657,9 @@ export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact
656
657
  ui_impacts: [...(item.ui_impacts || [])],
657
658
  workflow_impacts: [...(item.workflow_impacts || [])]
658
659
  }, maintainedBoundaryArtifact);
660
+ const importedDbSeamCandidates = ["entity", "enum", "index", "relation", "db"].includes(item.kind) || item.track === "db"
661
+ ? importedMaintainedDbSeams
662
+ : [];
659
663
  return {
660
664
  id: adoptionItemKey(item),
661
665
  bundle: item.bundle,
@@ -691,11 +695,68 @@ export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact
691
695
  projection_impacts: [...(item.projection_impacts || [])],
692
696
  ui_impacts: [...(item.ui_impacts || [])],
693
697
  workflow_impacts: [...(item.workflow_impacts || [])],
694
- maintained_seam_candidates: maintainedSeamCandidates,
698
+ maintained_seam_candidates: [...maintainedSeamCandidates, ...importedDbSeamCandidates],
695
699
  mapping_suggestions: mappingSuggestionsForItem(item),
696
700
  available_actions: ADOPTION_STATE_VOCABULARY
697
701
  };
698
702
  });
703
+ const importedDbSeamSurfaces = importedMaintainedDbSeams.map((seam) => ({
704
+ id: seam.id_hint || seam.seam_id,
705
+ bundle: "database",
706
+ item: seam.id_hint || seam.seam_id,
707
+ kind: seam.kind || "maintained_db_migration_seam",
708
+ track: "db",
709
+ source_kind: seam.source_kind || "migration_strategy_inference",
710
+ source_path: "candidates/app/db/candidates.json",
711
+ canonical_rel_path: null,
712
+ related_shapes: [],
713
+ review_boundary: {
714
+ automation_class: "review_required",
715
+ reasons: [
716
+ "manual_project_config_review",
717
+ "proposal_only_maintained_db_migration_seam"
718
+ ]
719
+ },
720
+ current_state: "stage",
721
+ recommended_state: "customize",
722
+ supported_states: ADOPTION_STATE_VOCABULARY,
723
+ human_review_required: true,
724
+ provenance: {
725
+ bundle: "database",
726
+ source_path: "candidates/app/db/candidates.json",
727
+ canonical_rel_path: null
728
+ },
729
+ requirements: {
730
+ related_docs: [],
731
+ related_capabilities: [],
732
+ related_shapes: [],
733
+ related_rules: [],
734
+ related_workflows: [],
735
+ blocking_dependencies: []
736
+ },
737
+ projection_impacts: [],
738
+ ui_impacts: [],
739
+ workflow_impacts: [],
740
+ maintained_seam_candidates: [seam],
741
+ mapping_suggestions: [{
742
+ type: "manual_project_config_review",
743
+ id: seam.id_hint || seam.seam_id,
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
+ ]
756
+ }],
757
+ available_actions: ADOPTION_STATE_VOCABULARY
758
+ }));
759
+ const proposalSurfaces = [...items, ...importedDbSeamSurfaces];
699
760
 
700
761
  return {
701
762
  type: "agent_adoption_plan",
@@ -708,10 +769,11 @@ export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact
708
769
  non_canonical_adoption_state: "stage"
709
770
  },
710
771
  approved_review_groups: [...new Set(adoptionPlan?.approved_review_groups || [])].sort(),
711
- imported_proposal_surfaces: items,
712
- staged_items: items.filter((item) => item.current_state === "stage").map((item) => item.id),
713
- accepted_items: items.filter((item) => item.current_state === "accept").map((item) => item.id),
714
- rejected_items: items.filter((item) => item.current_state === "reject").map((item) => item.id),
715
- requires_human_review: items.filter((item) => item.human_review_required).map((item) => item.id)
772
+ imported_maintained_db_seam_candidates: importedMaintainedDbSeams,
773
+ imported_proposal_surfaces: proposalSurfaces,
774
+ staged_items: proposalSurfaces.filter((item) => item.current_state === "stage").map((item) => item.id),
775
+ accepted_items: proposalSurfaces.filter((item) => item.current_state === "accept").map((item) => item.id),
776
+ rejected_items: proposalSurfaces.filter((item) => item.current_state === "reject").map((item) => item.id),
777
+ requires_human_review: proposalSurfaces.filter((item) => item.human_review_required).map((item) => item.id)
716
778
  };
717
779
  }
@@ -110,10 +110,12 @@ export function importCandidateCounts(summary) {
110
110
  return {
111
111
  dbEntities: candidates.db?.entities?.length || 0,
112
112
  dbEnums: candidates.db?.enums?.length || 0,
113
+ dbMaintainedSeams: candidates.db?.maintained_seams?.length || 0,
113
114
  apiCapabilities: candidates.api?.capabilities?.length || 0,
114
115
  apiRoutes: candidates.api?.routes?.length || 0,
115
116
  uiScreens: candidates.ui?.screens?.length || 0,
116
117
  uiRoutes: candidates.ui?.routes?.length || 0,
118
+ uiFlows: candidates.ui?.flows?.length || 0,
117
119
  uiWidgets: candidates.ui?.widgets?.length || candidates.ui?.components?.length || 0,
118
120
  uiShapes: candidates.ui?.shapes?.length || 0,
119
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
  }
@@ -261,7 +261,8 @@ export function normalizeCandidatesForTrack(track, candidates) {
261
261
  entities: dedupeCandidateRecords(candidates.entities || [], idHint),
262
262
  enums: dedupeCandidateRecords(candidates.enums || [], idHint),
263
263
  relations: dedupeCandidateRecords(candidates.relations || [], idHint),
264
- indexes: dedupeCandidateRecords(candidates.indexes || [], idHint)
264
+ indexes: dedupeCandidateRecords(candidates.indexes || [], idHint),
265
+ maintained_seams: dedupeCandidateRecords(candidates.maintained_seams || [], idHint)
265
266
  };
266
267
  }
267
268
  if (track === "api") {
@@ -283,6 +284,7 @@ export function normalizeCandidatesForTrack(track, candidates) {
283
284
  screens: dedupeCandidateRecords(candidates.screens || [], idHint),
284
285
  routes: dedupeCandidateRecords(candidates.routes || [], idHint),
285
286
  actions: dedupeCandidateRecords(candidates.actions || [], idHint),
287
+ flows: dedupeCandidateRecords(candidates.flows || [], idHint),
286
288
  widgets,
287
289
  shapes: dedupeCandidateRecords([...(candidates.shapes || []), ...eventShapes], idHint),
288
290
  stacks: [...new Set(candidates.stacks || [])].sort()
@@ -329,6 +331,7 @@ export function enrichUiWidgetDataSources(uiCandidates, allCandidates) {
329
331
  const { components, ...canonicalCandidates } = uiCandidates;
330
332
  return {
331
333
  ...canonicalCandidates,
334
+ flows: dedupeCandidateRecords(canonicalCandidates.flows || [], (/** @type {any} */ flow) => flow.id_hint),
332
335
  widgets: widgets.map((widget) => {
333
336
  const dataSource = inferredDataSourceForWidget(widget, allCandidates);
334
337
  const dataProp = widget.data_prop || "rows";
@@ -10,8 +10,14 @@ 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 || []).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
+ ]);
13
19
  return ensureTrailingNewline(
14
- `# 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`
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`
15
21
  );
16
22
  }
17
23
  if (track === "api") {
@@ -22,11 +28,15 @@ export function reportMarkdown(track, candidates) {
22
28
  if (track === "ui") {
23
29
  const widgets = uiWidgetCandidates(candidates);
24
30
  const shapes = candidates.shapes || [];
31
+ const flows = candidates.flows || [];
25
32
  const widgetLines = widgets.map((widget) =>
26
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}`
27
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
+ );
28
38
  return ensureTrailingNewline(
29
- `# 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`
30
40
  );
31
41
  }
32
42
  if (track === "cli") {
@@ -54,6 +64,6 @@ export function reportMarkdown(track, candidates) {
54
64
  */
55
65
  export function appReportMarkdown(candidates, tracks) {
56
66
  return ensureTrailingNewline(
57
- `# 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\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`
58
68
  );
59
69
  }
@@ -96,13 +96,13 @@ function selectDetectionsForTrack(track, detections) {
96
96
  */
97
97
  function initialCandidatesForTrack(track) {
98
98
  if (track === "db") {
99
- return { entities: [], enums: [], relations: [], indexes: [] };
99
+ return { entities: [], enums: [], relations: [], indexes: [], maintained_seams: [] };
100
100
  }
101
101
  if (track === "api") {
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,11 +5,13 @@ import {
5
5
  dedupeCandidateRecords,
6
6
  findImportFiles,
7
7
  idHintify,
8
+ isPrimaryImportSource,
8
9
  makeCandidateRecord,
9
10
  relativeTo,
10
11
  slugify,
11
12
  titleCase
12
13
  } from "../../core/shared.js";
14
+ import { inferDrizzleMaintainedDbSeams } from "./maintained-seams.js";
13
15
 
14
16
  function splitTopLevelEntries(block) {
15
17
  const entries = [];
@@ -165,21 +167,59 @@ function parseDrizzleTables(schemaText) {
165
167
  };
166
168
  }
167
169
 
170
+ function drizzleConfigFiles(context) {
171
+ return findImportFiles(context.paths, (filePath) =>
172
+ /drizzle\.config\.(ts|js|mjs|cjs)$/i.test(path.basename(filePath)) &&
173
+ isPrimaryImportSource(context.paths, filePath)
174
+ );
175
+ }
176
+
177
+ function configuredSchemaFiles(context, configFiles) {
178
+ const files = [];
179
+ for (const configFile of configFiles) {
180
+ const configText = context.helpers.readTextIfExists(configFile) || "";
181
+ for (const match of configText.matchAll(/\bschema\s*:\s*["'`]([^"'`*]+)["'`]/g)) {
182
+ const absoluteSchemaPath = path.resolve(path.dirname(configFile), match[1]);
183
+ if (context.helpers.readTextIfExists(absoluteSchemaPath) !== null) {
184
+ files.push(absoluteSchemaPath);
185
+ }
186
+ }
187
+ }
188
+ return files;
189
+ }
190
+
191
+ function isDrizzleSchemaSource(context, filePath) {
192
+ const text = context.helpers.readTextIfExists(filePath) || "";
193
+ return /\b(?:pgTable|sqliteTable|mysqlTable)\s*\(/.test(text);
194
+ }
195
+
196
+ function findDrizzleSchemaFiles(context) {
197
+ const configFiles = drizzleConfigFiles(context);
198
+ const conventionalSchemaFiles = findImportFiles(context.paths, (filePath) =>
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)
201
+ );
202
+ return [...new Set([
203
+ ...configuredSchemaFiles(context, configFiles),
204
+ ...conventionalSchemaFiles
205
+ ])].filter((filePath) => isDrizzleSchemaSource(context, filePath)).sort();
206
+ }
207
+
168
208
  export const drizzleExtractor = {
169
209
  id: "db.drizzle",
170
210
  track: "db",
171
211
  detect(context) {
172
- const hasConfig = findImportFiles(context.paths, (filePath) => /drizzle\.config\.(ts|js|mjs|cjs)$/i.test(path.basename(filePath))).length > 0;
173
- const hasSchema = findImportFiles(context.paths, (filePath) => /src\/schema\.(ts|js|mjs|cjs)$/i.test(filePath)).length > 0;
212
+ const hasConfig = drizzleConfigFiles(context).length > 0;
213
+ const hasSchema = findDrizzleSchemaFiles(context).length > 0;
174
214
  return {
175
215
  score: hasConfig || hasSchema ? 95 : 0,
176
216
  reasons: hasConfig || hasSchema ? ["Found Drizzle config/schema source"] : []
177
217
  };
178
218
  },
179
219
  extract(context) {
180
- const schemaFiles = findImportFiles(context.paths, (filePath) => /src\/schema\.(ts|js|mjs|cjs)$/i.test(filePath));
220
+ const schemaFiles = findDrizzleSchemaFiles(context);
181
221
  const findings = [];
182
- const candidates = { entities: [], enums: [], relations: [], indexes: [] };
222
+ const candidates = { entities: [], enums: [], relations: [], indexes: [], maintained_seams: [] };
183
223
  for (const filePath of schemaFiles) {
184
224
  const parsed = parseDrizzleTables(context.helpers.readTextIfExists(filePath) || "");
185
225
  const provenance = relativeTo(context.paths.repoRoot, filePath);
@@ -237,6 +277,7 @@ export const drizzleExtractor = {
237
277
  candidates.enums = dedupeCandidateRecords(candidates.enums, (record) => record.id_hint);
238
278
  candidates.relations = dedupeCandidateRecords(candidates.relations, (record) => record.id_hint);
239
279
  candidates.indexes = dedupeCandidateRecords(candidates.indexes, (record) => record.id_hint);
280
+ candidates.maintained_seams = inferDrizzleMaintainedDbSeams(context, schemaFiles);
240
281
  return { findings, candidates };
241
282
  }
242
283
  };