@topogram/cli 0.3.62 → 0.3.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/package.json +1 -1
  2. package/src/adoption/plan.d.ts +6 -0
  3. package/src/adoption/reporting.d.ts +10 -0
  4. package/src/adoption/review-groups.d.ts +6 -0
  5. package/src/agent-brief.d.ts +3 -0
  6. package/src/agent-brief.js +495 -0
  7. package/src/agent-ops/query-builders.d.ts +26 -0
  8. package/src/archive/archive.d.ts +2 -0
  9. package/src/archive/compact.d.ts +1 -0
  10. package/src/archive/unarchive.d.ts +1 -0
  11. package/src/catalog.d.ts +10 -0
  12. package/src/catalog.js +62 -66
  13. package/src/cli/catalog-alias.d.ts +1 -0
  14. package/src/cli/command-parser.js +38 -0
  15. package/src/cli/command-parsers/core.js +102 -0
  16. package/src/cli/command-parsers/generator.js +39 -0
  17. package/src/cli/command-parsers/import.js +44 -0
  18. package/src/cli/command-parsers/legacy-workflow.js +21 -0
  19. package/src/cli/command-parsers/project.js +47 -0
  20. package/src/cli/command-parsers/sdlc.js +47 -0
  21. package/src/cli/command-parsers/shared.js +51 -0
  22. package/src/cli/command-parsers/template.js +48 -0
  23. package/src/cli/commands/agent.js +47 -0
  24. package/src/cli/commands/catalog.js +617 -0
  25. package/src/cli/commands/check.js +268 -0
  26. package/src/cli/commands/doctor.js +268 -0
  27. package/src/cli/commands/emit.js +149 -0
  28. package/src/cli/commands/generate.js +96 -0
  29. package/src/cli/commands/generator-policy.js +785 -0
  30. package/src/cli/commands/generator.js +443 -0
  31. package/src/cli/commands/import-runner.js +157 -0
  32. package/src/cli/commands/import.js +1734 -0
  33. package/src/cli/commands/inspect.js +55 -0
  34. package/src/cli/commands/new.js +94 -0
  35. package/src/cli/commands/package.js +815 -0
  36. package/src/cli/commands/query.js +1302 -0
  37. package/src/cli/commands/release-rollout.js +257 -0
  38. package/src/cli/commands/release-shared.js +528 -0
  39. package/src/cli/commands/release-status.js +429 -0
  40. package/src/cli/commands/release.js +107 -0
  41. package/src/cli/commands/sdlc.js +168 -0
  42. package/src/cli/commands/setup.js +76 -0
  43. package/src/cli/commands/source.js +291 -0
  44. package/src/cli/commands/template-runner.js +198 -0
  45. package/src/cli/commands/template.js +2145 -0
  46. package/src/cli/commands/trust.js +219 -0
  47. package/src/cli/commands/version.js +40 -0
  48. package/src/cli/commands/widget.js +168 -0
  49. package/src/cli/commands/workflow.js +63 -0
  50. package/src/cli/dispatcher.js +392 -0
  51. package/src/cli/help-dispatch.js +188 -0
  52. package/src/cli/help.js +296 -0
  53. package/src/cli/migration-guidance.js +59 -0
  54. package/src/cli/options.js +96 -0
  55. package/src/cli/output-safety.js +107 -0
  56. package/src/cli/path-normalization.js +29 -0
  57. package/src/cli.js +47 -11711
  58. package/src/example-implementation.d.ts +2 -0
  59. package/src/format.d.ts +1 -0
  60. package/src/generator/check.d.ts +1 -0
  61. package/src/generator/context/bundle.d.ts +1 -0
  62. package/src/generator/context/shared.d.ts +2 -0
  63. package/src/generator/native/parity-bundle.js +2 -1
  64. package/src/generator/surfaces/web/html-escape.js +22 -0
  65. package/src/generator/surfaces/web/react.js +10 -8
  66. package/src/generator/surfaces/web/sveltekit.js +7 -5
  67. package/src/generator/surfaces/web/vanilla.js +8 -4
  68. package/src/generator.d.ts +2 -0
  69. package/src/github-client.js +520 -0
  70. package/src/import/core/shared.js +20 -62
  71. package/src/import/extractors/api/flutter-dio.js +4 -8
  72. package/src/import/extractors/api/react-native-repository.js +4 -8
  73. package/src/import/index.d.ts +4 -0
  74. package/src/import/provenance.d.ts +4 -0
  75. package/src/new-project.js +100 -11
  76. package/src/npm-safety.js +79 -0
  77. package/src/parser.d.ts +1 -0
  78. package/src/path-helpers.d.ts +1 -0
  79. package/src/path-helpers.js +20 -0
  80. package/src/project-config.js +1 -0
  81. package/src/reconcile/docs.d.ts +8 -0
  82. package/src/reconcile/journeys.d.ts +1 -0
  83. package/src/resolver.d.ts +1 -0
  84. package/src/runtime-support.js +29 -0
  85. package/src/sdlc/adopt.d.ts +1 -0
  86. package/src/sdlc/check.d.ts +1 -0
  87. package/src/sdlc/explain.d.ts +1 -0
  88. package/src/sdlc/release.d.ts +1 -0
  89. package/src/sdlc/scaffold.d.ts +1 -0
  90. package/src/sdlc/transition.d.ts +1 -0
  91. package/src/text-helpers.d.ts +6 -0
  92. package/src/text-helpers.js +245 -0
  93. package/src/topogram-config.js +306 -0
  94. package/src/validator.d.ts +2 -0
  95. package/src/workflows/adoption/index.js +26 -0
  96. package/src/workflows/docs-generate.js +262 -0
  97. package/src/workflows/docs-scan.js +703 -0
  98. package/src/workflows/docs.js +15 -0
  99. package/src/workflows/import-app/api.js +799 -0
  100. package/src/workflows/import-app/db.js +538 -0
  101. package/src/workflows/import-app/index.js +30 -0
  102. package/src/workflows/import-app/shared.js +218 -0
  103. package/src/workflows/import-app/ui.js +443 -0
  104. package/src/workflows/import-app/workflow.js +159 -0
  105. package/src/workflows/reconcile/adoption-plan.js +742 -0
  106. package/src/workflows/reconcile/auth.js +692 -0
  107. package/src/workflows/reconcile/bundle-core.js +600 -0
  108. package/src/workflows/reconcile/bundle-shared.js +75 -0
  109. package/src/workflows/reconcile/candidate-model.js +477 -0
  110. package/src/workflows/reconcile/canonical-surface.js +264 -0
  111. package/src/workflows/reconcile/gap-report.js +333 -0
  112. package/src/workflows/reconcile/ids.js +6 -0
  113. package/src/workflows/reconcile/impacts.js +625 -0
  114. package/src/workflows/reconcile/index.js +7 -0
  115. package/src/workflows/reconcile/renderers.js +461 -0
  116. package/src/workflows/reconcile/summary.js +90 -0
  117. package/src/workflows/reconcile/workflow.js +309 -0
  118. package/src/workflows/shared.js +189 -0
  119. package/src/workflows/types.d.ts +93 -0
  120. package/src/workflows.d.ts +1 -0
  121. package/src/workflows.js +10 -7652
