@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.
@@ -0,0 +1,231 @@
1
+ // @ts-check
2
+
3
+ import path from "node:path";
4
+
5
+ import { findImportFiles, isPrimaryImportSource, makeCandidateRecord, relativeTo } from "../../core/shared.js";
6
+
7
+ /** @param {string} value @returns {string} */
8
+ function toPosix(value) {
9
+ return String(value || "").replaceAll(path.sep, "/");
10
+ }
11
+
12
+ /** @param {any} context @param {string} filePath @returns {string} */
13
+ function appRelativePath(context, filePath) {
14
+ return toPosix(relativeTo(context.paths.workspaceRoot, filePath));
15
+ }
16
+
17
+ /** @param {any} context @param {string} filePath @returns {string} */
18
+ function evidencePath(context, filePath) {
19
+ return toPosix(relativeTo(context.paths.repoRoot, filePath));
20
+ }
21
+
22
+ /** @param {string} relativePath @param {string[]} segments @returns {string|null} */
23
+ function prefixThroughSegments(relativePath, segments) {
24
+ const parts = toPosix(relativePath).split("/");
25
+ for (let index = 0; index <= parts.length - segments.length; index += 1) {
26
+ if (segments.every((segment, offset) => parts[index + offset] === segment)) {
27
+ return parts.slice(0, index + segments.length).join("/");
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** @param {string} relativePath @returns {string|null} */
34
+ function migrationDirectoryFromRelativePath(relativePath) {
35
+ return prefixThroughSegments(relativePath, ["migrations"]) ||
36
+ prefixThroughSegments(relativePath, ["migration"]);
37
+ }
38
+
39
+ /** @param {any} context @param {string[]} files @param {string[][]} markers @returns {string|null} */
40
+ function firstMarkedDirectory(context, files, markers) {
41
+ const directories = new Set();
42
+ for (const filePath of files) {
43
+ const relativePath = appRelativePath(context, filePath);
44
+ for (const marker of markers) {
45
+ const directory = prefixThroughSegments(relativePath, marker);
46
+ if (directory) {
47
+ directories.add(directory);
48
+ }
49
+ }
50
+ }
51
+ return [...directories].sort()[0] || null;
52
+ }
53
+
54
+ /** @param {any} context @param {string[]} configFiles @returns {string|null} */
55
+ function drizzleOutPathFromConfig(context, configFiles) {
56
+ for (const configFile of configFiles.sort()) {
57
+ const configText = context.helpers.readTextIfExists(configFile) || "";
58
+ const outMatch = configText.match(/\bout\s*:\s*["'`]([^"'`]+)["'`]/);
59
+ if (!outMatch) {
60
+ continue;
61
+ }
62
+ const absoluteOut = path.resolve(path.dirname(configFile), outMatch[1]);
63
+ const relativeOut = appRelativePath(context, absoluteOut);
64
+ if (relativeOut && !relativeOut.startsWith("..")) {
65
+ return relativeOut;
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * @param {any} context
73
+ * @param {{ tool: "sql"|"prisma"|"drizzle", schemaPath?: string|null, migrationsPath?: string|null, evidence: string[], matchReasons: string[], missingDecisions: string[] }} options
74
+ * @returns {any}
75
+ */
76
+ function maintainedDbSeamCandidate(context, options) {
77
+ const runtimeId = "app_db";
78
+ const projectionId = "proj_db";
79
+ const snapshotPath = `topo/state/db/${runtimeId}/current.snapshot.json`;
80
+ const proposedRuntimeMigration = {
81
+ ownership: "maintained",
82
+ tool: options.tool,
83
+ apply: "never",
84
+ snapshotPath,
85
+ ...(options.schemaPath ? { schemaPath: options.schemaPath } : {}),
86
+ ...(options.migrationsPath ? { migrationsPath: options.migrationsPath } : {})
87
+ };
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
+ ];
96
+
97
+ return makeCandidateRecord({
98
+ kind: "maintained_db_migration_seam",
99
+ idHint,
100
+ label: `${options.tool.toUpperCase()} maintained database migrations`,
101
+ confidence: options.missingDecisions.length === 0 ? "high" : "medium",
102
+ sourceKind: "migration_strategy_inference",
103
+ sourceOfTruth: "candidate",
104
+ provenance: options.evidence,
105
+ track: "db",
106
+ seam_id: idHint,
107
+ output_id: "maintained_app",
108
+ ownership_class: "human_owned",
109
+ status: "review_required",
110
+ tool: options.tool,
111
+ ownership: "maintained",
112
+ apply: "never",
113
+ schemaPath: options.schemaPath || null,
114
+ migrationsPath: options.migrationsPath || null,
115
+ snapshotPath,
116
+ runtime_id_hint: runtimeId,
117
+ projection_id_hint: projectionId,
118
+ evidence: options.evidence,
119
+ match_reasons: options.matchReasons,
120
+ missing_decisions: options.missingDecisions,
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
+ },
129
+ maintained_modules: [options.schemaPath, options.migrationsPath].filter(Boolean),
130
+ emitted_dependencies: [snapshotPath, projectionId],
131
+ allowed_change_classes: ["proposal_only"],
132
+ drift_signals: ["schema_or_migration_changed", "migration_directory_changed"]
133
+ });
134
+ }
135
+
136
+ /** @param {any} context @param {string[]} prismaFiles @returns {any[]} */
137
+ export function inferPrismaMaintainedDbSeams(context, prismaFiles) {
138
+ if (!prismaFiles.length) {
139
+ return [];
140
+ }
141
+ const schemaPath = appRelativePath(context, prismaFiles[0]);
142
+ const migrationFiles = /** @type {string[]} */ (findImportFiles(context.paths, /** @param {string} filePath */ (filePath) =>
143
+ toPosix(filePath).includes("/prisma/migrations/") &&
144
+ isPrimaryImportSource(context.paths, filePath)
145
+ ));
146
+ const migrationsPath = firstMarkedDirectory(context, migrationFiles, [["prisma", "migrations"]]);
147
+ return [
148
+ maintainedDbSeamCandidate(context, {
149
+ tool: "prisma",
150
+ schemaPath,
151
+ migrationsPath,
152
+ evidence: [
153
+ ...prismaFiles.map((filePath) => evidencePath(context, filePath)),
154
+ ...migrationFiles.slice(0, 3).map(/** @param {string} filePath */ (filePath) => evidencePath(context, filePath))
155
+ ],
156
+ matchReasons: [
157
+ "found Prisma schema",
158
+ ...(migrationsPath ? ["found Prisma migrations directory"] : [])
159
+ ],
160
+ missingDecisions: migrationsPath ? [] : ["confirm Prisma migrationsPath before adding this strategy to topogram.project.json"]
161
+ })
162
+ ];
163
+ }
164
+
165
+ /** @param {any} context @param {string[]} schemaFiles @returns {any[]} */
166
+ export function inferDrizzleMaintainedDbSeams(context, schemaFiles) {
167
+ if (!schemaFiles.length) {
168
+ return [];
169
+ }
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
+ ));
178
+ const configuredOutPath = drizzleOutPathFromConfig(context, configFiles);
179
+ const migrationsPath = configuredOutPath ||
180
+ firstMarkedDirectory(context, drizzleFiles, [["drizzle"]]);
181
+ return [
182
+ maintainedDbSeamCandidate(context, {
183
+ tool: "drizzle",
184
+ schemaPath: appRelativePath(context, schemaFiles[0]),
185
+ migrationsPath,
186
+ evidence: [
187
+ ...schemaFiles.map((filePath) => evidencePath(context, filePath)),
188
+ ...configFiles.map(/** @param {string} filePath */ (filePath) => evidencePath(context, filePath)),
189
+ ...drizzleFiles.slice(0, 3).map(/** @param {string} filePath */ (filePath) => evidencePath(context, filePath))
190
+ ],
191
+ matchReasons: [
192
+ "found Drizzle schema source",
193
+ ...(configFiles.length ? ["found Drizzle config"] : []),
194
+ ...(migrationsPath ? ["found Drizzle migrations output"] : [])
195
+ ],
196
+ missingDecisions: migrationsPath ? [] : ["confirm Drizzle migrationsPath before adding this strategy to topogram.project.json"]
197
+ })
198
+ ];
199
+ }
200
+
201
+ /** @param {any} context @param {string[]} allSqlFiles @param {string[]} selectedSqlFiles @returns {any[]} */
202
+ export function inferSqlMaintainedDbSeams(context, allSqlFiles, selectedSqlFiles) {
203
+ if (!allSqlFiles.length) {
204
+ return [];
205
+ }
206
+ const schemaFile = selectedSqlFiles.find((filePath) => !/migration/i.test(path.basename(filePath))) ||
207
+ allSqlFiles.find((filePath) => /schema/i.test(path.basename(filePath))) ||
208
+ null;
209
+ const migrationFiles = allSqlFiles.filter((filePath) => {
210
+ const relativePath = appRelativePath(context, filePath);
211
+ return Boolean(migrationDirectoryFromRelativePath(relativePath)) || /migration/i.test(path.basename(filePath));
212
+ });
213
+ const migrationsPath = firstMarkedDirectory(context, migrationFiles, [["migrations"], ["migration"]]) ||
214
+ (migrationFiles.length ? toPosix(path.dirname(appRelativePath(context, migrationFiles[0]))) : null);
215
+ return [
216
+ maintainedDbSeamCandidate(context, {
217
+ tool: "sql",
218
+ schemaPath: schemaFile ? appRelativePath(context, schemaFile) : null,
219
+ migrationsPath,
220
+ evidence: [
221
+ ...(schemaFile ? [evidencePath(context, schemaFile)] : []),
222
+ ...migrationFiles.slice(0, 3).map((filePath) => evidencePath(context, filePath))
223
+ ],
224
+ matchReasons: [
225
+ ...(schemaFile ? ["found SQL schema"] : []),
226
+ ...(migrationsPath ? ["found SQL migrations directory or migration file"] : [])
227
+ ],
228
+ missingDecisions: migrationsPath ? [] : ["confirm SQL migrationsPath before adding this strategy to topogram.project.json"]
229
+ })
230
+ ];
231
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  findImportFiles,
3
+ isPrimaryImportSource,
3
4
  makeCandidateRecord,
4
5
  normalizePrismaType,
5
6
  relativeTo,
@@ -8,6 +9,7 @@ import {
8
9
  titleCase,
9
10
  idHintify
10
11
  } from "../../core/shared.js";
12
+ import { inferPrismaMaintainedDbSeams } from "./maintained-seams.js";
11
13
 
12
14
  function parsePrismaSchema(schemaText) {
13
15
  const enums = [];
@@ -112,7 +114,10 @@ export const prismaExtractor = {
112
114
  id: "db.prisma",
113
115
  track: "db",
114
116
  detect(context) {
115
- 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
+ );
116
121
  return {
117
122
  score: files.length > 0 ? 100 : 0,
118
123
  reasons: files.length > 0 ? ["Found Prisma schema"] : []
@@ -121,11 +126,14 @@ export const prismaExtractor = {
121
126
  extract(context) {
122
127
  const prismaFiles = selectPreferredImportFiles(
123
128
  context.paths,
124
- 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
+ ),
125
133
  "prisma"
126
134
  );
127
135
  const findings = [];
128
- const candidates = { entities: [], enums: [], relations: [], indexes: [] };
136
+ const candidates = { entities: [], enums: [], relations: [], indexes: [], maintained_seams: [] };
129
137
  for (const filePath of prismaFiles) {
130
138
  const parsed = parsePrismaSchema(context.helpers.readTextIfExists(filePath) || "");
131
139
  const provenance = relativeTo(context.paths.repoRoot, filePath);
@@ -179,7 +187,7 @@ export const prismaExtractor = {
179
187
  track: "db"
180
188
  })));
181
189
  }
190
+ candidates.maintained_seams = inferPrismaMaintainedDbSeams(context, prismaFiles);
182
191
  return { findings, candidates };
183
192
  }
184
193
  };
185
-
@@ -1,4 +1,5 @@
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
+ import { inferSqlMaintainedDbSeams } from "./maintained-seams.js";
2
3
 
3
4
  function parseTableConstraint(line, tableName) {
4
5
  const normalized = line.replace(/,$/, "").trim();
@@ -104,14 +105,14 @@ export const sqlExtractor = {
104
105
  id: "db.sql",
105
106
  track: "db",
106
107
  detect(context) {
107
- const files = findImportFiles(context.paths, (filePath) => filePath.endsWith(".sql"));
108
+ const files = findImportFiles(context.paths, (filePath) => filePath.endsWith(".sql") && isPrimaryImportSource(context.paths, filePath));
108
109
  return {
109
110
  score: files.length > 0 ? 80 : 0,
110
111
  reasons: files.length > 0 ? ["Found SQL schema or migration files"] : []
111
112
  };
112
113
  },
113
114
  extract(context) {
114
- const allSqlFiles = findImportFiles(context.paths, (filePath) => filePath.endsWith(".sql"));
115
+ const allSqlFiles = findImportFiles(context.paths, (filePath) => filePath.endsWith(".sql") && isPrimaryImportSource(context.paths, filePath));
115
116
  const schemaSqlFiles = allSqlFiles.filter((filePath) => !/migration/i.test(filePath) && !/\/src\/test\//i.test(filePath));
116
117
  const migrationSqlFiles = allSqlFiles.filter((filePath) => /migration/i.test(filePath));
117
118
  const sqlFiles =
@@ -119,7 +120,7 @@ export const sqlExtractor = {
119
120
  ? selectPreferredImportFiles(context.paths, schemaSqlFiles, "sql")
120
121
  : selectPreferredImportFiles(context.paths, migrationSqlFiles, "sql");
121
122
  const findings = [];
122
- const candidates = { entities: [], enums: [], relations: [], indexes: [] };
123
+ const candidates = { entities: [], enums: [], relations: [], indexes: [], maintained_seams: [] };
123
124
  for (const filePath of sqlFiles) {
124
125
  const parsed = parseSqlSchema(context.helpers.readTextIfExists(filePath) || "");
125
126
  const provenance = relativeTo(context.paths.repoRoot, filePath);
@@ -175,6 +176,7 @@ export const sqlExtractor = {
175
176
  track: "db"
176
177
  })));
177
178
  }
179
+ candidates.maintained_seams = inferSqlMaintainedDbSeams(context, allSqlFiles, sqlFiles);
178
180
  return { findings, candidates };
179
181
  }
180
182
  };
@@ -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
  }