@topogram/cli 0.3.75 → 0.3.76
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 +57 -6
- package/src/cli/commands/import/workspace.js +1 -0
- package/src/import/core/runner/candidates.js +2 -1
- package/src/import/core/runner/reports.js +5 -2
- package/src/import/core/runner/tracks.js +1 -1
- package/src/import/extractors/db/drizzle.js +35 -4
- package/src/import/extractors/db/maintained-seams.js +208 -0
- package/src/import/extractors/db/prisma.js +3 -2
- package/src/import/extractors/db/sql.js +3 -1
- package/src/workflows/reconcile/candidate-model.js +8 -2
- package/src/workflows/reconcile/workflow.js +1 -0
package/package.json
CHANGED
|
@@ -637,6 +637,7 @@ function mappingSuggestionsForItem(item) {
|
|
|
637
637
|
}
|
|
638
638
|
|
|
639
639
|
export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact = null) {
|
|
640
|
+
const importedMaintainedDbSeams = [...(adoptionPlan?.imported_maintained_db_seams || [])];
|
|
640
641
|
const items = (adoptionPlan?.items || []).map((item) => {
|
|
641
642
|
const currentState = inferCurrentAdoptionState(item);
|
|
642
643
|
const maintainedSeamCandidates = inferMaintainedSeamCandidates({
|
|
@@ -656,6 +657,9 @@ export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact
|
|
|
656
657
|
ui_impacts: [...(item.ui_impacts || [])],
|
|
657
658
|
workflow_impacts: [...(item.workflow_impacts || [])]
|
|
658
659
|
}, maintainedBoundaryArtifact);
|
|
660
|
+
const importedDbSeamCandidates = ["entity", "enum", "index", "relation", "db"].includes(item.kind) || item.track === "db"
|
|
661
|
+
? importedMaintainedDbSeams
|
|
662
|
+
: [];
|
|
659
663
|
return {
|
|
660
664
|
id: adoptionItemKey(item),
|
|
661
665
|
bundle: item.bundle,
|
|
@@ -691,11 +695,57 @@ export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact
|
|
|
691
695
|
projection_impacts: [...(item.projection_impacts || [])],
|
|
692
696
|
ui_impacts: [...(item.ui_impacts || [])],
|
|
693
697
|
workflow_impacts: [...(item.workflow_impacts || [])],
|
|
694
|
-
maintained_seam_candidates: maintainedSeamCandidates,
|
|
698
|
+
maintained_seam_candidates: [...maintainedSeamCandidates, ...importedDbSeamCandidates],
|
|
695
699
|
mapping_suggestions: mappingSuggestionsForItem(item),
|
|
696
700
|
available_actions: ADOPTION_STATE_VOCABULARY
|
|
697
701
|
};
|
|
698
702
|
});
|
|
703
|
+
const importedDbSeamSurfaces = importedMaintainedDbSeams.map((seam) => ({
|
|
704
|
+
id: seam.id_hint || seam.seam_id,
|
|
705
|
+
bundle: "database",
|
|
706
|
+
item: seam.id_hint || seam.seam_id,
|
|
707
|
+
kind: seam.kind || "maintained_db_migration_seam",
|
|
708
|
+
track: "db",
|
|
709
|
+
source_kind: seam.source_kind || "migration_strategy_inference",
|
|
710
|
+
source_path: "candidates/app/db/candidates.json",
|
|
711
|
+
canonical_rel_path: null,
|
|
712
|
+
related_shapes: [],
|
|
713
|
+
review_boundary: {
|
|
714
|
+
automation_class: "review_required",
|
|
715
|
+
reasons: [
|
|
716
|
+
"manual_project_config_review",
|
|
717
|
+
"proposal_only_maintained_db_migration_seam"
|
|
718
|
+
]
|
|
719
|
+
},
|
|
720
|
+
current_state: "stage",
|
|
721
|
+
recommended_state: "customize",
|
|
722
|
+
supported_states: ADOPTION_STATE_VOCABULARY,
|
|
723
|
+
human_review_required: true,
|
|
724
|
+
provenance: {
|
|
725
|
+
bundle: "database",
|
|
726
|
+
source_path: "candidates/app/db/candidates.json",
|
|
727
|
+
canonical_rel_path: null
|
|
728
|
+
},
|
|
729
|
+
requirements: {
|
|
730
|
+
related_docs: [],
|
|
731
|
+
related_capabilities: [],
|
|
732
|
+
related_shapes: [],
|
|
733
|
+
related_rules: [],
|
|
734
|
+
related_workflows: [],
|
|
735
|
+
blocking_dependencies: []
|
|
736
|
+
},
|
|
737
|
+
projection_impacts: [],
|
|
738
|
+
ui_impacts: [],
|
|
739
|
+
workflow_impacts: [],
|
|
740
|
+
maintained_seam_candidates: [seam],
|
|
741
|
+
mapping_suggestions: [{
|
|
742
|
+
type: "manual_project_config_review",
|
|
743
|
+
id: seam.id_hint || seam.seam_id,
|
|
744
|
+
reason: "Review the inferred database migration strategy before editing topogram.project.json."
|
|
745
|
+
}],
|
|
746
|
+
available_actions: ADOPTION_STATE_VOCABULARY
|
|
747
|
+
}));
|
|
748
|
+
const proposalSurfaces = [...items, ...importedDbSeamSurfaces];
|
|
699
749
|
|
|
700
750
|
return {
|
|
701
751
|
type: "agent_adoption_plan",
|
|
@@ -708,10 +758,11 @@ export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact
|
|
|
708
758
|
non_canonical_adoption_state: "stage"
|
|
709
759
|
},
|
|
710
760
|
approved_review_groups: [...new Set(adoptionPlan?.approved_review_groups || [])].sort(),
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
761
|
+
imported_maintained_db_seam_candidates: importedMaintainedDbSeams,
|
|
762
|
+
imported_proposal_surfaces: proposalSurfaces,
|
|
763
|
+
staged_items: proposalSurfaces.filter((item) => item.current_state === "stage").map((item) => item.id),
|
|
764
|
+
accepted_items: proposalSurfaces.filter((item) => item.current_state === "accept").map((item) => item.id),
|
|
765
|
+
rejected_items: proposalSurfaces.filter((item) => item.current_state === "reject").map((item) => item.id),
|
|
766
|
+
requires_human_review: proposalSurfaces.filter((item) => item.human_review_required).map((item) => item.id)
|
|
716
767
|
};
|
|
717
768
|
}
|
|
@@ -110,6 +110,7 @@ export function importCandidateCounts(summary) {
|
|
|
110
110
|
return {
|
|
111
111
|
dbEntities: candidates.db?.entities?.length || 0,
|
|
112
112
|
dbEnums: candidates.db?.enums?.length || 0,
|
|
113
|
+
dbMaintainedSeams: candidates.db?.maintained_seams?.length || 0,
|
|
113
114
|
apiCapabilities: candidates.api?.capabilities?.length || 0,
|
|
114
115
|
apiRoutes: candidates.api?.routes?.length || 0,
|
|
115
116
|
uiScreens: candidates.ui?.screens?.length || 0,
|
|
@@ -261,7 +261,8 @@ export function normalizeCandidatesForTrack(track, candidates) {
|
|
|
261
261
|
entities: dedupeCandidateRecords(candidates.entities || [], idHint),
|
|
262
262
|
enums: dedupeCandidateRecords(candidates.enums || [], idHint),
|
|
263
263
|
relations: dedupeCandidateRecords(candidates.relations || [], idHint),
|
|
264
|
-
indexes: dedupeCandidateRecords(candidates.indexes || [], idHint)
|
|
264
|
+
indexes: dedupeCandidateRecords(candidates.indexes || [], idHint),
|
|
265
|
+
maintained_seams: dedupeCandidateRecords(candidates.maintained_seams || [], idHint)
|
|
265
266
|
};
|
|
266
267
|
}
|
|
267
268
|
if (track === "api") {
|
|
@@ -10,8 +10,11 @@ import { uiWidgetCandidates } from "./candidates.js";
|
|
|
10
10
|
*/
|
|
11
11
|
export function reportMarkdown(track, candidates) {
|
|
12
12
|
if (track === "db") {
|
|
13
|
+
const seamLines = (candidates.maintained_seams || []).map((/** @type {any} */ seam) =>
|
|
14
|
+
`- \`${seam.id_hint}\` tool \`${seam.tool}\` confidence ${seam.confidence || "unknown"} schema \`${seam.schemaPath || "none"}\` migrations \`${seam.migrationsPath || "review-required"}\` missing decisions ${(seam.missing_decisions || []).length}`
|
|
15
|
+
);
|
|
13
16
|
return ensureTrailingNewline(
|
|
14
|
-
`# DB Import Report\n\n- Entities: ${candidates.entities.length}\n- Enums: ${candidates.enums.length}\n- Relations: ${candidates.relations.length}\n- Indexes: ${candidates.indexes.length}\n`
|
|
17
|
+
`# DB Import Report\n\n- Entities: ${candidates.entities.length}\n- Enums: ${candidates.enums.length}\n- Relations: ${candidates.relations.length}\n- Indexes: ${candidates.indexes.length}\n- Maintained DB migration seams: ${(candidates.maintained_seams || []).length}\n\n## Maintained DB Migration Seam Candidates\n\n${seamLines.length ? seamLines.join("\n") : "- none"}\n`
|
|
15
18
|
);
|
|
16
19
|
}
|
|
17
20
|
if (track === "api") {
|
|
@@ -54,6 +57,6 @@ export function reportMarkdown(track, candidates) {
|
|
|
54
57
|
*/
|
|
55
58
|
export function appReportMarkdown(candidates, tracks) {
|
|
56
59
|
return ensureTrailingNewline(
|
|
57
|
-
`# App Import Report\n\nTracks: ${tracks.join(", ")}\n\n## DB\n\n- Entities: ${candidates.db?.entities?.length || 0}\n- Enums: ${candidates.db?.enums?.length || 0}\n- Relations: ${candidates.db?.relations?.length || 0}\n\n## API\n\n- Capabilities: ${candidates.api?.capabilities?.length || 0}\n- Routes: ${candidates.api?.routes?.length || 0}\n- Stacks: ${candidates.api?.stacks?.length ? candidates.api.stacks.join(", ") : "none"}\n\n## UI\n\n- Screens: ${candidates.ui?.screens?.length || 0}\n- Routes: ${candidates.ui?.routes?.length || 0}\n- Actions: ${candidates.ui?.actions?.length || 0}\n- Widgets: ${uiWidgetCandidates(candidates.ui).length}\n- Event payload shapes: ${candidates.ui?.shapes?.length || 0}\n- Stacks: ${candidates.ui?.stacks?.length ? candidates.ui.stacks.join(", ") : "none"}\n\n## CLI\n\n- Commands: ${candidates.cli?.commands?.length || 0}\n- Capabilities: ${candidates.cli?.capabilities?.length || 0}\n- CLI surfaces: ${candidates.cli?.surfaces?.length || 0}\n\n## Workflows\n\n- Workflows: ${candidates.workflows?.workflows?.length || 0}\n- States: ${candidates.workflows?.workflow_states?.length || 0}\n- Transitions: ${candidates.workflows?.workflow_transitions?.length || 0}\n\n## Verification\n\n- Verifications: ${candidates.verification?.verifications?.length || 0}\n- Scenarios: ${candidates.verification?.scenarios?.length || 0}\n- Frameworks: ${candidates.verification?.frameworks?.length ? candidates.verification.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.verification?.scripts?.length || 0}\n`
|
|
60
|
+
`# App Import Report\n\nTracks: ${tracks.join(", ")}\n\n## DB\n\n- Entities: ${candidates.db?.entities?.length || 0}\n- Enums: ${candidates.db?.enums?.length || 0}\n- Relations: ${candidates.db?.relations?.length || 0}\n- Maintained DB migration seams: ${candidates.db?.maintained_seams?.length || 0}\n\n## API\n\n- Capabilities: ${candidates.api?.capabilities?.length || 0}\n- Routes: ${candidates.api?.routes?.length || 0}\n- Stacks: ${candidates.api?.stacks?.length ? candidates.api.stacks.join(", ") : "none"}\n\n## UI\n\n- Screens: ${candidates.ui?.screens?.length || 0}\n- Routes: ${candidates.ui?.routes?.length || 0}\n- Actions: ${candidates.ui?.actions?.length || 0}\n- Widgets: ${uiWidgetCandidates(candidates.ui).length}\n- Event payload shapes: ${candidates.ui?.shapes?.length || 0}\n- Stacks: ${candidates.ui?.stacks?.length ? candidates.ui.stacks.join(", ") : "none"}\n\n## CLI\n\n- Commands: ${candidates.cli?.commands?.length || 0}\n- Capabilities: ${candidates.cli?.capabilities?.length || 0}\n- CLI surfaces: ${candidates.cli?.surfaces?.length || 0}\n\n## Workflows\n\n- Workflows: ${candidates.workflows?.workflows?.length || 0}\n- States: ${candidates.workflows?.workflow_states?.length || 0}\n- Transitions: ${candidates.workflows?.workflow_transitions?.length || 0}\n\n## Verification\n\n- Verifications: ${candidates.verification?.verifications?.length || 0}\n- Scenarios: ${candidates.verification?.scenarios?.length || 0}\n- Frameworks: ${candidates.verification?.frameworks?.length ? candidates.verification.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.verification?.scripts?.length || 0}\n`
|
|
58
61
|
);
|
|
59
62
|
}
|
|
@@ -96,7 +96,7 @@ function selectDetectionsForTrack(track, detections) {
|
|
|
96
96
|
*/
|
|
97
97
|
function initialCandidatesForTrack(track) {
|
|
98
98
|
if (track === "db") {
|
|
99
|
-
return { entities: [], enums: [], relations: [], indexes: [] };
|
|
99
|
+
return { entities: [], enums: [], relations: [], indexes: [], maintained_seams: [] };
|
|
100
100
|
}
|
|
101
101
|
if (track === "api") {
|
|
102
102
|
return { capabilities: [], routes: [], stacks: [] };
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
slugify,
|
|
11
11
|
titleCase
|
|
12
12
|
} from "../../core/shared.js";
|
|
13
|
+
import { inferDrizzleMaintainedDbSeams } from "./maintained-seams.js";
|
|
13
14
|
|
|
14
15
|
function splitTopLevelEntries(block) {
|
|
15
16
|
const entries = [];
|
|
@@ -165,21 +166,50 @@ function parseDrizzleTables(schemaText) {
|
|
|
165
166
|
};
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
function drizzleConfigFiles(context) {
|
|
170
|
+
return findImportFiles(context.paths, (filePath) => /drizzle\.config\.(ts|js|mjs|cjs)$/i.test(path.basename(filePath)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function configuredSchemaFiles(context, configFiles) {
|
|
174
|
+
const files = [];
|
|
175
|
+
for (const configFile of configFiles) {
|
|
176
|
+
const configText = context.helpers.readTextIfExists(configFile) || "";
|
|
177
|
+
for (const match of configText.matchAll(/\bschema\s*:\s*["'`]([^"'`*]+)["'`]/g)) {
|
|
178
|
+
const absoluteSchemaPath = path.resolve(path.dirname(configFile), match[1]);
|
|
179
|
+
if (context.helpers.readTextIfExists(absoluteSchemaPath) !== null) {
|
|
180
|
+
files.push(absoluteSchemaPath);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return files;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function findDrizzleSchemaFiles(context) {
|
|
188
|
+
const configFiles = drizzleConfigFiles(context);
|
|
189
|
+
const conventionalSchemaFiles = findImportFiles(context.paths, (filePath) =>
|
|
190
|
+
/(?:^|\/)(?:src\/db\/schema|src\/schema|db\/schema|schema)\.(ts|js|mjs|cjs)$/i.test(relativeTo(context.paths.workspaceRoot, filePath).replaceAll(path.sep, "/"))
|
|
191
|
+
);
|
|
192
|
+
return [...new Set([
|
|
193
|
+
...configuredSchemaFiles(context, configFiles),
|
|
194
|
+
...conventionalSchemaFiles
|
|
195
|
+
])].sort();
|
|
196
|
+
}
|
|
197
|
+
|
|
168
198
|
export const drizzleExtractor = {
|
|
169
199
|
id: "db.drizzle",
|
|
170
200
|
track: "db",
|
|
171
201
|
detect(context) {
|
|
172
|
-
const hasConfig =
|
|
173
|
-
const hasSchema =
|
|
202
|
+
const hasConfig = drizzleConfigFiles(context).length > 0;
|
|
203
|
+
const hasSchema = findDrizzleSchemaFiles(context).length > 0;
|
|
174
204
|
return {
|
|
175
205
|
score: hasConfig || hasSchema ? 95 : 0,
|
|
176
206
|
reasons: hasConfig || hasSchema ? ["Found Drizzle config/schema source"] : []
|
|
177
207
|
};
|
|
178
208
|
},
|
|
179
209
|
extract(context) {
|
|
180
|
-
const schemaFiles =
|
|
210
|
+
const schemaFiles = findDrizzleSchemaFiles(context);
|
|
181
211
|
const findings = [];
|
|
182
|
-
const candidates = { entities: [], enums: [], relations: [], indexes: [] };
|
|
212
|
+
const candidates = { entities: [], enums: [], relations: [], indexes: [], maintained_seams: [] };
|
|
183
213
|
for (const filePath of schemaFiles) {
|
|
184
214
|
const parsed = parseDrizzleTables(context.helpers.readTextIfExists(filePath) || "");
|
|
185
215
|
const provenance = relativeTo(context.paths.repoRoot, filePath);
|
|
@@ -237,6 +267,7 @@ export const drizzleExtractor = {
|
|
|
237
267
|
candidates.enums = dedupeCandidateRecords(candidates.enums, (record) => record.id_hint);
|
|
238
268
|
candidates.relations = dedupeCandidateRecords(candidates.relations, (record) => record.id_hint);
|
|
239
269
|
candidates.indexes = dedupeCandidateRecords(candidates.indexes, (record) => record.id_hint);
|
|
270
|
+
candidates.maintained_seams = inferDrizzleMaintainedDbSeams(context, schemaFiles);
|
|
240
271
|
return { findings, candidates };
|
|
241
272
|
}
|
|
242
273
|
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { findImportFiles, 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
|
+
|
|
90
|
+
return makeCandidateRecord({
|
|
91
|
+
kind: "maintained_db_migration_seam",
|
|
92
|
+
idHint,
|
|
93
|
+
label: `${options.tool.toUpperCase()} maintained database migrations`,
|
|
94
|
+
confidence: options.missingDecisions.length === 0 ? "high" : "medium",
|
|
95
|
+
sourceKind: "migration_strategy_inference",
|
|
96
|
+
sourceOfTruth: "candidate",
|
|
97
|
+
provenance: options.evidence,
|
|
98
|
+
track: "db",
|
|
99
|
+
seam_id: idHint,
|
|
100
|
+
output_id: "maintained_app",
|
|
101
|
+
ownership_class: "human_owned",
|
|
102
|
+
status: "review_required",
|
|
103
|
+
tool: options.tool,
|
|
104
|
+
ownership: "maintained",
|
|
105
|
+
apply: "never",
|
|
106
|
+
schemaPath: options.schemaPath || null,
|
|
107
|
+
migrationsPath: options.migrationsPath || null,
|
|
108
|
+
snapshotPath,
|
|
109
|
+
runtime_id_hint: runtimeId,
|
|
110
|
+
projection_id_hint: projectionId,
|
|
111
|
+
evidence: options.evidence,
|
|
112
|
+
match_reasons: options.matchReasons,
|
|
113
|
+
missing_decisions: options.missingDecisions,
|
|
114
|
+
proposed_runtime_migration: proposedRuntimeMigration,
|
|
115
|
+
maintained_modules: [options.schemaPath, options.migrationsPath].filter(Boolean),
|
|
116
|
+
emitted_dependencies: [snapshotPath, projectionId],
|
|
117
|
+
allowed_change_classes: ["proposal_only"],
|
|
118
|
+
drift_signals: ["schema_or_migration_changed", "migration_directory_changed"]
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @param {any} context @param {string[]} prismaFiles @returns {any[]} */
|
|
123
|
+
export function inferPrismaMaintainedDbSeams(context, prismaFiles) {
|
|
124
|
+
if (!prismaFiles.length) {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
const schemaPath = appRelativePath(context, prismaFiles[0]);
|
|
128
|
+
const migrationFiles = /** @type {string[]} */ (findImportFiles(context.paths, /** @param {string} filePath */ (filePath) => toPosix(filePath).includes("/prisma/migrations/")));
|
|
129
|
+
const migrationsPath = firstMarkedDirectory(context, migrationFiles, [["prisma", "migrations"]]);
|
|
130
|
+
return [
|
|
131
|
+
maintainedDbSeamCandidate(context, {
|
|
132
|
+
tool: "prisma",
|
|
133
|
+
schemaPath,
|
|
134
|
+
migrationsPath,
|
|
135
|
+
evidence: [
|
|
136
|
+
...prismaFiles.map((filePath) => evidencePath(context, filePath)),
|
|
137
|
+
...migrationFiles.slice(0, 3).map(/** @param {string} filePath */ (filePath) => evidencePath(context, filePath))
|
|
138
|
+
],
|
|
139
|
+
matchReasons: [
|
|
140
|
+
"found Prisma schema",
|
|
141
|
+
...(migrationsPath ? ["found Prisma migrations directory"] : [])
|
|
142
|
+
],
|
|
143
|
+
missingDecisions: migrationsPath ? [] : ["confirm Prisma migrationsPath before adding this strategy to topogram.project.json"]
|
|
144
|
+
})
|
|
145
|
+
];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** @param {any} context @param {string[]} schemaFiles @returns {any[]} */
|
|
149
|
+
export function inferDrizzleMaintainedDbSeams(context, schemaFiles) {
|
|
150
|
+
if (!schemaFiles.length) {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
const configFiles = /** @type {string[]} */ (findImportFiles(context.paths, /** @param {string} filePath */ (filePath) => /drizzle\.config\.(ts|js|mjs|cjs)$/i.test(path.basename(filePath))));
|
|
154
|
+
const drizzleFiles = /** @type {string[]} */ (findImportFiles(context.paths, /** @param {string} filePath */ (filePath) => appRelativePath(context, filePath).startsWith("drizzle/")));
|
|
155
|
+
const configuredOutPath = drizzleOutPathFromConfig(context, configFiles);
|
|
156
|
+
const migrationsPath = configuredOutPath ||
|
|
157
|
+
firstMarkedDirectory(context, drizzleFiles, [["drizzle"]]);
|
|
158
|
+
return [
|
|
159
|
+
maintainedDbSeamCandidate(context, {
|
|
160
|
+
tool: "drizzle",
|
|
161
|
+
schemaPath: appRelativePath(context, schemaFiles[0]),
|
|
162
|
+
migrationsPath,
|
|
163
|
+
evidence: [
|
|
164
|
+
...schemaFiles.map((filePath) => evidencePath(context, filePath)),
|
|
165
|
+
...configFiles.map(/** @param {string} filePath */ (filePath) => evidencePath(context, filePath)),
|
|
166
|
+
...drizzleFiles.slice(0, 3).map(/** @param {string} filePath */ (filePath) => evidencePath(context, filePath))
|
|
167
|
+
],
|
|
168
|
+
matchReasons: [
|
|
169
|
+
"found Drizzle schema source",
|
|
170
|
+
...(configFiles.length ? ["found Drizzle config"] : []),
|
|
171
|
+
...(migrationsPath ? ["found Drizzle migrations output"] : [])
|
|
172
|
+
],
|
|
173
|
+
missingDecisions: migrationsPath ? [] : ["confirm Drizzle migrationsPath before adding this strategy to topogram.project.json"]
|
|
174
|
+
})
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** @param {any} context @param {string[]} allSqlFiles @param {string[]} selectedSqlFiles @returns {any[]} */
|
|
179
|
+
export function inferSqlMaintainedDbSeams(context, allSqlFiles, selectedSqlFiles) {
|
|
180
|
+
if (!allSqlFiles.length) {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
const schemaFile = selectedSqlFiles.find((filePath) => !/migration/i.test(path.basename(filePath))) ||
|
|
184
|
+
allSqlFiles.find((filePath) => /schema/i.test(path.basename(filePath))) ||
|
|
185
|
+
null;
|
|
186
|
+
const migrationFiles = allSqlFiles.filter((filePath) => {
|
|
187
|
+
const relativePath = appRelativePath(context, filePath);
|
|
188
|
+
return Boolean(migrationDirectoryFromRelativePath(relativePath)) || /migration/i.test(path.basename(filePath));
|
|
189
|
+
});
|
|
190
|
+
const migrationsPath = firstMarkedDirectory(context, migrationFiles, [["migrations"], ["migration"]]) ||
|
|
191
|
+
(migrationFiles.length ? toPosix(path.dirname(appRelativePath(context, migrationFiles[0]))) : null);
|
|
192
|
+
return [
|
|
193
|
+
maintainedDbSeamCandidate(context, {
|
|
194
|
+
tool: "sql",
|
|
195
|
+
schemaPath: schemaFile ? appRelativePath(context, schemaFile) : null,
|
|
196
|
+
migrationsPath,
|
|
197
|
+
evidence: [
|
|
198
|
+
...(schemaFile ? [evidencePath(context, schemaFile)] : []),
|
|
199
|
+
...migrationFiles.slice(0, 3).map((filePath) => evidencePath(context, filePath))
|
|
200
|
+
],
|
|
201
|
+
matchReasons: [
|
|
202
|
+
...(schemaFile ? ["found SQL schema"] : []),
|
|
203
|
+
...(migrationsPath ? ["found SQL migrations directory or migration file"] : [])
|
|
204
|
+
],
|
|
205
|
+
missingDecisions: migrationsPath ? [] : ["confirm SQL migrationsPath before adding this strategy to topogram.project.json"]
|
|
206
|
+
})
|
|
207
|
+
];
|
|
208
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
titleCase,
|
|
9
9
|
idHintify
|
|
10
10
|
} from "../../core/shared.js";
|
|
11
|
+
import { inferPrismaMaintainedDbSeams } from "./maintained-seams.js";
|
|
11
12
|
|
|
12
13
|
function parsePrismaSchema(schemaText) {
|
|
13
14
|
const enums = [];
|
|
@@ -125,7 +126,7 @@ export const prismaExtractor = {
|
|
|
125
126
|
"prisma"
|
|
126
127
|
);
|
|
127
128
|
const findings = [];
|
|
128
|
-
const candidates = { entities: [], enums: [], relations: [], indexes: [] };
|
|
129
|
+
const candidates = { entities: [], enums: [], relations: [], indexes: [], maintained_seams: [] };
|
|
129
130
|
for (const filePath of prismaFiles) {
|
|
130
131
|
const parsed = parsePrismaSchema(context.helpers.readTextIfExists(filePath) || "");
|
|
131
132
|
const provenance = relativeTo(context.paths.repoRoot, filePath);
|
|
@@ -179,7 +180,7 @@ export const prismaExtractor = {
|
|
|
179
180
|
track: "db"
|
|
180
181
|
})));
|
|
181
182
|
}
|
|
183
|
+
candidates.maintained_seams = inferPrismaMaintainedDbSeams(context, prismaFiles);
|
|
182
184
|
return { findings, candidates };
|
|
183
185
|
}
|
|
184
186
|
};
|
|
185
|
-
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { canonicalCandidateTerm, findImportFiles, 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();
|
|
@@ -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
|
};
|
|
@@ -81,7 +81,7 @@ export function bestContextBundleForCandidate(bundles, candidate) {
|
|
|
81
81
|
|
|
82
82
|
/** @param {ResolvedGraph} graph @param {ImportArtifacts} appImport @param {any} topogramRoot @returns {any} */
|
|
83
83
|
export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
|
|
84
|
-
const dbCandidates = appImport.candidates.db || { entities: [], enums: [] };
|
|
84
|
+
const dbCandidates = appImport.candidates.db || { entities: [], enums: [], maintained_seams: [] };
|
|
85
85
|
const apiCandidates = appImport.candidates.api || { capabilities: [] };
|
|
86
86
|
const uiCandidates = appImport.candidates.ui || { screens: [], routes: [], actions: [], widgets: [], shapes: [] };
|
|
87
87
|
const uiWidgetCandidates = uiCandidates.widgets || uiCandidates.components || [];
|
|
@@ -151,6 +151,10 @@ export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
|
|
|
151
151
|
bundles.delete(`enum_${enumId}`);
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
|
+
for (const seam of dbCandidates.maintained_seams || []) {
|
|
155
|
+
const bundle = getOrCreateCandidateBundle(bundles, "database", "Database");
|
|
156
|
+
bundle.maintainedSeams = [...(bundle.maintainedSeams || []), seam];
|
|
157
|
+
}
|
|
154
158
|
for (const entry of apiCandidates.capabilities || []) {
|
|
155
159
|
const matchedCapability = graph ? matchImportedApiCapability(entry, topogramApiCapabilities) : null;
|
|
156
160
|
if (matchedCapability) {
|
|
@@ -333,7 +337,8 @@ export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
|
|
|
333
337
|
bundle.workflows.length > 0 ||
|
|
334
338
|
bundle.verifications.length > 0 ||
|
|
335
339
|
bundle.workflowStates.length > 0 ||
|
|
336
|
-
bundle.workflowTransitions.length > 0
|
|
340
|
+
bundle.workflowTransitions.length > 0 ||
|
|
341
|
+
(bundle.maintainedSeams || []).length > 0
|
|
337
342
|
)
|
|
338
343
|
.map((/** @type {any} */ bundle) => {
|
|
339
344
|
const sortedBundle = {
|
|
@@ -353,6 +358,7 @@ export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
|
|
|
353
358
|
verifications: bundle.verifications.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
|
|
354
359
|
workflowStates: bundle.workflowStates.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
|
|
355
360
|
workflowTransitions: bundle.workflowTransitions.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
|
|
361
|
+
maintainedSeams: (bundle.maintainedSeams || []).sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
|
|
356
362
|
docs: bundle.docs.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id.localeCompare(b.id))
|
|
357
363
|
};
|
|
358
364
|
const mergeHints = buildBundleMergeHints(sortedBundle, canonicalEntityIds);
|
|
@@ -91,6 +91,7 @@ export function reconcileWorkflow(inputPath, options = {}) {
|
|
|
91
91
|
type: "reconcile_adoption_plan",
|
|
92
92
|
workspace: paths.topogramRoot,
|
|
93
93
|
approved_review_groups: [...new Set(existingPlan?.approved_review_groups || [])],
|
|
94
|
+
imported_maintained_db_seams: appImport.candidates?.db?.maintained_seams || [],
|
|
94
95
|
items: mergedPlanItems,
|
|
95
96
|
projection_review_groups: buildProjectionReviewGroups(mergedPlanItems),
|
|
96
97
|
ui_review_groups: buildUiReviewGroups(mergedPlanItems),
|