@@ -0,0 +1,218 @@
1
+ // @ts-check
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { relativeTo } from "../../path-helpers.js";
6
+ import { canonicalCandidateTerm, idHintify } from "../../text-helpers.js";
7
+ import { listFilesRecursive } from "../shared.js";
8
+
9
+ export const IMPORT_TRACKS = new Set(["db", "api", "ui", "workflows", "verification"]);
10
+ export const SCALAR_FIELD_TYPES = new Set([
11
+ "bigint",
12
+ "boolean",
13
+ "bytes",
14
+ "datetime",
15
+ "decimal",
16
+ "float",
17
+ "int",
18
+ "json",
19
+ "string",
20
+ "text",
21
+ "uuid"
22
+ ]);
23
+
24
+ /** @param {any} fromValue @returns {any} */
25
+ export function parseImportTracks(fromValue) {
26
+ if (!fromValue) {
27
+ return ["db", "api"];
28
+ }
29
+ const tracks = String(fromValue)
30
+ .split(",")
31
+ .map((/** @type {any} */ track) => track.trim().toLowerCase())
32
+ .filter(Boolean);
33
+ if (tracks.length === 0) {
34
+ throw new Error("Expected --from to include at least one import track");
35
+ }
36
+ const invalid = tracks.filter((/** @type {any} */ track) => !IMPORT_TRACKS.has(track));
37
+ if (invalid.length > 0) {
38
+ throw new Error(`Unsupported import track(s): ${invalid.join(", ")}`);
39
+ }
40
+ return [...new Set(tracks)];
41
+ }
42
+
43
+ /** @param {WorkspacePaths} paths @returns {any} */
44
+ export function importSearchRoots(paths) {
45
+ return [...new Set([paths.workspaceRoot, paths.topogramRoot].filter(Boolean))];
46
+ }
47
+
48
+ /** @param {WorkspacePaths} paths @param {string} filePath @returns {any} */
49
+ export function normalizeImportRelativePath(paths, filePath) {
50
+ return relativeTo(paths.repoRoot, filePath);
51
+ }
52
+
53
+ /** @param {WorkspacePaths} paths @param {string} filePath @param {string} kind @returns {any} */
54
+ export function canonicalSourceRank(paths, filePath, kind) {
55
+ const relativePath = normalizeImportRelativePath(paths, filePath);
56
+ const normalizedPath = relativePath.replaceAll(path.sep, "/");
57
+ const penalties = [
58
+ { pattern: /\/apps\/local-stack\//, weight: 80 },
59
+ { pattern: /\/artifacts\/environment\//, weight: 60 },
60
+ { pattern: /\/artifacts\/deploy\//, weight: 60 },
61
+ { pattern: /\/artifacts\/compile-check\//, weight: 50 },
62
+ { pattern: /\/artifacts\/db-lifecycle\//, weight: 50 },
63
+ { pattern: /\/artifacts\/migrations\//, weight: 40 }
64
+ ];
65
+
66
+ let rank = 100;
67
+ if (kind === "prisma") {
68
+ if (/\/prisma\/schema\.prisma$/i.test(normalizedPath) && !normalizedPath.includes("/artifacts/")) {
69
+ rank = 0;
70
+ } else if (/\/apps\/backend\/prisma\/schema\.prisma$/i.test(normalizedPath)) {
71
+ rank = 0;
72
+ } else if (/\/artifacts\/prisma\/schema\.prisma$/i.test(normalizedPath)) {
73
+ rank = 10;
74
+ }
75
+ } else if (kind === "sql") {
76
+ if (/\/db\/schema\.sql$/i.test(normalizedPath) || /\/schema\.sql$/i.test(normalizedPath)) {
77
+ rank = 0;
78
+ } else if (/\/artifacts\/db\/.+\.sql$/i.test(normalizedPath)) {
79
+ rank = 10;
80
+ } else if (/migration/i.test(path.basename(normalizedPath))) {
81
+ rank = 30;
82
+ }
83
+ } else if (kind === "openapi") {
84
+ if (/\/artifacts\/openapi\/openapi\.(json|ya?ml)$/i.test(normalizedPath)) {
85
+ rank = 0;
86
+ } else if (/\/openapi\.(json|ya?ml)$/i.test(normalizedPath) || /\/swagger\.(json|ya?ml)$/i.test(normalizedPath)) {
87
+ rank = 10;
88
+ }
89
+ }
90
+
91
+ for (const penalty of penalties) {
92
+ if (penalty.pattern.test(normalizedPath)) {
93
+ rank += penalty.weight;
94
+ }
95
+ }
96
+ return rank;
97
+ }
98
+
99
+ /** @param {WorkspacePaths} paths @param {any[]} files @param {string} kind @returns {any} */
100
+ export function selectPreferredImportFiles(paths, files, kind) {
101
+ if (files.length === 0) {
102
+ return [];
103
+ }
104
+ const rankedFiles = files.map((/** @type {any} */ filePath) => ({
105
+ filePath,
106
+ rank: canonicalSourceRank(paths, filePath, kind)
107
+ }));
108
+ const bestRank = Math.min(...rankedFiles.map((/** @type {any} */ entry) => entry.rank));
109
+ return rankedFiles
110
+ .filter((/** @type {any} */ entry) => entry.rank === bestRank)
111
+ .map((/** @type {any} */ entry) => entry.filePath)
112
+ .sort();
113
+ }
114
+
115
+ /** @param {WorkspacePaths} paths @param {any} predicate @returns {any} */
116
+ export function findImportFiles(paths, predicate) {
117
+ const files = new Set();
118
+ for (const rootDir of importSearchRoots(paths)) {
119
+ for (const filePath of listFilesRecursive(rootDir, predicate)) {
120
+ if (
121
+ filePath.includes(`${path.sep}candidates${path.sep}`) ||
122
+ filePath.includes(`${path.sep}docs-generated${path.sep}`) ||
123
+ filePath.includes(`${path.sep}topogram${path.sep}tests${path.sep}fixtures${path.sep}expected${path.sep}`)
124
+ ) {
125
+ continue;
126
+ }
127
+ files.add(filePath);
128
+ }
129
+ }
130
+ return [...files].sort();
131
+ }
132
+
133
+ /** @param {WorkflowRecord} input @returns {CandidateRecord} */
134
+ export function makeCandidateRecord({
135
+ kind,
136
+ idHint,
137
+ label,
138
+ confidence = "medium",
139
+ sourceKind,
140
+ sourceOfTruth = "imported",
141
+ provenance,
142
+ track = null,
143
+ ...payload
144
+ }) {
145
+ const inferredTrack =
146
+ track ||
147
+ (["entity", "enum", "relation", "index"].includes(kind)
148
+ ? "db"
149
+ : kind === "capability"
150
+ ? "api"
151
+ : null);
152
+ return {
153
+ kind,
154
+ id_hint: idHint,
155
+ label,
156
+ confidence,
157
+ source_kind: sourceKind,
158
+ source_of_truth: sourceOfTruth,
159
+ provenance: Array.isArray(provenance) ? provenance : [provenance].filter(Boolean),
160
+ track: inferredTrack,
161
+ ...payload
162
+ };
163
+ }
164
+
165
+ /** @param {any[]} records @param {any} keyFn @returns {any} */
166
+ export function dedupeCandidateRecords(records, keyFn) {
167
+ const seen = new Map();
168
+ for (const record of records) {
169
+ const key = keyFn(record);
170
+ const recordProvenance = Array.isArray(record.provenance) ? record.provenance : [record.provenance].filter(Boolean);
171
+ if (!seen.has(key)) {
172
+ seen.set(key, { ...record, provenance: recordProvenance });
173
+ continue;
174
+ }
175
+ const current = seen.get(key);
176
+ const currentProvenance = Array.isArray(current.provenance) ? current.provenance : [current.provenance].filter(Boolean);
177
+ current.provenance = [...new Set([...currentProvenance, ...recordProvenance])];
178
+ }
179
+ return [...seen.values()];
180
+ }
181
+
182
+ /** @param {string} pathValue @returns {any} */
183
+ export function normalizeOpenApiPath(pathValue) {
184
+ return String(pathValue || "")
185
+ .replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}")
186
+ .replace(/\/+$/, "") || "/";
187
+ }
188
+
189
+ /** @param {string} pathValue @returns {any} */
190
+ export function normalizeEndpointPathForMatch(pathValue) {
191
+ const normalizedPath = normalizeOpenApiPath(pathValue);
192
+ const segments = normalizedPath
193
+ .split("/")
194
+ .filter(Boolean)
195
+ .map((/** @type {any} */ segment) => {
196
+ if (/^\{[^}]+\}$/.test(segment)) {
197
+ return "{}";
198
+ }
199
+ return segment
200
+ .split("-")
201
+ .map((/** @type {any} */ part) => canonicalCandidateTerm(part))
202
+ .join("-");
203
+ });
204
+ return `/${segments.join("/")}`.replace(/\/+$/, "") || "/";
205
+ }
206
+
207
+ /** @param {WorkflowRecord} record @returns {any} */
208
+ export function inferCapabilityEntityId(record) {
209
+ if (record.entity_id) {
210
+ return record.entity_id;
211
+ }
212
+ const pathSegments = normalizeEndpointPathForMatch(record.endpoint?.path || "")
213
+ .split("/")
214
+ .filter(Boolean)
215
+ .filter((/** @type {any} */ segment) => segment !== "{}");
216
+ const resourceSegment = pathSegments[0] || record.id_hint.replace(/^cap_(create|update|delete|get|list)_/, "");
217
+ return `entity_${idHintify(canonicalCandidateTerm(resourceSegment))}`;
218
+ }
@@ -0,0 +1,443 @@
1
+ // @ts-check
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { relativeTo } from "../../path-helpers.js";
6
+ import { canonicalCandidateTerm, idHintify, titleCase } from "../../text-helpers.js";
7
+ import { listFilesRecursive, readTextIfExists } from "../shared.js";
8
+ import { dedupeCandidateRecords, makeCandidateRecord } from "./shared.js";
9
+
10
+ /** @param {string} rootDir @returns {any} */
11
+ export function inferSvelteRoutes(rootDir) {
12
+ const routesRoot = path.join(rootDir, "src", "routes");
13
+ if (!fs.existsSync(routesRoot)) {
14
+ return [];
15
+ }
16
+ const files = listFilesRecursive(routesRoot, (/** @type {any} */ child) => child.endsWith("+page.svelte") || child.endsWith("+page.ts") || child.endsWith("+page.server.ts"));
17
+ const routes = new Set();
18
+ for (const filePath of files) {
19
+ const relative = relativeTo(routesRoot, filePath)
20
+ .replace(/(^|\/)\+page(\.server|)\.(svelte|ts)$/, "")
21
+ .replace(/\[(.+?)\]/g, ":$1")
22
+ .replace(/^$/, "/");
23
+ routes.add(relative.startsWith("/") ? relative : `/${relative}`);
24
+ }
25
+ return [...routes].sort();
26
+ }
27
+
28
+ /** @param {string} rootDir @returns {any} */
29
+ export function inferReactRoutes(rootDir) {
30
+ const appPath = path.join(rootDir, "src", "App.tsx");
31
+ const text = readTextIfExists(appPath);
32
+ if (!text) {
33
+ return [];
34
+ }
35
+ const routes = new Set();
36
+ for (const match of text.matchAll(/path:\s*"([^"]+)"/g)) {
37
+ routes.add(match[1]);
38
+ }
39
+ for (const match of text.matchAll(/path="([^"]+)"/g)) {
40
+ routes.add(match[1]);
41
+ }
42
+ return [...routes].sort();
43
+ }
44
+
45
+ /** @param {string} rootDir @returns {any} */
46
+ export function inferNextAppRoutes(rootDir) {
47
+ const appDir = path.join(rootDir, "app");
48
+ if (!fs.existsSync(appDir)) {
49
+ return [];
50
+ }
51
+ const routeFiles = listFilesRecursive(
52
+ appDir,
53
+ (/** @type {any} */ child) =>
54
+ /\/page\.(tsx|ts|jsx|js|mdx)$/.test(child) ||
55
+ /\/route\.(tsx|ts|jsx|js)$/.test(child)
56
+ );
57
+ /** @type {any[]} */
58
+ const routes = [];
59
+ for (const filePath of routeFiles) {
60
+ const relative = relativeTo(appDir, filePath);
61
+ const isPage = /\/page\.(tsx|ts|jsx|js|mdx)$/.test(`/${relative}`) || /^page\.(tsx|ts|jsx|js|mdx)$/.test(relative);
62
+ const normalizedPath = `/${relative}`
63
+ .replace(/\/page\.(tsx|ts|jsx|js|mdx)$/, "")
64
+ .replace(/\/route\.(tsx|ts|jsx|js)$/, "")
65
+ .replace(/\[(\.\.\.)?([^\]]+)\]/g, (/** @type {any} */ _match, /** @type {any} */ catchAll, /** @type {any} */ name) => catchAll ? `:${name}*` : `:${name}`)
66
+ .replace(/\/index$/, "")
67
+ .replace(/^\/$/, "/");
68
+ routes.push({
69
+ path: normalizedPath === "" ? "/" : normalizedPath,
70
+ kind: isPage ? "page" : "route",
71
+ file: filePath
72
+ });
73
+ }
74
+ return routes.sort((/** @type {any} */ a, /** @type {any} */ b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind));
75
+ }
76
+
77
+ /** @param {string} routePath @returns {any} */
78
+ export function nextScreenKindForRoute(routePath) {
79
+ const normalized = String(routePath || "");
80
+ if (/\/(login|register|setup)$/.test(normalized)) {
81
+ return "flow";
82
+ }
83
+ return screenKindForRoute(routePath);
84
+ }
85
+
86
+ /** @param {string} routePath @returns {any} */
87
+ export function nextScreenIdForRoute(routePath) {
88
+ const normalized = String(routePath || "");
89
+ if (normalized === "/") {
90
+ return "home";
91
+ }
92
+ if (/\/login$/.test(normalized)) {
93
+ return "login";
94
+ }
95
+ if (/\/register$/.test(normalized)) {
96
+ return "register";
97
+ }
98
+ if (/\/setup$/.test(normalized)) {
99
+ return "setup";
100
+ }
101
+ return screenIdForRoute(routePath);
102
+ }
103
+
104
+ /** @param {string} routePath @returns {any} */
105
+ export function entityIdForNextRoute(routePath) {
106
+ const normalized = String(routePath || "");
107
+ if (/^\/posts(\/|$)/.test(normalized)) {
108
+ return "entity_post";
109
+ }
110
+ if (/^\/users(\/|$)/.test(normalized)) {
111
+ return "entity_user";
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /** @param {string} routePath @returns {any} */
117
+ export function conceptIdForNextRoute(routePath) {
118
+ const normalized = String(routePath || "");
119
+ if (normalized === "/") {
120
+ return "surface_home";
121
+ }
122
+ if (/\/login$/.test(normalized)) {
123
+ return "flow_login";
124
+ }
125
+ if (/\/register$/.test(normalized)) {
126
+ return "flow_register";
127
+ }
128
+ if (/\/setup$/.test(normalized)) {
129
+ return "flow_setup";
130
+ }
131
+ return entityIdForNextRoute(routePath) || entityIdForRoute(routePath);
132
+ }
133
+
134
+ /** @param {string} routePath @returns {any} */
135
+ export function uiCapabilityHintsForNextRoute(routePath) {
136
+ const normalized = String(routePath || "");
137
+ if (normalized === "/") {
138
+ return { load: null, submit: null, primary_action: null };
139
+ }
140
+ if (/\/login$/.test(normalized)) {
141
+ return { load: null, submit: "cap_sign_in_user", primary_action: "cap_sign_in_user" };
142
+ }
143
+ if (/\/register$/.test(normalized)) {
144
+ return { load: null, submit: "cap_register_user", primary_action: "cap_register_user" };
145
+ }
146
+ if (/\/setup$/.test(normalized)) {
147
+ return { load: null, submit: null, primary_action: null };
148
+ }
149
+ if (/^\/posts\/new$/.test(normalized)) {
150
+ return { load: null, submit: "cap_create_post", primary_action: "cap_create_post" };
151
+ }
152
+ if (/^\/posts\/:id$/.test(normalized) || /^\/posts\/:[^/]+$/.test(normalized)) {
153
+ return { load: "cap_get_post", submit: null, primary_action: "cap_update_post" };
154
+ }
155
+ if (/^\/posts$/.test(normalized)) {
156
+ return { load: "cap_list_posts", submit: null, primary_action: "cap_create_post" };
157
+ }
158
+ if (/^\/users\/new$/.test(normalized)) {
159
+ return { load: null, submit: "cap_create_user", primary_action: "cap_create_user" };
160
+ }
161
+ return uiCapabilityHintsForRoute(routePath);
162
+ }
163
+
164
+ /** @param {string} routePath @returns {any} */
165
+ export function routeSegments(routePath) {
166
+ return String(routePath || "")
167
+ .split("/")
168
+ .filter(Boolean)
169
+ .map((/** @type {any} */ segment) => segment.replace(/^:/, ""));
170
+ }
171
+
172
+ /** @param {string} routePath @returns {any} */
173
+ export function screenKindForRoute(routePath) {
174
+ const normalized = String(routePath || "");
175
+ const segments = routeSegments(normalized);
176
+ if (/\/new$/.test(normalized)) {
177
+ return "form";
178
+ }
179
+ if (/\/:?[A-Za-z0-9_]+\/edit$/.test(normalized)) {
180
+ return "form";
181
+ }
182
+ if (segments.length >= 2 && !/\/new$/.test(normalized) && !/\/edit$/.test(normalized)) {
183
+ return "detail";
184
+ }
185
+ return "list";
186
+ }
187
+
188
+ /** @param {string} routePath @returns {any} */
189
+ export function screenIdForRoute(routePath) {
190
+ const segments = routeSegments(routePath);
191
+ const resource = canonicalCandidateTerm(segments[0] || "home");
192
+ const kind = screenKindForRoute(routePath);
193
+ if (kind === "form" && /\/new$/.test(routePath)) {
194
+ return `${resource}_create`;
195
+ }
196
+ if (kind === "form" && /\/edit$/.test(routePath)) {
197
+ return `${resource}_edit`;
198
+ }
199
+ if (kind === "detail") {
200
+ return `${resource}_detail`;
201
+ }
202
+ return `${resource}_list`;
203
+ }
204
+
205
+ /** @param {string} routePath @returns {any} */
206
+ export function uiCapabilityHintsForRoute(routePath) {
207
+ const segments = routeSegments(routePath);
208
+ const resource = canonicalCandidateTerm(segments[0] || "item");
209
+ const idSegment = segments[1] || null;
210
+ if (/\/new$/.test(routePath)) {
211
+ return { load: null, submit: `cap_create_${resource}`, primary_action: `cap_create_${resource}` };
212
+ }
213
+ if (/\/edit$/.test(routePath)) {
214
+ return { load: `cap_get_${resource}`, submit: `cap_update_${resource}`, primary_action: `cap_update_${resource}` };
215
+ }
216
+ if (idSegment && !/new|edit/.test(idSegment)) {
217
+ return { load: `cap_get_${resource}`, submit: null, primary_action: `cap_update_${resource}` };
218
+ }
219
+ return { load: `cap_list_${resource}s`, submit: null, primary_action: `cap_create_${resource}` };
220
+ }
221
+
222
+ /** @param {string} routePath @returns {any} */
223
+ export function entityIdForRoute(routePath) {
224
+ const segments = routeSegments(routePath);
225
+ return `entity_${canonicalCandidateTerm(segments[0] || "item")}`;
226
+ }
227
+
228
+ /** @param {WorkspacePaths} paths @returns {any} */
229
+ export function collectUiImport(paths) {
230
+ /** @type {any[]} */
231
+ const findings = [];
232
+ /** @type {WorkflowRecord} */
233
+ const candidates = {
234
+ screens: [],
235
+ routes: [],
236
+ actions: [],
237
+ stacks: []
238
+ };
239
+
240
+ const reactRoots = [
241
+ path.join(paths.workspaceRoot, "apps", "web"),
242
+ path.join(paths.workspaceRoot, "examples", "maintained", "proof-app")
243
+ ];
244
+ const svelteRoots = [
245
+ path.join(paths.workspaceRoot, "apps", "web-sveltekit"),
246
+ path.join(paths.workspaceRoot, "apps", "local-stack", "web")
247
+ ];
248
+ const nextRoots = [paths.workspaceRoot];
249
+
250
+ for (const rootDir of reactRoots) {
251
+ const routes = inferReactRoutes(rootDir);
252
+ if (routes.length === 0) {
253
+ continue;
254
+ }
255
+ const provenance = relativeTo(paths.repoRoot, path.join(rootDir, "src", "App.tsx"));
256
+ findings.push({
257
+ kind: "react_screen_routes",
258
+ file: provenance,
259
+ routes
260
+ });
261
+ candidates.stacks.push("react_web");
262
+ for (const routePath of routes) {
263
+ const screenId = screenIdForRoute(routePath);
264
+ const screenKind = screenKindForRoute(routePath);
265
+ const capabilityHints = uiCapabilityHintsForRoute(routePath);
266
+ candidates.screens.push(
267
+ makeCandidateRecord({
268
+ kind: "screen",
269
+ idHint: screenId,
270
+ label: titleCase(screenId),
271
+ confidence: "medium",
272
+ sourceKind: "route_code",
273
+ provenance: `${provenance}#${routePath}`,
274
+ track: "ui",
275
+ entity_id: entityIdForRoute(routePath),
276
+ screen_kind: screenKind,
277
+ route_path: routePath,
278
+ capability_hints: capabilityHints
279
+ })
280
+ );
281
+ candidates.routes.push(
282
+ makeCandidateRecord({
283
+ kind: "ui_route",
284
+ idHint: `${screenId}_route`,
285
+ label: routePath,
286
+ confidence: "medium",
287
+ sourceKind: "route_code",
288
+ provenance: `${provenance}#${routePath}`,
289
+ track: "ui",
290
+ screen_id: screenId,
291
+ entity_id: entityIdForRoute(routePath),
292
+ path: routePath
293
+ })
294
+ );
295
+ if (capabilityHints.primary_action) {
296
+ candidates.actions.push(
297
+ makeCandidateRecord({
298
+ kind: "ui_action",
299
+ idHint: `${screenId}_${idHintify(capabilityHints.primary_action)}`,
300
+ label: capabilityHints.primary_action,
301
+ confidence: "low",
302
+ sourceKind: "route_code",
303
+ provenance: `${provenance}#${routePath}`,
304
+ track: "ui",
305
+ screen_id: screenId,
306
+ entity_id: entityIdForRoute(routePath),
307
+ capability_hint: capabilityHints.primary_action,
308
+ prominence: screenKind === "list" ? "primary" : "secondary"
309
+ })
310
+ );
311
+ }
312
+ }
313
+ }
314
+
315
+ for (const rootDir of svelteRoots) {
316
+ const routes = inferSvelteRoutes(rootDir);
317
+ if (routes.length === 0) {
318
+ continue;
319
+ }
320
+ const provenance = relativeTo(paths.repoRoot, path.join(rootDir, "src", "routes"));
321
+ findings.push({
322
+ kind: "sveltekit_screen_routes",
323
+ file: provenance,
324
+ routes
325
+ });
326
+ candidates.stacks.push("sveltekit_web");
327
+ for (const routePath of routes) {
328
+ const screenId = screenIdForRoute(routePath);
329
+ const screenKind = screenKindForRoute(routePath);
330
+ const capabilityHints = uiCapabilityHintsForRoute(routePath);
331
+ candidates.screens.push(
332
+ makeCandidateRecord({
333
+ kind: "screen",
334
+ idHint: screenId,
335
+ label: titleCase(screenId),
336
+ confidence: "medium",
337
+ sourceKind: "route_code",
338
+ provenance: `${provenance}#${routePath}`,
339
+ track: "ui",
340
+ entity_id: entityIdForRoute(routePath),
341
+ screen_kind: screenKind,
342
+ route_path: routePath,
343
+ capability_hints: capabilityHints
344
+ })
345
+ );
346
+ candidates.routes.push(
347
+ makeCandidateRecord({
348
+ kind: "ui_route",
349
+ idHint: `${screenId}_route`,
350
+ label: routePath,
351
+ confidence: "medium",
352
+ sourceKind: "route_code",
353
+ provenance: `${provenance}#${routePath}`,
354
+ track: "ui",
355
+ screen_id: screenId,
356
+ entity_id: entityIdForRoute(routePath),
357
+ path: routePath
358
+ })
359
+ );
360
+ }
361
+ }
362
+
363
+ for (const rootDir of nextRoots) {
364
+ const routes = inferNextAppRoutes(rootDir);
365
+ if (routes.length === 0) {
366
+ continue;
367
+ }
368
+ const provenanceRoot = relativeTo(paths.repoRoot, path.join(rootDir, "app"));
369
+ findings.push({
370
+ kind: "next_app_routes",
371
+ file: provenanceRoot,
372
+ routes: routes.map((/** @type {any} */ route) => route.path)
373
+ });
374
+ candidates.stacks.push("next_app_router");
375
+ for (const route of routes) {
376
+ if (route.kind !== "page") {
377
+ continue;
378
+ }
379
+ const routeProvenance = `${relativeTo(paths.repoRoot, route.file)}#${route.path}`;
380
+ const screenId = nextScreenIdForRoute(route.path);
381
+ const screenKind = nextScreenKindForRoute(route.path);
382
+ const capabilityHints = uiCapabilityHintsForNextRoute(route.path);
383
+ const entityId = entityIdForNextRoute(route.path);
384
+ const conceptId = conceptIdForNextRoute(route.path);
385
+ candidates.screens.push(
386
+ makeCandidateRecord({
387
+ kind: "screen",
388
+ idHint: screenId,
389
+ label: titleCase(screenId),
390
+ confidence: "medium",
391
+ sourceKind: "route_code",
392
+ provenance: routeProvenance,
393
+ track: "ui",
394
+ entity_id: entityId,
395
+ concept_id: conceptId,
396
+ screen_kind: screenKind,
397
+ route_path: route.path,
398
+ capability_hints: capabilityHints
399
+ })
400
+ );
401
+ candidates.routes.push(
402
+ makeCandidateRecord({
403
+ kind: "ui_route",
404
+ idHint: `${screenId}_route`,
405
+ label: route.path,
406
+ confidence: "medium",
407
+ sourceKind: "route_code",
408
+ provenance: routeProvenance,
409
+ track: "ui",
410
+ screen_id: screenId,
411
+ entity_id: entityId,
412
+ concept_id: conceptId,
413
+ path: route.path
414
+ })
415
+ );
416
+ if (capabilityHints.primary_action) {
417
+ candidates.actions.push(
418
+ makeCandidateRecord({
419
+ kind: "ui_action",
420
+ idHint: `${screenId}_${idHintify(capabilityHints.primary_action)}`,
421
+ label: capabilityHints.primary_action,
422
+ confidence: "low",
423
+ sourceKind: "route_code",
424
+ provenance: routeProvenance,
425
+ track: "ui",
426
+ screen_id: screenId,
427
+ entity_id: entityId,
428
+ concept_id: conceptId,
429
+ capability_hint: capabilityHints.primary_action,
430
+ prominence: screenKind === "list" ? "primary" : "secondary"
431
+ })
432
+ );
433
+ }
434
+ }
435
+ }
436
+
437
+ candidates.screens = dedupeCandidateRecords(candidates.screens, (/** @type {any} */ record) => record.id_hint);
438
+ candidates.routes = dedupeCandidateRecords(candidates.routes, (/** @type {any} */ record) => record.id_hint);
439
+ candidates.actions = dedupeCandidateRecords(candidates.actions, (/** @type {any} */ record) => record.id_hint);
440
+ candidates.stacks = [...new Set(candidates.stacks)].sort();
441
+
442
+ return { findings, candidates };
443
+ }