@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 +1 -1
- package/src/adoption/plan/index.js +68 -6
- package/src/cli/commands/import/workspace.js +2 -0
- package/src/cli.js +3 -3
- package/src/import/core/runner/candidates.js +4 -1
- package/src/import/core/runner/reports.js +13 -3
- package/src/import/core/runner/tracks.js +2 -2
- package/src/import/core/shared/files.js +56 -0
- package/src/import/core/shared/next-app.js +2 -2
- package/src/import/core/shared/ui-routes.js +106 -0
- package/src/import/core/shared.js +6 -0
- package/src/import/extractors/api/generic-route-fallback.js +2 -1
- package/src/import/extractors/api/openapi.js +3 -3
- package/src/import/extractors/cli/generic.js +2 -1
- package/src/import/extractors/db/drizzle.js +45 -4
- package/src/import/extractors/db/maintained-seams.js +231 -0
- package/src/import/extractors/db/prisma.js +12 -4
- package/src/import/extractors/db/sql.js +6 -4
- package/src/import/extractors/ui/next-app-router.js +26 -5
- package/src/import/extractors/ui/next-pages-router.js +31 -7
- package/src/import/extractors/ui/react-router.js +34 -6
- package/src/import/extractors/ui/sveltekit.js +34 -6
- package/src/import/extractors/ui/swiftui.js +3 -2
- package/src/workflows/reconcile/bundle-core/index.js +20 -1
- package/src/workflows/reconcile/candidate-model.js +21 -3
- package/src/workflows/reconcile/impacts/adoption-plan.js +13 -0
- package/src/workflows/reconcile/renderers.js +33 -0
- package/src/workflows/reconcile/workflow.js +3 -1
|
@@ -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) =>
|
|
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) =>
|
|
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
|
|
30
|
-
const
|
|
31
|
-
const
|
|
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
|
|
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:
|
|
91
|
+
provenance: routeProvenance,
|
|
86
92
|
track: "ui",
|
|
87
|
-
entity_id:
|
|
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:
|
|
105
|
+
provenance: routeProvenance,
|
|
99
106
|
track: "ui",
|
|
100
107
|
screen_id: screenId,
|
|
101
|
-
entity_id:
|
|
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
|
|
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:
|
|
90
|
+
provenance: routeProvenance,
|
|
85
91
|
track: "ui",
|
|
86
|
-
entity_id:
|
|
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:
|
|
104
|
+
provenance: routeProvenance,
|
|
98
105
|
track: "ui",
|
|
99
106
|
screen_id: screenId,
|
|
100
|
-
entity_id:
|
|
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
|
}
|