@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.
- package/package.json +1 -1
- package/src/adoption/plan.d.ts +6 -0
- package/src/adoption/reporting.d.ts +10 -0
- package/src/adoption/review-groups.d.ts +6 -0
- package/src/agent-brief.d.ts +3 -0
- package/src/agent-brief.js +495 -0
- package/src/agent-ops/query-builders.d.ts +26 -0
- package/src/archive/archive.d.ts +2 -0
- package/src/archive/compact.d.ts +1 -0
- package/src/archive/unarchive.d.ts +1 -0
- package/src/catalog.d.ts +10 -0
- package/src/catalog.js +62 -66
- package/src/cli/catalog-alias.d.ts +1 -0
- package/src/cli/command-parser.js +38 -0
- package/src/cli/command-parsers/core.js +102 -0
- package/src/cli/command-parsers/generator.js +39 -0
- package/src/cli/command-parsers/import.js +44 -0
- package/src/cli/command-parsers/legacy-workflow.js +21 -0
- package/src/cli/command-parsers/project.js +47 -0
- package/src/cli/command-parsers/sdlc.js +47 -0
- package/src/cli/command-parsers/shared.js +51 -0
- package/src/cli/command-parsers/template.js +48 -0
- package/src/cli/commands/agent.js +47 -0
- package/src/cli/commands/catalog.js +617 -0
- package/src/cli/commands/check.js +268 -0
- package/src/cli/commands/doctor.js +268 -0
- package/src/cli/commands/emit.js +149 -0
- package/src/cli/commands/generate.js +96 -0
- package/src/cli/commands/generator-policy.js +785 -0
- package/src/cli/commands/generator.js +443 -0
- package/src/cli/commands/import-runner.js +157 -0
- package/src/cli/commands/import.js +1734 -0
- package/src/cli/commands/inspect.js +55 -0
- package/src/cli/commands/new.js +94 -0
- package/src/cli/commands/package.js +815 -0
- package/src/cli/commands/query.js +1302 -0
- package/src/cli/commands/release-rollout.js +257 -0
- package/src/cli/commands/release-shared.js +528 -0
- package/src/cli/commands/release-status.js +429 -0
- package/src/cli/commands/release.js +107 -0
- package/src/cli/commands/sdlc.js +168 -0
- package/src/cli/commands/setup.js +76 -0
- package/src/cli/commands/source.js +291 -0
- package/src/cli/commands/template-runner.js +198 -0
- package/src/cli/commands/template.js +2145 -0
- package/src/cli/commands/trust.js +219 -0
- package/src/cli/commands/version.js +40 -0
- package/src/cli/commands/widget.js +168 -0
- package/src/cli/commands/workflow.js +63 -0
- package/src/cli/dispatcher.js +392 -0
- package/src/cli/help-dispatch.js +188 -0
- package/src/cli/help.js +296 -0
- package/src/cli/migration-guidance.js +59 -0
- package/src/cli/options.js +96 -0
- package/src/cli/output-safety.js +107 -0
- package/src/cli/path-normalization.js +29 -0
- package/src/cli.js +47 -11711
- package/src/example-implementation.d.ts +2 -0
- package/src/format.d.ts +1 -0
- package/src/generator/check.d.ts +1 -0
- package/src/generator/context/bundle.d.ts +1 -0
- package/src/generator/context/shared.d.ts +2 -0
- package/src/generator/native/parity-bundle.js +2 -1
- package/src/generator/surfaces/web/html-escape.js +22 -0
- package/src/generator/surfaces/web/react.js +10 -8
- package/src/generator/surfaces/web/sveltekit.js +7 -5
- package/src/generator/surfaces/web/vanilla.js +8 -4
- package/src/generator.d.ts +2 -0
- package/src/github-client.js +520 -0
- package/src/import/core/shared.js +20 -62
- package/src/import/extractors/api/flutter-dio.js +4 -8
- package/src/import/extractors/api/react-native-repository.js +4 -8
- package/src/import/index.d.ts +4 -0
- package/src/import/provenance.d.ts +4 -0
- package/src/new-project.js +100 -11
- package/src/npm-safety.js +79 -0
- package/src/parser.d.ts +1 -0
- package/src/path-helpers.d.ts +1 -0
- package/src/path-helpers.js +20 -0
- package/src/project-config.js +1 -0
- package/src/reconcile/docs.d.ts +8 -0
- package/src/reconcile/journeys.d.ts +1 -0
- package/src/resolver.d.ts +1 -0
- package/src/runtime-support.js +29 -0
- package/src/sdlc/adopt.d.ts +1 -0
- package/src/sdlc/check.d.ts +1 -0
- package/src/sdlc/explain.d.ts +1 -0
- package/src/sdlc/release.d.ts +1 -0
- package/src/sdlc/scaffold.d.ts +1 -0
- package/src/sdlc/transition.d.ts +1 -0
- package/src/text-helpers.d.ts +6 -0
- package/src/text-helpers.js +245 -0
- package/src/topogram-config.js +306 -0
- package/src/validator.d.ts +2 -0
- package/src/workflows/adoption/index.js +26 -0
- package/src/workflows/docs-generate.js +262 -0
- package/src/workflows/docs-scan.js +703 -0
- package/src/workflows/docs.js +15 -0
- package/src/workflows/import-app/api.js +799 -0
- package/src/workflows/import-app/db.js +538 -0
- package/src/workflows/import-app/index.js +30 -0
- package/src/workflows/import-app/shared.js +218 -0
- package/src/workflows/import-app/ui.js +443 -0
- package/src/workflows/import-app/workflow.js +159 -0
- package/src/workflows/reconcile/adoption-plan.js +742 -0
- package/src/workflows/reconcile/auth.js +692 -0
- package/src/workflows/reconcile/bundle-core.js +600 -0
- package/src/workflows/reconcile/bundle-shared.js +75 -0
- package/src/workflows/reconcile/candidate-model.js +477 -0
- package/src/workflows/reconcile/canonical-surface.js +264 -0
- package/src/workflows/reconcile/gap-report.js +333 -0
- package/src/workflows/reconcile/ids.js +6 -0
- package/src/workflows/reconcile/impacts.js +625 -0
- package/src/workflows/reconcile/index.js +7 -0
- package/src/workflows/reconcile/renderers.js +461 -0
- package/src/workflows/reconcile/summary.js +90 -0
- package/src/workflows/reconcile/workflow.js +309 -0
- package/src/workflows/shared.js +189 -0
- package/src/workflows/types.d.ts +93 -0
- package/src/workflows.d.ts +1 -0
- package/src/workflows.js +10 -7652
|
@@ -0,0 +1,1734 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
import { buildOutputFiles } from "../../generator.js";
|
|
9
|
+
import { stableStringify } from "../../format.js";
|
|
10
|
+
import { parsePath } from "../../parser.js";
|
|
11
|
+
import { resolveWorkspace } from "../../resolver.js";
|
|
12
|
+
import { formatValidationErrors, validateWorkspace } from "../../validator.js";
|
|
13
|
+
import { runWorkflow } from "../../workflows.js";
|
|
14
|
+
import {
|
|
15
|
+
buildTopogramImportStatus,
|
|
16
|
+
collectImportSourceFileRecords,
|
|
17
|
+
TOPOGRAM_IMPORT_FILE,
|
|
18
|
+
writeTopogramImportRecord
|
|
19
|
+
} from "../../import/provenance.js";
|
|
20
|
+
import {
|
|
21
|
+
loadProjectConfig,
|
|
22
|
+
validateProjectConfig,
|
|
23
|
+
validateProjectOutputOwnership
|
|
24
|
+
} from "../../project-config.js";
|
|
25
|
+
import { validateProjectImplementationTrust } from "../../template-trust.js";
|
|
26
|
+
import { shellCommandArg } from "./catalog.js";
|
|
27
|
+
import { CLI_PACKAGE_NAME, readInstalledCliPackageVersion } from "./package.js";
|
|
28
|
+
|
|
29
|
+
const TOPOGRAM_IMPORT_ADOPTIONS_FILE = ".topogram-import-adoptions.jsonl";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {Record<string, any>} AnyRecord
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} inputPath
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function normalizeTopogramPath(inputPath) {
|
|
40
|
+
const absolute = path.resolve(inputPath);
|
|
41
|
+
if (path.basename(absolute) === "topogram") {
|
|
42
|
+
return absolute;
|
|
43
|
+
}
|
|
44
|
+
const candidate = path.join(absolute, "topogram");
|
|
45
|
+
return fs.existsSync(candidate) ? candidate : absolute;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} inputPath
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function normalizeProjectRoot(inputPath) {
|
|
53
|
+
const absolute = path.resolve(inputPath);
|
|
54
|
+
if (path.basename(absolute) === "topogram") {
|
|
55
|
+
return path.dirname(absolute);
|
|
56
|
+
}
|
|
57
|
+
return absolute;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {...{ ok: boolean, errors?: any[] }|null|undefined} results
|
|
62
|
+
* @returns {{ ok: boolean, errors: any[] }}
|
|
63
|
+
*/
|
|
64
|
+
function combineProjectValidationResults(...results) {
|
|
65
|
+
const errors = [];
|
|
66
|
+
for (const result of results) {
|
|
67
|
+
errors.push(...(result?.errors || []));
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
ok: errors.length === 0,
|
|
71
|
+
errors
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {AnyRecord} component
|
|
77
|
+
* @returns {{ uses_api: string|null, uses_database: string|null }}
|
|
78
|
+
*/
|
|
79
|
+
function topologyComponentReferences(component) {
|
|
80
|
+
return {
|
|
81
|
+
uses_api: component.uses_api || null,
|
|
82
|
+
uses_database: component.uses_database || null
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {AnyRecord} component
|
|
88
|
+
* @returns {any}
|
|
89
|
+
*/
|
|
90
|
+
function topologyComponentPort(component) {
|
|
91
|
+
return Object.prototype.hasOwnProperty.call(component, "port") ? component.port : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {AnyRecord|null|undefined} config
|
|
96
|
+
* @returns {{ outputs: any[], runtimes: any[], edges: any[] }}
|
|
97
|
+
*/
|
|
98
|
+
function summarizeProjectTopology(config) {
|
|
99
|
+
const outputs = Object.entries(config?.outputs || {})
|
|
100
|
+
.map(([name, output]) => ({
|
|
101
|
+
name,
|
|
102
|
+
path: output?.path || null,
|
|
103
|
+
ownership: output?.ownership || null
|
|
104
|
+
}))
|
|
105
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
106
|
+
const runtimes = (config?.topology?.runtimes || [])
|
|
107
|
+
.map((/** @type {AnyRecord} */ component) => ({
|
|
108
|
+
id: component.id,
|
|
109
|
+
kind: component.kind,
|
|
110
|
+
projection: component.projection,
|
|
111
|
+
generator: {
|
|
112
|
+
id: component.generator?.id || null,
|
|
113
|
+
version: component.generator?.version || null
|
|
114
|
+
},
|
|
115
|
+
port: topologyComponentPort(component),
|
|
116
|
+
references: topologyComponentReferences(component)
|
|
117
|
+
}))
|
|
118
|
+
.sort((/** @type {AnyRecord} */ left, /** @type {AnyRecord} */ right) => left.id.localeCompare(right.id));
|
|
119
|
+
const edges = runtimes.flatMap((/** @type {AnyRecord} */ component) => {
|
|
120
|
+
const references = [];
|
|
121
|
+
if (component.references.uses_api) {
|
|
122
|
+
references.push({
|
|
123
|
+
from: component.id,
|
|
124
|
+
to: component.references.uses_api,
|
|
125
|
+
type: "calls_api"
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (component.references.uses_database) {
|
|
129
|
+
references.push({
|
|
130
|
+
from: component.id,
|
|
131
|
+
to: component.references.uses_database,
|
|
132
|
+
type: "uses_database"
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return references;
|
|
136
|
+
}).sort((/** @type {AnyRecord} */ left, /** @type {AnyRecord} */ right) => `${left.from}:${left.type}:${left.to}`.localeCompare(`${right.from}:${right.type}:${right.to}`));
|
|
137
|
+
return {
|
|
138
|
+
outputs,
|
|
139
|
+
runtimes,
|
|
140
|
+
edges
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {AnyRecord|null|undefined} topology
|
|
146
|
+
* @returns {AnyRecord|null}
|
|
147
|
+
*/
|
|
148
|
+
function publicProjectTopology(topology) {
|
|
149
|
+
if (!topology || typeof topology !== "object") {
|
|
150
|
+
return topology || null;
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
...Object.fromEntries(Object.entries(topology).filter(([key]) => key !== "components")),
|
|
154
|
+
runtimes: topology.runtimes || []
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {{ inputPath: string, ast: AnyRecord, resolved: AnyRecord, projectConfigInfo: AnyRecord|null, projectValidation: { ok: boolean, errors: any[] } }} input
|
|
160
|
+
* @returns {AnyRecord}
|
|
161
|
+
*/
|
|
162
|
+
function checkSummaryPayload({ inputPath, ast, resolved, projectConfigInfo, projectValidation }) {
|
|
163
|
+
const statementCount = ast.files.flatMap((/** @type {{ statements: any[] }} */ file) => file.statements).length;
|
|
164
|
+
const projectInfo = projectConfigInfo || {
|
|
165
|
+
configPath: null,
|
|
166
|
+
compatibility: false,
|
|
167
|
+
config: { topology: null }
|
|
168
|
+
};
|
|
169
|
+
const resolvedTopology = summarizeProjectTopology(projectInfo.config);
|
|
170
|
+
return {
|
|
171
|
+
ok: resolved.ok && projectValidation.ok,
|
|
172
|
+
inputPath,
|
|
173
|
+
topogram: {
|
|
174
|
+
files: ast.files.length,
|
|
175
|
+
statements: statementCount,
|
|
176
|
+
valid: resolved.ok
|
|
177
|
+
},
|
|
178
|
+
project: {
|
|
179
|
+
configPath: projectInfo.configPath,
|
|
180
|
+
compatibility: Boolean(projectInfo.compatibility),
|
|
181
|
+
valid: projectValidation.ok,
|
|
182
|
+
topology: publicProjectTopology(projectInfo.config.topology),
|
|
183
|
+
resolvedTopology
|
|
184
|
+
},
|
|
185
|
+
errors: [
|
|
186
|
+
...(resolved.ok ? [] : resolved.validation.errors.map((/** @type {AnyRecord} */ error) => ({
|
|
187
|
+
source: "topogram",
|
|
188
|
+
message: error.message,
|
|
189
|
+
loc: error.loc
|
|
190
|
+
}))),
|
|
191
|
+
...projectValidation.errors.map((error) => ({
|
|
192
|
+
source: "project",
|
|
193
|
+
message: error.message,
|
|
194
|
+
loc: error.loc
|
|
195
|
+
}))
|
|
196
|
+
]
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {string} filePath
|
|
202
|
+
* @returns {{ sha256: string, size: number }}
|
|
203
|
+
*/
|
|
204
|
+
function projectFileHash(filePath) {
|
|
205
|
+
const bytes = fs.readFileSync(filePath);
|
|
206
|
+
return {
|
|
207
|
+
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
208
|
+
size: bytes.length
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function printImportHelp() {
|
|
213
|
+
console.log("Usage: topogram import <app-path> --out <target> [--from <track[,track]>] [--json]");
|
|
214
|
+
console.log(" or: topogram import refresh [path] [--from <app-path>] [--dry-run] [--json]");
|
|
215
|
+
console.log(" or: topogram import diff [path] [--json]");
|
|
216
|
+
console.log(" or: topogram import check [path] [--json]");
|
|
217
|
+
console.log(" or: topogram import plan [path] [--json]");
|
|
218
|
+
console.log(" or: topogram import adopt --list [path] [--json]");
|
|
219
|
+
console.log(" or: topogram import adopt <selector> [path] [--dry-run|--write] [--force --reason <text>] [--json]");
|
|
220
|
+
console.log(" or: topogram import status [path] [--json]");
|
|
221
|
+
console.log(" or: topogram import history [path] [--verify] [--json]");
|
|
222
|
+
console.log("");
|
|
223
|
+
console.log("Creates an editable Topogram workspace from a brownfield app without modifying the app.");
|
|
224
|
+
console.log("");
|
|
225
|
+
console.log("Behavior:");
|
|
226
|
+
console.log(" - writes raw import candidates under topogram/candidates/app");
|
|
227
|
+
console.log(" - writes reconcile proposal bundles under topogram/candidates/reconcile");
|
|
228
|
+
console.log(" - writes topogram.project.json with maintained ownership and no generated stack binding");
|
|
229
|
+
console.log(` - writes ${TOPOGRAM_IMPORT_FILE} with source file hashes from import time`);
|
|
230
|
+
console.log(" - imported Topogram artifacts are project-owned after creation");
|
|
231
|
+
console.log(" - refresh rewrites only candidate/reconcile artifacts and source provenance");
|
|
232
|
+
console.log(" - adoption previews never write canonical Topogram files unless --write is passed");
|
|
233
|
+
console.log(" - adoption writes refuse dirty brownfield source provenance unless --force is passed");
|
|
234
|
+
console.log(` - adoption writes append audit receipts to ${TOPOGRAM_IMPORT_ADOPTIONS_FILE}`);
|
|
235
|
+
console.log(" - forced adoption writes require --reason <text>");
|
|
236
|
+
console.log("");
|
|
237
|
+
console.log("Examples:");
|
|
238
|
+
console.log(" topogram import ./existing-app --out ./imported-topogram");
|
|
239
|
+
console.log(" topogram import ./existing-app --out ./imported-topogram --from db,api,ui");
|
|
240
|
+
console.log(" topogram import diff ./imported-topogram");
|
|
241
|
+
console.log(" topogram import refresh ./imported-topogram --from ./existing-app --dry-run");
|
|
242
|
+
console.log(" topogram import refresh ./imported-topogram --from ./existing-app");
|
|
243
|
+
console.log(" topogram import check ./imported-topogram");
|
|
244
|
+
console.log(" topogram import plan ./imported-topogram");
|
|
245
|
+
console.log(" topogram import adopt --list ./imported-topogram");
|
|
246
|
+
console.log(" topogram import adopt bundle:task ./imported-topogram --dry-run");
|
|
247
|
+
console.log(" topogram import adopt bundle:task ./imported-topogram --write");
|
|
248
|
+
console.log(" topogram import adopt bundle:task ./imported-topogram --write --force --reason \"Reviewed source drift\"");
|
|
249
|
+
console.log(" topogram import status ./imported-topogram");
|
|
250
|
+
console.log(" topogram import history ./imported-topogram");
|
|
251
|
+
console.log(" topogram import history ./imported-topogram --verify");
|
|
252
|
+
console.log(" topogram import check --json");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @param {string} targetPath
|
|
257
|
+
* @returns {void}
|
|
258
|
+
*/
|
|
259
|
+
function ensureEmptyImportTarget(targetPath) {
|
|
260
|
+
if (!fs.existsSync(targetPath)) {
|
|
261
|
+
fs.mkdirSync(targetPath, { recursive: true });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (!fs.statSync(targetPath).isDirectory()) {
|
|
265
|
+
throw new Error(`Cannot import into non-directory path '${targetPath}'.`);
|
|
266
|
+
}
|
|
267
|
+
const entries = fs.readdirSync(targetPath).filter((/** @type {string} */ entry) => entry !== ".DS_Store");
|
|
268
|
+
if (entries.length > 0) {
|
|
269
|
+
throw new Error(`Refusing to import into non-empty directory '${targetPath}'.`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {string} outDir
|
|
275
|
+
* @param {Record<string, any>} files
|
|
276
|
+
* @returns {string[]}
|
|
277
|
+
*/
|
|
278
|
+
function writeRelativeFiles(outDir, files) {
|
|
279
|
+
const written = [];
|
|
280
|
+
for (const [relativePath, contents] of Object.entries(files || {})) {
|
|
281
|
+
const normalizedRelativePath = relativePath.replaceAll(path.sep, "/");
|
|
282
|
+
const destination = path.join(outDir, normalizedRelativePath);
|
|
283
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
284
|
+
fs.writeFileSync(destination, typeof contents === "string" ? contents : `${stableStringify(contents)}\n`, "utf8");
|
|
285
|
+
written.push(normalizedRelativePath);
|
|
286
|
+
}
|
|
287
|
+
return written.sort((a, b) => a.localeCompare(b));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* @returns {Record<string, any>}
|
|
292
|
+
*/
|
|
293
|
+
function importedProjectConfig() {
|
|
294
|
+
return {
|
|
295
|
+
version: "0.1",
|
|
296
|
+
outputs: {
|
|
297
|
+
maintained_app: {
|
|
298
|
+
path: "./app",
|
|
299
|
+
ownership: "maintained"
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
topology: {
|
|
303
|
+
runtimes: []
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* @param {string} sourceRoot
|
|
310
|
+
* @param {string} targetRoot
|
|
311
|
+
* @param {ReturnType<typeof runWorkflow>["summary"]} importSummary
|
|
312
|
+
* @returns {string}
|
|
313
|
+
*/
|
|
314
|
+
function importedWorkspaceReadme(sourceRoot, targetRoot, importSummary) {
|
|
315
|
+
return [
|
|
316
|
+
"# Imported Topogram Workspace",
|
|
317
|
+
"",
|
|
318
|
+
"This workspace was created from a brownfield app import.",
|
|
319
|
+
"",
|
|
320
|
+
`- Imported source: \`${sourceRoot}\``,
|
|
321
|
+
`- Target workspace: \`${targetRoot}\``,
|
|
322
|
+
`- Tracks: ${(importSummary.tracks || []).join(", ") || "none"}`,
|
|
323
|
+
`- Provenance: \`${TOPOGRAM_IMPORT_FILE}\``,
|
|
324
|
+
"",
|
|
325
|
+
"Imported Topogram artifacts are project-owned after creation. Edit them directly, promote candidates deliberately, and run `topogram check` before generation or maintained-app work.",
|
|
326
|
+
"",
|
|
327
|
+
"Useful commands:",
|
|
328
|
+
"",
|
|
329
|
+
"```sh",
|
|
330
|
+
"topogram import check",
|
|
331
|
+
"topogram check",
|
|
332
|
+
"topogram query import-plan ./topogram",
|
|
333
|
+
"```",
|
|
334
|
+
""
|
|
335
|
+
].join("\n");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* @param {Record<string, any>} summary
|
|
340
|
+
* @returns {Record<string, number>}
|
|
341
|
+
*/
|
|
342
|
+
function importCandidateCounts(summary) {
|
|
343
|
+
const candidates = summary.candidates || {};
|
|
344
|
+
return {
|
|
345
|
+
dbEntities: candidates.db?.entities?.length || 0,
|
|
346
|
+
dbEnums: candidates.db?.enums?.length || 0,
|
|
347
|
+
apiCapabilities: candidates.api?.capabilities?.length || 0,
|
|
348
|
+
apiRoutes: candidates.api?.routes?.length || 0,
|
|
349
|
+
uiScreens: candidates.ui?.screens?.length || 0,
|
|
350
|
+
uiRoutes: candidates.ui?.routes?.length || 0,
|
|
351
|
+
uiWidgets: candidates.ui?.widgets?.length || candidates.ui?.components?.length || 0,
|
|
352
|
+
workflows: candidates.workflows?.workflows?.length || 0,
|
|
353
|
+
verifications: candidates.verification?.verifications?.length || 0
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* @param {string} rootPath
|
|
359
|
+
* @returns {number}
|
|
360
|
+
*/
|
|
361
|
+
function countFilesRecursive(rootPath) {
|
|
362
|
+
if (!fs.existsSync(rootPath)) {
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
|
365
|
+
let count = 0;
|
|
366
|
+
for (const entry of fs.readdirSync(rootPath, { withFileTypes: true })) {
|
|
367
|
+
const childPath = path.join(rootPath, entry.name);
|
|
368
|
+
if (entry.isDirectory()) {
|
|
369
|
+
count += countFilesRecursive(childPath);
|
|
370
|
+
} else if (entry.isFile()) {
|
|
371
|
+
count += 1;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return count;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @param {string} projectRoot
|
|
379
|
+
* @returns {{ path: string, record: Record<string, any> }}
|
|
380
|
+
*/
|
|
381
|
+
function readTopogramImportRecord(projectRoot) {
|
|
382
|
+
const importPath = path.join(normalizeProjectRoot(projectRoot), TOPOGRAM_IMPORT_FILE);
|
|
383
|
+
if (!fs.existsSync(importPath)) {
|
|
384
|
+
throw new Error(`No brownfield import provenance found at '${importPath}'. Run 'topogram import <app-path> --out <target>' first.`);
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
return { path: importPath, record: JSON.parse(fs.readFileSync(importPath, "utf8")) };
|
|
388
|
+
} catch (error) {
|
|
389
|
+
throw new Error(`Invalid brownfield import provenance JSON at '${importPath}'.`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* @param {Record<string, any>} importRecord
|
|
395
|
+
* @returns {string|null}
|
|
396
|
+
*/
|
|
397
|
+
function importTrackValueFromRecord(importRecord) {
|
|
398
|
+
const tracks = Array.isArray(importRecord.import?.tracks)
|
|
399
|
+
? importRecord.import.tracks.map((/** @type {any} */ track) => String(track).trim()).filter(Boolean)
|
|
400
|
+
: [];
|
|
401
|
+
return tracks.length ? [...new Set(tracks)].join(",") : null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* @param {string} topogramRoot
|
|
406
|
+
* @returns {{ rawCandidateFiles: number, reconcileFiles: number }}
|
|
407
|
+
*/
|
|
408
|
+
function clearImportRefreshCandidateArtifacts(topogramRoot) {
|
|
409
|
+
const appCandidatesRoot = path.join(topogramRoot, "candidates", "app");
|
|
410
|
+
const reconcileRoot = path.join(topogramRoot, "candidates", "reconcile");
|
|
411
|
+
const removed = {
|
|
412
|
+
rawCandidateFiles: countFilesRecursive(appCandidatesRoot),
|
|
413
|
+
reconcileFiles: countFilesRecursive(reconcileRoot)
|
|
414
|
+
};
|
|
415
|
+
fs.rmSync(appCandidatesRoot, { recursive: true, force: true });
|
|
416
|
+
fs.rmSync(reconcileRoot, { recursive: true, force: true });
|
|
417
|
+
return removed;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* @param {{ changed?: any[], added?: any[], removed?: any[] }} [content]
|
|
422
|
+
* @returns {{ changed: number, added: number, removed: number }}
|
|
423
|
+
*/
|
|
424
|
+
function sourceDiffCounts(content = {}) {
|
|
425
|
+
return {
|
|
426
|
+
changed: content.changed?.length || 0,
|
|
427
|
+
added: content.added?.length || 0,
|
|
428
|
+
removed: content.removed?.length || 0
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* @param {string} projectRoot
|
|
434
|
+
* @param {AnyRecord} importRecord
|
|
435
|
+
* @param {string} sourceRoot
|
|
436
|
+
* @returns {AnyRecord}
|
|
437
|
+
*/
|
|
438
|
+
function compareImportRecordToSource(projectRoot, importRecord, sourceRoot) {
|
|
439
|
+
const trustedFiles = Array.isArray(importRecord.files) ? importRecord.files : [];
|
|
440
|
+
const trustedByPath = new Map(trustedFiles.map((/** @type {AnyRecord} */ file) => [String(file.path), file]));
|
|
441
|
+
const currentFiles = collectImportSourceFileRecords(sourceRoot, { excludeRoots: [projectRoot] });
|
|
442
|
+
const currentByPath = new Map(currentFiles.map((/** @type {AnyRecord} */ file) => [file.path, file]));
|
|
443
|
+
/** @type {string[]} */
|
|
444
|
+
const changed = [];
|
|
445
|
+
/** @type {string[]} */
|
|
446
|
+
const added = [];
|
|
447
|
+
/** @type {string[]} */
|
|
448
|
+
const removed = [];
|
|
449
|
+
for (const [filePath, current] of currentByPath) {
|
|
450
|
+
const trusted = trustedByPath.get(filePath);
|
|
451
|
+
if (!trusted) {
|
|
452
|
+
added.push(filePath);
|
|
453
|
+
} else if (trusted.sha256 !== current.sha256 || trusted.size !== current.size) {
|
|
454
|
+
changed.push(filePath);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
for (const filePath of trustedByPath.keys()) {
|
|
458
|
+
if (!currentByPath.has(filePath)) {
|
|
459
|
+
removed.push(filePath);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const content = {
|
|
463
|
+
changed: changed.sort((a, b) => a.localeCompare(b)),
|
|
464
|
+
added: added.sort((a, b) => a.localeCompare(b)),
|
|
465
|
+
removed: removed.sort((a, b) => a.localeCompare(b))
|
|
466
|
+
};
|
|
467
|
+
const counts = sourceDiffCounts(content);
|
|
468
|
+
const clean = counts.changed === 0 && counts.added === 0 && counts.removed === 0;
|
|
469
|
+
return {
|
|
470
|
+
ok: clean,
|
|
471
|
+
status: clean ? "clean" : "changed",
|
|
472
|
+
content,
|
|
473
|
+
counts,
|
|
474
|
+
files: currentFiles
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* @param {Record<string, number>} [previous]
|
|
480
|
+
* @param {Record<string, number>} [next]
|
|
481
|
+
* @returns {AnyRecord}
|
|
482
|
+
*/
|
|
483
|
+
function buildCountDeltas(previous = {}, next = {}) {
|
|
484
|
+
const keys = [...new Set([...Object.keys(previous || {}), ...Object.keys(next || {})])].sort((a, b) => a.localeCompare(b));
|
|
485
|
+
/** @type {Record<string, { previous: number, next: number, delta: number }>} */
|
|
486
|
+
const deltas = {};
|
|
487
|
+
/** @type {Array<{ key: string, previous: number, next: number, delta: number }>} */
|
|
488
|
+
const changed = [];
|
|
489
|
+
for (const key of keys) {
|
|
490
|
+
const previousCount = Number(previous?.[key] || 0);
|
|
491
|
+
const nextCount = Number(next?.[key] || 0);
|
|
492
|
+
const delta = nextCount - previousCount;
|
|
493
|
+
deltas[key] = { previous: previousCount, next: nextCount, delta };
|
|
494
|
+
if (delta !== 0) {
|
|
495
|
+
changed.push({ key, previous: previousCount, next: nextCount, delta });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
previous,
|
|
500
|
+
next,
|
|
501
|
+
deltas,
|
|
502
|
+
changed
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* @param {AnyRecord} item
|
|
508
|
+
* @returns {string}
|
|
509
|
+
*/
|
|
510
|
+
function adoptionSurfaceKey(item) {
|
|
511
|
+
return `${item?.bundle || "unbundled"}:${item?.kind || "unknown"}:${item?.item || item?.id || "unknown"}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* @param {AnyRecord} item
|
|
516
|
+
* @returns {AnyRecord}
|
|
517
|
+
*/
|
|
518
|
+
function summarizeAdoptionSurface(item) {
|
|
519
|
+
return {
|
|
520
|
+
key: adoptionSurfaceKey(item),
|
|
521
|
+
bundle: item?.bundle || "unbundled",
|
|
522
|
+
kind: item?.kind || "unknown",
|
|
523
|
+
item: item?.item || item?.id || "unknown",
|
|
524
|
+
currentState: item?.current_state || null
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* @param {AnyRecord[]} [currentSurfaces]
|
|
530
|
+
* @param {AnyRecord[]} [nextSurfaces]
|
|
531
|
+
* @returns {AnyRecord}
|
|
532
|
+
*/
|
|
533
|
+
function summarizeAdoptionPlanDeltas(currentSurfaces = [], nextSurfaces = []) {
|
|
534
|
+
const currentByKey = new Map((currentSurfaces || []).map((item) => [adoptionSurfaceKey(item), item]));
|
|
535
|
+
const nextByKey = new Map((nextSurfaces || []).map((item) => [adoptionSurfaceKey(item), item]));
|
|
536
|
+
/** @type {AnyRecord[]} */
|
|
537
|
+
const added = [];
|
|
538
|
+
/** @type {AnyRecord[]} */
|
|
539
|
+
const removed = [];
|
|
540
|
+
/** @type {AnyRecord[]} */
|
|
541
|
+
const changed = [];
|
|
542
|
+
for (const [key, next] of nextByKey) {
|
|
543
|
+
const current = currentByKey.get(key);
|
|
544
|
+
if (!current) {
|
|
545
|
+
added.push(summarizeAdoptionSurface(next));
|
|
546
|
+
} else if (stableStringify(current) !== stableStringify(next)) {
|
|
547
|
+
changed.push({
|
|
548
|
+
...summarizeAdoptionSurface(next),
|
|
549
|
+
previousState: current.current_state || null,
|
|
550
|
+
nextState: next.current_state || null
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
for (const [key, current] of currentByKey) {
|
|
555
|
+
if (!nextByKey.has(key)) {
|
|
556
|
+
removed.push(summarizeAdoptionSurface(current));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const currentByBundle = countByField(currentSurfaces, "bundle");
|
|
560
|
+
const nextByBundle = countByField(nextSurfaces, "bundle");
|
|
561
|
+
return {
|
|
562
|
+
added: added.sort((left, right) => left.key.localeCompare(right.key)),
|
|
563
|
+
removed: removed.sort((left, right) => left.key.localeCompare(right.key)),
|
|
564
|
+
changed: changed.sort((left, right) => left.key.localeCompare(right.key)),
|
|
565
|
+
byBundle: buildCountDeltas(currentByBundle, nextByBundle)
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* @param {string|null|undefined} fileContents
|
|
571
|
+
* @returns {AnyRecord[]}
|
|
572
|
+
*/
|
|
573
|
+
function adoptionSurfacesFromPlanFile(fileContents) {
|
|
574
|
+
if (!fileContents) {
|
|
575
|
+
return [];
|
|
576
|
+
}
|
|
577
|
+
const parsed = JSON.parse(fileContents);
|
|
578
|
+
return parsed.imported_proposal_surfaces || [];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* @param {string} projectRoot
|
|
583
|
+
* @param {string} topogramRoot
|
|
584
|
+
* @param {Record<string, any>} importFiles
|
|
585
|
+
* @returns {AnyRecord}
|
|
586
|
+
*/
|
|
587
|
+
function buildRefreshPreviewReconcile(projectRoot, topogramRoot, importFiles) {
|
|
588
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "topogram-import-refresh-preview."));
|
|
589
|
+
try {
|
|
590
|
+
const tempProjectRoot = path.join(tempRoot, "workspace");
|
|
591
|
+
const tempTopogramRoot = path.join(tempProjectRoot, "topogram");
|
|
592
|
+
fs.mkdirSync(tempProjectRoot, { recursive: true });
|
|
593
|
+
fs.cpSync(topogramRoot, tempTopogramRoot, { recursive: true });
|
|
594
|
+
const projectConfigPath = path.join(projectRoot, "topogram.project.json");
|
|
595
|
+
if (fs.existsSync(projectConfigPath)) {
|
|
596
|
+
fs.cpSync(projectConfigPath, path.join(tempProjectRoot, "topogram.project.json"));
|
|
597
|
+
}
|
|
598
|
+
clearImportRefreshCandidateArtifacts(tempTopogramRoot);
|
|
599
|
+
writeRelativeFiles(tempTopogramRoot, importFiles || {});
|
|
600
|
+
const reconcileResult = runWorkflow("reconcile", tempProjectRoot, {});
|
|
601
|
+
return {
|
|
602
|
+
reconcileFileCount: Object.keys(reconcileResult.files || {}).length,
|
|
603
|
+
reconcileFilePaths: Object.keys(reconcileResult.files || {}).sort((a, b) => a.localeCompare(b)),
|
|
604
|
+
adoptionSurfaces: adoptionSurfacesFromPlanFile(reconcileResult.files?.["candidates/reconcile/adoption-plan.agent.json"]),
|
|
605
|
+
summary: reconcileResult.summary || {}
|
|
606
|
+
};
|
|
607
|
+
} finally {
|
|
608
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* @param {string} topogramRoot
|
|
614
|
+
* @returns {AnyRecord[]}
|
|
615
|
+
*/
|
|
616
|
+
function readCurrentAdoptionSurfaces(topogramRoot) {
|
|
617
|
+
const planPath = path.join(topogramRoot, "candidates", "reconcile", "adoption-plan.agent.json");
|
|
618
|
+
if (!fs.existsSync(planPath)) {
|
|
619
|
+
return [];
|
|
620
|
+
}
|
|
621
|
+
return adoptionSurfacesFromPlanFile(fs.readFileSync(planPath, "utf8"));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* @param {string} inputPath
|
|
626
|
+
* @param {{ sourcePath?: string|null }} [options]
|
|
627
|
+
* @returns {AnyRecord}
|
|
628
|
+
*/
|
|
629
|
+
function buildBrownfieldImportRefreshAnalysis(inputPath, options = {}) {
|
|
630
|
+
const projectRoot = normalizeProjectRoot(inputPath);
|
|
631
|
+
const topogramRoot = normalizeTopogramPath(projectRoot);
|
|
632
|
+
if (!fs.existsSync(topogramRoot) || !fs.statSync(topogramRoot).isDirectory()) {
|
|
633
|
+
throw new Error(`No topogram directory found for imported workspace '${inputPath}'.`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const { record: importRecord } = readTopogramImportRecord(projectRoot);
|
|
637
|
+
const sourcePath = options.sourcePath && !String(options.sourcePath).startsWith("-")
|
|
638
|
+
? options.sourcePath
|
|
639
|
+
: importRecord.source?.path;
|
|
640
|
+
if (!sourcePath) {
|
|
641
|
+
throw new Error("No brownfield source path was provided or recorded. Use 'topogram import refresh <workspace> --from <app-path>'.");
|
|
642
|
+
}
|
|
643
|
+
const sourceRoot = path.resolve(sourcePath);
|
|
644
|
+
if (!fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {
|
|
645
|
+
throw new Error(`Cannot refresh from missing app directory '${sourcePath}'.`);
|
|
646
|
+
}
|
|
647
|
+
if (sourceRoot === projectRoot) {
|
|
648
|
+
throw new Error("Refusing to refresh import from the imported Topogram workspace itself.");
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const sourceComparison = compareImportRecordToSource(projectRoot, importRecord, sourceRoot);
|
|
652
|
+
const trackValue = importTrackValueFromRecord(importRecord);
|
|
653
|
+
const importResult = runWorkflow("import-app", sourceRoot, { from: trackValue });
|
|
654
|
+
const candidateCounts = importCandidateCounts(importResult.summary);
|
|
655
|
+
const candidateCountDeltas = buildCountDeltas(importRecord.import?.candidateCounts || {}, candidateCounts);
|
|
656
|
+
const removedCandidateFiles = {
|
|
657
|
+
rawCandidateFiles: countFilesRecursive(path.join(topogramRoot, "candidates", "app")),
|
|
658
|
+
reconcileFiles: countFilesRecursive(path.join(topogramRoot, "candidates", "reconcile"))
|
|
659
|
+
};
|
|
660
|
+
const previewReconcile = buildRefreshPreviewReconcile(projectRoot, topogramRoot, importResult.files || {});
|
|
661
|
+
const currentAdoptionSurfaces = readCurrentAdoptionSurfaces(topogramRoot);
|
|
662
|
+
const adoptionPlanDeltas = summarizeAdoptionPlanDeltas(currentAdoptionSurfaces, previewReconcile.adoptionSurfaces);
|
|
663
|
+
const receiptVerification = verifyImportAdoptionReceipts(projectRoot, readImportAdoptionReceipts(projectRoot));
|
|
664
|
+
const plannedFiles = [
|
|
665
|
+
TOPOGRAM_IMPORT_FILE,
|
|
666
|
+
...Object.keys(importResult.files || {}).map((filePath) => `topogram/${filePath}`),
|
|
667
|
+
...previewReconcile.reconcileFilePaths.map((/** @type {string} */ filePath) => `topogram/${filePath}`)
|
|
668
|
+
].sort((a, b) => a.localeCompare(b));
|
|
669
|
+
const analysis = /** @type {AnyRecord} */ ({
|
|
670
|
+
projectRoot,
|
|
671
|
+
topogramRoot,
|
|
672
|
+
sourcePath: sourceRoot,
|
|
673
|
+
provenancePath: path.join(projectRoot, TOPOGRAM_IMPORT_FILE),
|
|
674
|
+
importedAt: importRecord.importedAt || null,
|
|
675
|
+
previousImportStatus: sourceComparison.status,
|
|
676
|
+
sourceDiff: {
|
|
677
|
+
status: sourceComparison.status,
|
|
678
|
+
counts: sourceComparison.counts,
|
|
679
|
+
changed: sourceComparison.content.changed,
|
|
680
|
+
added: sourceComparison.content.added,
|
|
681
|
+
removed: sourceComparison.content.removed
|
|
682
|
+
},
|
|
683
|
+
tracks: importResult.summary.tracks || [],
|
|
684
|
+
sourceFiles: sourceComparison.files.length,
|
|
685
|
+
removedCandidateFiles,
|
|
686
|
+
rawCandidateFiles: Object.keys(importResult.files || {}).length,
|
|
687
|
+
reconcileFiles: previewReconcile.reconcileFileCount,
|
|
688
|
+
candidateCounts,
|
|
689
|
+
candidateCountDeltas,
|
|
690
|
+
adoptionPlanDeltas,
|
|
691
|
+
receiptVerification,
|
|
692
|
+
plannedFiles
|
|
693
|
+
});
|
|
694
|
+
Object.defineProperty(analysis, "importResult", {
|
|
695
|
+
value: importResult,
|
|
696
|
+
enumerable: false
|
|
697
|
+
});
|
|
698
|
+
return analysis;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* @param {string} sourcePath
|
|
703
|
+
* @param {string} targetPath
|
|
704
|
+
* @param {{ from?: string|null }} [options]
|
|
705
|
+
* @returns {{ ok: boolean, sourcePath: string, targetPath: string, topogramRoot: string, projectConfigPath: string, provenancePath: string, tracks: string[], sourceFiles: number, rawCandidateFiles: number, reconcileFiles: number, writtenFiles: string[], candidateCounts: Record<string, number>, nextCommands: string[] }}
|
|
706
|
+
*/
|
|
707
|
+
export function buildBrownfieldImportWorkspacePayload(sourcePath, targetPath, options = {}) {
|
|
708
|
+
const sourceRoot = path.resolve(sourcePath);
|
|
709
|
+
const targetRoot = path.resolve(targetPath);
|
|
710
|
+
if (!fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {
|
|
711
|
+
throw new Error(`Cannot import missing app directory '${sourcePath}'.`);
|
|
712
|
+
}
|
|
713
|
+
if (sourceRoot === targetRoot) {
|
|
714
|
+
throw new Error("Refusing to import into the same directory as the brownfield app.");
|
|
715
|
+
}
|
|
716
|
+
ensureEmptyImportTarget(targetRoot);
|
|
717
|
+
|
|
718
|
+
const topogramRoot = path.join(targetRoot, "topogram");
|
|
719
|
+
fs.mkdirSync(topogramRoot, { recursive: true });
|
|
720
|
+
const sourceFiles = collectImportSourceFileRecords(sourceRoot, { excludeRoots: [targetRoot] });
|
|
721
|
+
const importResult = runWorkflow("import-app", sourceRoot, { from: options.from || null });
|
|
722
|
+
const rawCandidateFiles = writeRelativeFiles(topogramRoot, importResult.files || {});
|
|
723
|
+
|
|
724
|
+
const projectConfigPath = path.join(targetRoot, "topogram.project.json");
|
|
725
|
+
fs.writeFileSync(projectConfigPath, `${stableStringify(importedProjectConfig())}\n`, "utf8");
|
|
726
|
+
fs.writeFileSync(path.join(targetRoot, "README.md"), importedWorkspaceReadme(sourceRoot, targetRoot, importResult.summary), "utf8");
|
|
727
|
+
|
|
728
|
+
const reconcileResult = runWorkflow("reconcile", targetRoot, {});
|
|
729
|
+
const reconcileFiles = writeRelativeFiles(topogramRoot, reconcileResult.files || {});
|
|
730
|
+
const candidateCounts = importCandidateCounts(importResult.summary);
|
|
731
|
+
const provenance = writeTopogramImportRecord(targetRoot, {
|
|
732
|
+
sourceRoot,
|
|
733
|
+
ignoredRoots: [targetRoot],
|
|
734
|
+
tracks: importResult.summary.tracks || [],
|
|
735
|
+
findingsCount: importResult.summary.findings_count || 0,
|
|
736
|
+
candidateCounts,
|
|
737
|
+
files: sourceFiles
|
|
738
|
+
});
|
|
739
|
+
const writtenFiles = [
|
|
740
|
+
"README.md",
|
|
741
|
+
"topogram.project.json",
|
|
742
|
+
TOPOGRAM_IMPORT_FILE,
|
|
743
|
+
...rawCandidateFiles.map((filePath) => `topogram/${filePath}`),
|
|
744
|
+
...reconcileFiles.map((filePath) => `topogram/${filePath}`)
|
|
745
|
+
].sort((a, b) => a.localeCompare(b));
|
|
746
|
+
return {
|
|
747
|
+
ok: true,
|
|
748
|
+
sourcePath: sourceRoot,
|
|
749
|
+
targetPath: targetRoot,
|
|
750
|
+
topogramRoot,
|
|
751
|
+
projectConfigPath,
|
|
752
|
+
provenancePath: provenance.path,
|
|
753
|
+
tracks: importResult.summary.tracks || [],
|
|
754
|
+
sourceFiles: sourceFiles.length,
|
|
755
|
+
rawCandidateFiles: rawCandidateFiles.length,
|
|
756
|
+
reconcileFiles: reconcileFiles.length,
|
|
757
|
+
writtenFiles,
|
|
758
|
+
candidateCounts,
|
|
759
|
+
nextCommands: [
|
|
760
|
+
"topogram import check",
|
|
761
|
+
"topogram import plan",
|
|
762
|
+
"topogram import adopt bundle:task --dry-run",
|
|
763
|
+
"topogram import status",
|
|
764
|
+
"topogram check",
|
|
765
|
+
"topogram query import-plan ./topogram"
|
|
766
|
+
]
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* @param {string} inputPath
|
|
772
|
+
* @param {{ sourcePath?: string|null, dryRun?: boolean }} [options]
|
|
773
|
+
* @returns {{ ok: boolean, dryRun: boolean, projectRoot: string, topogramRoot: string, sourcePath: string, provenancePath: string, previousImportStatus: string, currentImportStatus: string, tracks: string[], sourceFiles: number, sourceDiff: Record<string, any>, removedCandidateFiles: Record<string, number>, rawCandidateFiles: number, reconcileFiles: number, writtenFiles: string[], plannedFiles: string[], candidateCounts: Record<string, number>, candidateCountDeltas: Record<string, any>, adoptionPlanDeltas: Record<string, any>, receiptVerification: Record<string, any>, refreshMetadata: Record<string, any>|null, nextCommands: string[] }}
|
|
774
|
+
*/
|
|
775
|
+
export function buildBrownfieldImportRefreshPayload(inputPath, options = {}) {
|
|
776
|
+
const analysis = buildBrownfieldImportRefreshAnalysis(inputPath, options);
|
|
777
|
+
const dryRun = Boolean(options.dryRun);
|
|
778
|
+
let provenancePath = analysis.provenancePath;
|
|
779
|
+
let currentImportStatus = dryRun ? analysis.previousImportStatus : "unknown";
|
|
780
|
+
/** @type {string[]} */
|
|
781
|
+
let writtenFiles = [];
|
|
782
|
+
/** @type {AnyRecord|null} */
|
|
783
|
+
let refreshMetadata = null;
|
|
784
|
+
if (!dryRun) {
|
|
785
|
+
const removedCandidateFiles = clearImportRefreshCandidateArtifacts(analysis.topogramRoot);
|
|
786
|
+
const rawCandidateFiles = writeRelativeFiles(analysis.topogramRoot, analysis.importResult.files || {});
|
|
787
|
+
const reconcileResult = runWorkflow("reconcile", analysis.projectRoot, {});
|
|
788
|
+
const reconcileFiles = writeRelativeFiles(analysis.topogramRoot, reconcileResult.files || {});
|
|
789
|
+
const refreshedAt = new Date().toISOString();
|
|
790
|
+
refreshMetadata = {
|
|
791
|
+
refreshedAt,
|
|
792
|
+
previousSourceStatus: analysis.previousImportStatus,
|
|
793
|
+
sourceDiffCounts: analysis.sourceDiff.counts
|
|
794
|
+
};
|
|
795
|
+
const provenance = writeTopogramImportRecord(analysis.projectRoot, {
|
|
796
|
+
sourceRoot: analysis.sourcePath,
|
|
797
|
+
ignoredRoots: [analysis.projectRoot],
|
|
798
|
+
importedAt: analysis.importedAt || undefined,
|
|
799
|
+
refreshedAt,
|
|
800
|
+
refresh: {
|
|
801
|
+
previousSourceStatus: analysis.previousImportStatus,
|
|
802
|
+
sourceDiffCounts: analysis.sourceDiff.counts
|
|
803
|
+
},
|
|
804
|
+
tracks: analysis.importResult.summary.tracks || [],
|
|
805
|
+
findingsCount: analysis.importResult.summary.findings_count || 0,
|
|
806
|
+
candidateCounts: analysis.candidateCounts,
|
|
807
|
+
files: collectImportSourceFileRecords(analysis.sourcePath, { excludeRoots: [analysis.projectRoot] })
|
|
808
|
+
});
|
|
809
|
+
provenancePath = provenance.path;
|
|
810
|
+
currentImportStatus = buildTopogramImportStatus(analysis.projectRoot).status;
|
|
811
|
+
writtenFiles = [
|
|
812
|
+
TOPOGRAM_IMPORT_FILE,
|
|
813
|
+
...rawCandidateFiles.map((filePath) => `topogram/${filePath}`),
|
|
814
|
+
...reconcileFiles.map((filePath) => `topogram/${filePath}`)
|
|
815
|
+
].sort((a, b) => a.localeCompare(b));
|
|
816
|
+
analysis.removedCandidateFiles = removedCandidateFiles;
|
|
817
|
+
analysis.rawCandidateFiles = rawCandidateFiles.length;
|
|
818
|
+
analysis.reconcileFiles = reconcileFiles.length;
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
ok: dryRun || currentImportStatus === "clean",
|
|
822
|
+
dryRun,
|
|
823
|
+
projectRoot: analysis.projectRoot,
|
|
824
|
+
topogramRoot: analysis.topogramRoot,
|
|
825
|
+
sourcePath: analysis.sourcePath,
|
|
826
|
+
provenancePath,
|
|
827
|
+
previousImportStatus: analysis.previousImportStatus,
|
|
828
|
+
currentImportStatus,
|
|
829
|
+
tracks: analysis.tracks,
|
|
830
|
+
sourceFiles: analysis.sourceFiles,
|
|
831
|
+
sourceDiff: analysis.sourceDiff,
|
|
832
|
+
removedCandidateFiles: analysis.removedCandidateFiles,
|
|
833
|
+
rawCandidateFiles: analysis.rawCandidateFiles,
|
|
834
|
+
reconcileFiles: analysis.reconcileFiles,
|
|
835
|
+
writtenFiles,
|
|
836
|
+
plannedFiles: analysis.plannedFiles,
|
|
837
|
+
candidateCounts: analysis.candidateCounts,
|
|
838
|
+
candidateCountDeltas: analysis.candidateCountDeltas,
|
|
839
|
+
adoptionPlanDeltas: analysis.adoptionPlanDeltas,
|
|
840
|
+
receiptVerification: analysis.receiptVerification,
|
|
841
|
+
refreshMetadata,
|
|
842
|
+
nextCommands: [
|
|
843
|
+
dryRun
|
|
844
|
+
? `topogram import refresh ${importProjectCommandPath(analysis.projectRoot)}`
|
|
845
|
+
: `topogram import check ${importProjectCommandPath(analysis.projectRoot)}`,
|
|
846
|
+
`topogram import plan ${importProjectCommandPath(analysis.projectRoot)}`,
|
|
847
|
+
`topogram import status ${importProjectCommandPath(analysis.projectRoot)}`,
|
|
848
|
+
`topogram import history ${importProjectCommandPath(analysis.projectRoot)} --verify`
|
|
849
|
+
]
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* @param {string} inputPath
|
|
855
|
+
* @param {{ sourcePath?: string|null }} [options]
|
|
856
|
+
* @returns {AnyRecord}
|
|
857
|
+
*/
|
|
858
|
+
export function buildBrownfieldImportDiffPayload(inputPath, options = {}) {
|
|
859
|
+
const analysis = buildBrownfieldImportRefreshAnalysis(inputPath, options);
|
|
860
|
+
return {
|
|
861
|
+
ok: true,
|
|
862
|
+
projectRoot: analysis.projectRoot,
|
|
863
|
+
topogramRoot: analysis.topogramRoot,
|
|
864
|
+
sourcePath: analysis.sourcePath,
|
|
865
|
+
provenancePath: analysis.provenancePath,
|
|
866
|
+
importStatus: analysis.previousImportStatus,
|
|
867
|
+
sourceDiff: analysis.sourceDiff,
|
|
868
|
+
tracks: analysis.tracks,
|
|
869
|
+
sourceFiles: analysis.sourceFiles,
|
|
870
|
+
candidateCounts: analysis.candidateCounts,
|
|
871
|
+
candidateCountDeltas: analysis.candidateCountDeltas,
|
|
872
|
+
adoptionPlanDeltas: analysis.adoptionPlanDeltas,
|
|
873
|
+
receiptVerification: analysis.receiptVerification,
|
|
874
|
+
plannedFiles: analysis.plannedFiles,
|
|
875
|
+
nextCommands: [
|
|
876
|
+
`topogram import refresh ${importProjectCommandPath(analysis.projectRoot)} --dry-run`,
|
|
877
|
+
`topogram import refresh ${importProjectCommandPath(analysis.projectRoot)}`,
|
|
878
|
+
`topogram import plan ${importProjectCommandPath(analysis.projectRoot)}`
|
|
879
|
+
]
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* @param {ReturnType<typeof buildBrownfieldImportWorkspacePayload>} payload
|
|
885
|
+
* @returns {void}
|
|
886
|
+
*/
|
|
887
|
+
export function printBrownfieldImportWorkspace(payload) {
|
|
888
|
+
console.log(`Imported brownfield app to ${payload.targetPath}.`);
|
|
889
|
+
console.log(`Source: ${payload.sourcePath}`);
|
|
890
|
+
console.log(`Topogram: ${payload.topogramRoot}`);
|
|
891
|
+
console.log(`Project config: ${payload.projectConfigPath}`);
|
|
892
|
+
console.log(`Import provenance: ${payload.provenancePath}`);
|
|
893
|
+
console.log(`Tracked source files: ${payload.sourceFiles}`);
|
|
894
|
+
console.log(`Raw candidate files: ${payload.rawCandidateFiles}`);
|
|
895
|
+
console.log(`Reconcile proposal files: ${payload.reconcileFiles}`);
|
|
896
|
+
console.log("Imported Topogram artifacts are project-owned after creation; source hashes record the app evidence trusted at import time.");
|
|
897
|
+
console.log("");
|
|
898
|
+
console.log("Next steps:");
|
|
899
|
+
console.log(` cd ${shellCommandArg(path.relative(process.cwd(), payload.targetPath) || ".")}`);
|
|
900
|
+
for (const command of payload.nextCommands) {
|
|
901
|
+
console.log(` ${command}`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* @param {ReturnType<typeof buildBrownfieldImportRefreshPayload>} payload
|
|
907
|
+
* @returns {void}
|
|
908
|
+
*/
|
|
909
|
+
export function printBrownfieldImportRefresh(payload) {
|
|
910
|
+
console.log(`${payload.dryRun ? "Previewed" : "Refreshed"} brownfield import candidates for ${payload.projectRoot}.`);
|
|
911
|
+
console.log(`Source: ${payload.sourcePath}`);
|
|
912
|
+
console.log(`Topogram: ${payload.topogramRoot}`);
|
|
913
|
+
console.log(`Import provenance: ${payload.provenancePath}`);
|
|
914
|
+
console.log(`Previous source status: ${payload.previousImportStatus}`);
|
|
915
|
+
console.log(`Current source status: ${payload.currentImportStatus}`);
|
|
916
|
+
console.log(`Source diff: changed=${payload.sourceDiff.counts.changed}, added=${payload.sourceDiff.counts.added}, removed=${payload.sourceDiff.counts.removed}`);
|
|
917
|
+
console.log(`Tracked source files: ${payload.sourceFiles}`);
|
|
918
|
+
console.log(`Raw candidate files: ${payload.rawCandidateFiles}`);
|
|
919
|
+
console.log(`Reconcile proposal files: ${payload.reconcileFiles}`);
|
|
920
|
+
console.log(`Replaced candidate files: ${payload.removedCandidateFiles.rawCandidateFiles + payload.removedCandidateFiles.reconcileFiles}`);
|
|
921
|
+
const candidateChanges = payload.candidateCountDeltas.changed || [];
|
|
922
|
+
console.log(`Candidate count changes: ${candidateChanges.length}`);
|
|
923
|
+
for (const item of candidateChanges.slice(0, 8)) {
|
|
924
|
+
const sign = item.delta > 0 ? "+" : "";
|
|
925
|
+
console.log(`- ${item.key}: ${item.previous} -> ${item.next} (${sign}${item.delta})`);
|
|
926
|
+
}
|
|
927
|
+
const adoptionDeltas = payload.adoptionPlanDeltas;
|
|
928
|
+
console.log(`Adoption plan changes: added=${adoptionDeltas.added.length}, removed=${adoptionDeltas.removed.length}, changed=${adoptionDeltas.changed.length}`);
|
|
929
|
+
console.log(`Receipt verification: ${payload.receiptVerification.status}`);
|
|
930
|
+
if (payload.dryRun) {
|
|
931
|
+
console.log("No files were written. Re-run without --dry-run to refresh candidates and source provenance.");
|
|
932
|
+
}
|
|
933
|
+
console.log("Canonical Topogram files were not overwritten. Adopt refreshed candidates explicitly after review.");
|
|
934
|
+
console.log("");
|
|
935
|
+
console.log("Next steps:");
|
|
936
|
+
for (const command of payload.nextCommands) {
|
|
937
|
+
console.log(` ${command}`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* @param {ReturnType<typeof buildBrownfieldImportDiffPayload>} payload
|
|
943
|
+
* @returns {void}
|
|
944
|
+
*/
|
|
945
|
+
export function printBrownfieldImportDiff(payload) {
|
|
946
|
+
console.log(`Import diff for ${payload.projectRoot}`);
|
|
947
|
+
console.log(`Source: ${payload.sourcePath}`);
|
|
948
|
+
console.log(`Source status: ${payload.importStatus}`);
|
|
949
|
+
console.log(`Source diff: changed=${payload.sourceDiff.counts.changed}, added=${payload.sourceDiff.counts.added}, removed=${payload.sourceDiff.counts.removed}`);
|
|
950
|
+
for (const filePath of [...payload.sourceDiff.changed, ...payload.sourceDiff.added, ...payload.sourceDiff.removed].slice(0, 12)) {
|
|
951
|
+
const status = payload.sourceDiff.changed.includes(filePath)
|
|
952
|
+
? "changed"
|
|
953
|
+
: payload.sourceDiff.added.includes(filePath)
|
|
954
|
+
? "added"
|
|
955
|
+
: "removed";
|
|
956
|
+
console.log(`- ${filePath}: ${status}`);
|
|
957
|
+
}
|
|
958
|
+
console.log("");
|
|
959
|
+
console.log("Candidate count changes:");
|
|
960
|
+
const candidateChanges = payload.candidateCountDeltas.changed || [];
|
|
961
|
+
if (candidateChanges.length === 0) {
|
|
962
|
+
console.log("- None");
|
|
963
|
+
} else {
|
|
964
|
+
for (const item of candidateChanges) {
|
|
965
|
+
const sign = item.delta > 0 ? "+" : "";
|
|
966
|
+
console.log(`- ${item.key}: ${item.previous} -> ${item.next} (${sign}${item.delta})`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
console.log("");
|
|
970
|
+
console.log(`Adoption plan changes: added=${payload.adoptionPlanDeltas.added.length}, removed=${payload.adoptionPlanDeltas.removed.length}, changed=${payload.adoptionPlanDeltas.changed.length}`);
|
|
971
|
+
for (const item of payload.adoptionPlanDeltas.added.slice(0, 8)) {
|
|
972
|
+
console.log(`- added ${item.bundle}/${item.kind}/${item.item}`);
|
|
973
|
+
}
|
|
974
|
+
for (const item of payload.adoptionPlanDeltas.removed.slice(0, 8)) {
|
|
975
|
+
console.log(`- removed ${item.bundle}/${item.kind}/${item.item}`);
|
|
976
|
+
}
|
|
977
|
+
console.log(`Receipt verification: ${payload.receiptVerification.status}`);
|
|
978
|
+
const receiptSummary = payload.receiptVerification.summary;
|
|
979
|
+
console.log(`Adopted file audit: changed=${receiptSummary.changedFileCount}, removed=${receiptSummary.removedFileCount}, unverifiable=${receiptSummary.unverifiableFileCount}`);
|
|
980
|
+
console.log("");
|
|
981
|
+
console.log("Next steps:");
|
|
982
|
+
for (const command of payload.nextCommands) {
|
|
983
|
+
console.log(` ${command}`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* @param {string} inputPath
|
|
989
|
+
* @returns {ReturnType<typeof checkSummaryPayload>}
|
|
990
|
+
*/
|
|
991
|
+
function buildTopogramCheckPayloadForPath(inputPath) {
|
|
992
|
+
const ast = parsePath(inputPath);
|
|
993
|
+
const resolved = resolveWorkspace(ast);
|
|
994
|
+
const explicitProjectConfig = loadProjectConfig(inputPath);
|
|
995
|
+
const projectValidation = explicitProjectConfig
|
|
996
|
+
? combineProjectValidationResults(
|
|
997
|
+
validateProjectConfig(explicitProjectConfig.config, resolved.ok ? resolved.graph : null, { configDir: explicitProjectConfig.configDir }),
|
|
998
|
+
validateProjectOutputOwnership(explicitProjectConfig),
|
|
999
|
+
validateProjectImplementationTrust(explicitProjectConfig)
|
|
1000
|
+
)
|
|
1001
|
+
: { ok: false, errors: [{ message: "Missing topogram.project.json or compatible topogram.implementation.json", loc: null }] };
|
|
1002
|
+
return checkSummaryPayload({ inputPath, ast, resolved, projectConfigInfo: explicitProjectConfig, projectValidation });
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* @param {string} projectRoot
|
|
1007
|
+
* @returns {{ ok: boolean, projectRoot: string, import: ReturnType<typeof buildTopogramImportStatus>, topogram: ReturnType<typeof buildTopogramCheckPayloadForPath>, errors: any[] }}
|
|
1008
|
+
*/
|
|
1009
|
+
export function buildBrownfieldImportCheckPayload(projectRoot) {
|
|
1010
|
+
const resolvedRoot = normalizeProjectRoot(projectRoot);
|
|
1011
|
+
const importStatus = buildTopogramImportStatus(resolvedRoot);
|
|
1012
|
+
const topogramCheck = buildTopogramCheckPayloadForPath(resolvedRoot);
|
|
1013
|
+
return {
|
|
1014
|
+
ok: importStatus.ok && topogramCheck.ok,
|
|
1015
|
+
projectRoot: resolvedRoot,
|
|
1016
|
+
import: importStatus,
|
|
1017
|
+
topogram: topogramCheck,
|
|
1018
|
+
errors: [
|
|
1019
|
+
...(importStatus.errors || []).map((/** @type {string} */ message) => ({ source: "import", message })),
|
|
1020
|
+
...(topogramCheck.errors || [])
|
|
1021
|
+
]
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* @param {ReturnType<typeof buildBrownfieldImportCheckPayload>} payload
|
|
1027
|
+
* @returns {void}
|
|
1028
|
+
*/
|
|
1029
|
+
export function printBrownfieldImportCheck(payload) {
|
|
1030
|
+
console.log(`Topogram import check: ${payload.import.status}`);
|
|
1031
|
+
console.log(`Project: ${payload.projectRoot}`);
|
|
1032
|
+
if (payload.import.source?.source?.path) {
|
|
1033
|
+
console.log(`Imported source: ${payload.import.source.source.path}`);
|
|
1034
|
+
}
|
|
1035
|
+
console.log(`Provenance: ${payload.import.path}`);
|
|
1036
|
+
if (payload.import.source?.files) {
|
|
1037
|
+
console.log(`Trusted source files: ${payload.import.source.files.length}`);
|
|
1038
|
+
}
|
|
1039
|
+
if (payload.import.status === "changed") {
|
|
1040
|
+
console.log(`Changed source files: ${payload.import.content.changed.length}`);
|
|
1041
|
+
console.log(`Added source files: ${payload.import.content.added.length}`);
|
|
1042
|
+
console.log(`Removed source files: ${payload.import.content.removed.length}`);
|
|
1043
|
+
}
|
|
1044
|
+
console.log(`Topogram check: ${payload.topogram.ok ? "passed" : "failed"}`);
|
|
1045
|
+
console.log("Imported Topogram artifacts are project-owned; import check compares only the brownfield source hashes trusted at import time plus normal Topogram validity.");
|
|
1046
|
+
for (const diagnostic of payload.import.diagnostics || []) {
|
|
1047
|
+
const label = diagnostic.severity === "warning" ? "Warning" : "Error";
|
|
1048
|
+
console.log(`${label}: ${diagnostic.message}`);
|
|
1049
|
+
if (diagnostic.suggestedFix) {
|
|
1050
|
+
console.log(`Fix: ${diagnostic.suggestedFix}`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
for (const error of payload.topogram.errors || []) {
|
|
1054
|
+
console.log(`Error: ${error.message}`);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* @param {string} filePath
|
|
1060
|
+
* @returns {AnyRecord|null}
|
|
1061
|
+
*/
|
|
1062
|
+
function readJsonIfExists(filePath) {
|
|
1063
|
+
if (!fs.existsSync(filePath)) {
|
|
1064
|
+
return null;
|
|
1065
|
+
}
|
|
1066
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* @param {string} projectRoot
|
|
1071
|
+
* @returns {string}
|
|
1072
|
+
*/
|
|
1073
|
+
function importAdoptionsPath(projectRoot) {
|
|
1074
|
+
return path.join(normalizeProjectRoot(projectRoot), TOPOGRAM_IMPORT_ADOPTIONS_FILE);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* @param {string} projectRoot
|
|
1079
|
+
* @returns {AnyRecord[]}
|
|
1080
|
+
*/
|
|
1081
|
+
function readImportAdoptionReceipts(projectRoot) {
|
|
1082
|
+
const historyPath = importAdoptionsPath(projectRoot);
|
|
1083
|
+
if (!fs.existsSync(historyPath)) {
|
|
1084
|
+
return [];
|
|
1085
|
+
}
|
|
1086
|
+
return fs.readFileSync(historyPath, "utf8")
|
|
1087
|
+
.split(/\r?\n/)
|
|
1088
|
+
.map((/** @type {string} */ line) => line.trim())
|
|
1089
|
+
.filter(Boolean)
|
|
1090
|
+
.map((/** @type {string} */ line, /** @type {number} */ index) => {
|
|
1091
|
+
try {
|
|
1092
|
+
return JSON.parse(line);
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
throw new Error(`Invalid import adoption receipt JSON at ${historyPath}:${index + 1}.`);
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* @param {string} projectRoot
|
|
1101
|
+
* @param {AnyRecord} receipt
|
|
1102
|
+
* @returns {string}
|
|
1103
|
+
*/
|
|
1104
|
+
function appendImportAdoptionReceipt(projectRoot, receipt) {
|
|
1105
|
+
const historyPath = importAdoptionsPath(projectRoot);
|
|
1106
|
+
fs.appendFileSync(historyPath, `${JSON.stringify(receipt)}\n`, "utf8");
|
|
1107
|
+
return historyPath;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* @param {AnyRecord[]} items
|
|
1112
|
+
* @param {string} fieldName
|
|
1113
|
+
* @returns {Record<string, number>}
|
|
1114
|
+
*/
|
|
1115
|
+
function countByField(items, fieldName) {
|
|
1116
|
+
/** @type {Record<string, number>} */
|
|
1117
|
+
const counts = {};
|
|
1118
|
+
for (const item of items || []) {
|
|
1119
|
+
const key = item?.[fieldName] || "unknown";
|
|
1120
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
1121
|
+
}
|
|
1122
|
+
return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)));
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* @param {string} projectRoot
|
|
1127
|
+
* @returns {string}
|
|
1128
|
+
*/
|
|
1129
|
+
function importProjectCommandPath(projectRoot) {
|
|
1130
|
+
return shellCommandArg(path.relative(process.cwd(), projectRoot) || ".");
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* @param {string} projectRoot
|
|
1135
|
+
* @param {string} selector
|
|
1136
|
+
* @param {boolean} [write]
|
|
1137
|
+
* @returns {string}
|
|
1138
|
+
*/
|
|
1139
|
+
function importAdoptCommand(projectRoot, selector, write = false) {
|
|
1140
|
+
return `topogram import adopt ${selector} ${importProjectCommandPath(projectRoot)} ${write ? "--write" : "--dry-run"}`;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const BROWNFIELD_BROAD_ADOPT_SELECTORS = [
|
|
1144
|
+
{
|
|
1145
|
+
selector: "from-plan",
|
|
1146
|
+
kind: "plan",
|
|
1147
|
+
label: "approved or pending plan items",
|
|
1148
|
+
matches: (/** @type {AnyRecord} */ item) => item.current_state === "stage" || item.current_state === "accept"
|
|
1149
|
+
},
|
|
1150
|
+
{ selector: "actors", kind: "kind", label: "actors", matches: (/** @type {AnyRecord} */ item) => item.kind === "actor" },
|
|
1151
|
+
{ selector: "roles", kind: "kind", label: "roles", matches: (/** @type {AnyRecord} */ item) => item.kind === "role" },
|
|
1152
|
+
{ selector: "enums", kind: "kind", label: "enums", matches: (/** @type {AnyRecord} */ item) => item.kind === "enum" },
|
|
1153
|
+
{ selector: "shapes", kind: "kind", label: "shapes", matches: (/** @type {AnyRecord} */ item) => item.kind === "shape" },
|
|
1154
|
+
{ selector: "entities", kind: "kind", label: "entities", matches: (/** @type {AnyRecord} */ item) => item.kind === "entity" },
|
|
1155
|
+
{ selector: "capabilities", kind: "kind", label: "capabilities", matches: (/** @type {AnyRecord} */ item) => item.kind === "capability" },
|
|
1156
|
+
{ selector: "widgets", kind: "kind", label: "widgets", matches: (/** @type {AnyRecord} */ item) => item.kind === "widget" },
|
|
1157
|
+
{ selector: "docs", kind: "track", label: "docs", matches: (/** @type {AnyRecord} */ item) => item.track === "docs" },
|
|
1158
|
+
{
|
|
1159
|
+
selector: "journeys",
|
|
1160
|
+
kind: "track",
|
|
1161
|
+
label: "journey docs",
|
|
1162
|
+
matches: (/** @type {AnyRecord} */ item) => item.track === "docs" && String(item.canonical_rel_path || "").startsWith("docs/journeys/")
|
|
1163
|
+
},
|
|
1164
|
+
{ selector: "workflows", kind: "track", label: "workflows", matches: (/** @type {AnyRecord} */ item) => item.track === "workflows" || item.kind === "decision" },
|
|
1165
|
+
{ selector: "verification", kind: "kind", label: "verification", matches: (/** @type {AnyRecord} */ item) => item.kind === "verification" },
|
|
1166
|
+
{ selector: "ui", kind: "track", label: "UI reports and widgets", matches: (/** @type {AnyRecord} */ item) => item.track === "ui" }
|
|
1167
|
+
];
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* @param {string} inputPath
|
|
1171
|
+
* @returns {AnyRecord}
|
|
1172
|
+
*/
|
|
1173
|
+
function readImportAdoptionArtifacts(inputPath) {
|
|
1174
|
+
const projectRoot = normalizeProjectRoot(inputPath);
|
|
1175
|
+
const topogramRoot = normalizeTopogramPath(inputPath);
|
|
1176
|
+
const reconcileRoot = path.join(topogramRoot, "candidates", "reconcile");
|
|
1177
|
+
const paths = {
|
|
1178
|
+
reconcileRoot,
|
|
1179
|
+
adoptionPlanAgent: path.join(reconcileRoot, "adoption-plan.agent.json"),
|
|
1180
|
+
adoptionPlan: path.join(reconcileRoot, "adoption-plan.json"),
|
|
1181
|
+
adoptionStatus: path.join(reconcileRoot, "adoption-status.json"),
|
|
1182
|
+
reconcileReport: path.join(reconcileRoot, "report.json")
|
|
1183
|
+
};
|
|
1184
|
+
if (!fs.existsSync(paths.adoptionPlanAgent)) {
|
|
1185
|
+
throw new Error(`No import adoption plan found under '${reconcileRoot}'. Run 'topogram import <app-path> --out <target>' first.`);
|
|
1186
|
+
}
|
|
1187
|
+
return {
|
|
1188
|
+
projectRoot,
|
|
1189
|
+
topogramRoot,
|
|
1190
|
+
paths,
|
|
1191
|
+
adoptionPlan: JSON.parse(fs.readFileSync(paths.adoptionPlanAgent, "utf8")),
|
|
1192
|
+
adoptionStatus: readJsonIfExists(paths.adoptionStatus),
|
|
1193
|
+
reconcileReport: readJsonIfExists(paths.reconcileReport)
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* @param {string} projectRoot
|
|
1199
|
+
* @param {AnyRecord} adoptionPlan
|
|
1200
|
+
* @returns {AnyRecord[]}
|
|
1201
|
+
*/
|
|
1202
|
+
function buildBrownfieldBroadAdoptSelectors(projectRoot, adoptionPlan) {
|
|
1203
|
+
const surfaces = /** @type {AnyRecord[]} */ (adoptionPlan.imported_proposal_surfaces || []);
|
|
1204
|
+
return BROWNFIELD_BROAD_ADOPT_SELECTORS.map((definition) => {
|
|
1205
|
+
const items = surfaces.filter(definition.matches);
|
|
1206
|
+
const pendingItems = items.filter((/** @type {AnyRecord} */ item) => !["accept", "accepted", "applied"].includes(item.current_state));
|
|
1207
|
+
const appliedItems = items.filter((/** @type {AnyRecord} */ item) => ["accept", "accepted", "applied"].includes(item.current_state));
|
|
1208
|
+
const blockedItems = items.filter((/** @type {AnyRecord} */ item) => item.human_review_required);
|
|
1209
|
+
return {
|
|
1210
|
+
selector: definition.selector,
|
|
1211
|
+
kind: definition.kind,
|
|
1212
|
+
label: definition.label,
|
|
1213
|
+
itemCount: items.length,
|
|
1214
|
+
pendingItemCount: pendingItems.length,
|
|
1215
|
+
appliedItemCount: appliedItems.length,
|
|
1216
|
+
blockedItemCount: blockedItems.length,
|
|
1217
|
+
previewCommand: importAdoptCommand(projectRoot, definition.selector, false),
|
|
1218
|
+
writeCommand: importAdoptCommand(projectRoot, definition.selector, true)
|
|
1219
|
+
};
|
|
1220
|
+
}).filter((selector) => selector.itemCount > 0);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* @param {AnyRecord} adoptionPlan
|
|
1225
|
+
* @param {AnyRecord} adoptionStatus
|
|
1226
|
+
* @param {string} projectRoot
|
|
1227
|
+
* @returns {AnyRecord}
|
|
1228
|
+
*/
|
|
1229
|
+
function summarizeImportAdoption(adoptionPlan, adoptionStatus, projectRoot) {
|
|
1230
|
+
const surfaces = adoptionPlan.imported_proposal_surfaces || [];
|
|
1231
|
+
/** @type {string[]} */
|
|
1232
|
+
const slugs = [];
|
|
1233
|
+
/** @type {Map<string, AnyRecord[]>} */
|
|
1234
|
+
const surfaceMap = new Map();
|
|
1235
|
+
for (const surface of surfaces) {
|
|
1236
|
+
const slug = surface.bundle || "unbundled";
|
|
1237
|
+
if (!surfaceMap.has(slug)) {
|
|
1238
|
+
surfaceMap.set(slug, []);
|
|
1239
|
+
slugs.push(slug);
|
|
1240
|
+
}
|
|
1241
|
+
surfaceMap.get(slug)?.push(surface);
|
|
1242
|
+
}
|
|
1243
|
+
for (const item of /** @type {AnyRecord[]} */ (adoptionStatus?.bundle_priorities || [])) {
|
|
1244
|
+
if (item?.bundle && !surfaceMap.has(item.bundle)) {
|
|
1245
|
+
surfaceMap.set(item.bundle, []);
|
|
1246
|
+
slugs.push(item.bundle);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
const blockersByBundle = new Map((/** @type {AnyRecord[]} */ (adoptionStatus?.bundle_blockers || [])).map((item) => [item.bundle, item]));
|
|
1250
|
+
const prioritiesByBundle = new Map((/** @type {AnyRecord[]} */ (adoptionStatus?.bundle_priorities || [])).map((item) => [item.bundle, item]));
|
|
1251
|
+
const bundles = slugs.sort((left, right) => left.localeCompare(right)).map((slug) => {
|
|
1252
|
+
const bundleSurfaces = surfaceMap.get(slug) || [];
|
|
1253
|
+
const blocker = blockersByBundle.get(slug) || null;
|
|
1254
|
+
const priority = prioritiesByBundle.get(slug) || null;
|
|
1255
|
+
const pendingItems = blocker?.pending_items || bundleSurfaces
|
|
1256
|
+
.filter((/** @type {AnyRecord} */ item) => !["accept", "accepted", "applied"].includes(item.current_state))
|
|
1257
|
+
.map((/** @type {AnyRecord} */ item) => item.item);
|
|
1258
|
+
const appliedItems = blocker?.applied_items || [];
|
|
1259
|
+
const blockedItems = blocker?.blocked_items || [];
|
|
1260
|
+
return {
|
|
1261
|
+
bundle: slug,
|
|
1262
|
+
itemCount: bundleSurfaces.length,
|
|
1263
|
+
pendingItemCount: pendingItems.length,
|
|
1264
|
+
appliedItemCount: appliedItems.length,
|
|
1265
|
+
blockedItemCount: blockedItems.length,
|
|
1266
|
+
humanReviewRequiredCount: bundleSurfaces.filter((/** @type {AnyRecord} */ item) => item.human_review_required).length,
|
|
1267
|
+
kindCounts: countByField(bundleSurfaces, "kind"),
|
|
1268
|
+
complete: Boolean(priority?.is_complete) || (pendingItems.length === 0 && blockedItems.length === 0 && appliedItems.length > 0),
|
|
1269
|
+
evidenceScore: priority?.evidence_score || 0,
|
|
1270
|
+
why: priority?.operator_summary?.whyThisBundle || null,
|
|
1271
|
+
nextCommand: importAdoptCommand(projectRoot, `bundle:${slug}`, false)
|
|
1272
|
+
};
|
|
1273
|
+
});
|
|
1274
|
+
const nextBundle = bundles.find((bundle) => !bundle.complete && bundle.pendingItemCount > 0) || bundles.find((bundle) => !bundle.complete) || bundles[0] || null;
|
|
1275
|
+
const blockedCount = bundles.reduce((total, bundle) => total + bundle.blockedItemCount, 0);
|
|
1276
|
+
const pendingCount = bundles.reduce((total, bundle) => total + bundle.pendingItemCount, 0);
|
|
1277
|
+
const appliedCount = adoptionStatus?.applied_item_count ?? bundles.reduce((total, bundle) => total + bundle.appliedItemCount, 0);
|
|
1278
|
+
return {
|
|
1279
|
+
summary: {
|
|
1280
|
+
bundleCount: bundles.length,
|
|
1281
|
+
proposalItemCount: surfaces.length,
|
|
1282
|
+
pendingItemCount: pendingCount,
|
|
1283
|
+
appliedItemCount: appliedCount,
|
|
1284
|
+
blockedItemCount: blockedCount,
|
|
1285
|
+
requiresHumanReviewCount: (adoptionPlan.requires_human_review || []).length || surfaces.filter((/** @type {AnyRecord} */ item) => item.human_review_required).length
|
|
1286
|
+
},
|
|
1287
|
+
bundles,
|
|
1288
|
+
risks: [
|
|
1289
|
+
...(blockedCount > 0 ? [`${blockedCount} adoption item(s) are blocked.`] : []),
|
|
1290
|
+
...(((adoptionPlan.requires_human_review || []).length || surfaces.some((/** @type {AnyRecord} */ item) => item.human_review_required))
|
|
1291
|
+
? ["Imported proposal items require human review before adoption."]
|
|
1292
|
+
: [])
|
|
1293
|
+
],
|
|
1294
|
+
nextCommand: nextBundle ? nextBundle.nextCommand : `topogram import status ${importProjectCommandPath(projectRoot)}`
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* @param {string} inputPath
|
|
1300
|
+
* @returns {AnyRecord}
|
|
1301
|
+
*/
|
|
1302
|
+
export function buildBrownfieldImportPlanPayload(inputPath) {
|
|
1303
|
+
const artifacts = readImportAdoptionArtifacts(inputPath);
|
|
1304
|
+
const adoptionStatus = runWorkflow("adoption-status", artifacts.projectRoot).summary || artifacts.adoptionStatus || {};
|
|
1305
|
+
const adoption = summarizeImportAdoption(artifacts.adoptionPlan, adoptionStatus, artifacts.projectRoot);
|
|
1306
|
+
return {
|
|
1307
|
+
ok: true,
|
|
1308
|
+
projectRoot: artifacts.projectRoot,
|
|
1309
|
+
topogramRoot: artifacts.topogramRoot,
|
|
1310
|
+
artifacts: {
|
|
1311
|
+
adoptionPlan: artifacts.paths.adoptionPlanAgent,
|
|
1312
|
+
adoptionStatus: artifacts.paths.adoptionStatus,
|
|
1313
|
+
reconcileReport: artifacts.paths.reconcileReport
|
|
1314
|
+
},
|
|
1315
|
+
...adoption,
|
|
1316
|
+
commands: {
|
|
1317
|
+
check: `topogram import check ${importProjectCommandPath(artifacts.projectRoot)}`,
|
|
1318
|
+
status: `topogram import status ${importProjectCommandPath(artifacts.projectRoot)}`,
|
|
1319
|
+
next: adoption.nextCommand
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* @param {AnyRecord} payload
|
|
1326
|
+
* @returns {void}
|
|
1327
|
+
*/
|
|
1328
|
+
export function printBrownfieldImportPlan(payload) {
|
|
1329
|
+
console.log(`Import adoption plan for ${payload.projectRoot}`);
|
|
1330
|
+
console.log(`Proposal items: ${payload.summary.proposalItemCount}`);
|
|
1331
|
+
console.log(`Bundles: ${payload.summary.bundleCount}`);
|
|
1332
|
+
for (const bundle of payload.bundles) {
|
|
1333
|
+
console.log(`- ${bundle.bundle}: ${bundle.itemCount} item(s), ${bundle.pendingItemCount} pending, ${bundle.appliedItemCount} applied`);
|
|
1334
|
+
if (bundle.why) {
|
|
1335
|
+
console.log(` ${bundle.why}`);
|
|
1336
|
+
}
|
|
1337
|
+
console.log(` Preview: ${bundle.nextCommand}`);
|
|
1338
|
+
}
|
|
1339
|
+
if (payload.risks.length > 0) {
|
|
1340
|
+
console.log("Risks:");
|
|
1341
|
+
for (const risk of payload.risks) {
|
|
1342
|
+
console.log(`- ${risk}`);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
console.log("");
|
|
1346
|
+
console.log(`Next: ${payload.nextCommand}`);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* @param {string} inputPath
|
|
1351
|
+
* @returns {AnyRecord}
|
|
1352
|
+
*/
|
|
1353
|
+
export function buildBrownfieldImportAdoptListPayload(inputPath) {
|
|
1354
|
+
const artifacts = readImportAdoptionArtifacts(inputPath);
|
|
1355
|
+
const plan = buildBrownfieldImportPlanPayload(inputPath);
|
|
1356
|
+
const selectors = plan.bundles.map((/** @type {AnyRecord} */ bundle) => ({
|
|
1357
|
+
selector: `bundle:${bundle.bundle}`,
|
|
1358
|
+
kind: "bundle",
|
|
1359
|
+
bundle: bundle.bundle,
|
|
1360
|
+
itemCount: bundle.itemCount,
|
|
1361
|
+
pendingItemCount: bundle.pendingItemCount,
|
|
1362
|
+
appliedItemCount: bundle.appliedItemCount,
|
|
1363
|
+
blockedItemCount: bundle.blockedItemCount,
|
|
1364
|
+
complete: bundle.complete,
|
|
1365
|
+
previewCommand: importAdoptCommand(plan.projectRoot, `bundle:${bundle.bundle}`, false),
|
|
1366
|
+
writeCommand: importAdoptCommand(plan.projectRoot, `bundle:${bundle.bundle}`, true)
|
|
1367
|
+
}));
|
|
1368
|
+
const broadSelectors = buildBrownfieldBroadAdoptSelectors(plan.projectRoot, artifacts.adoptionPlan);
|
|
1369
|
+
return {
|
|
1370
|
+
ok: true,
|
|
1371
|
+
projectRoot: plan.projectRoot,
|
|
1372
|
+
topogramRoot: plan.topogramRoot,
|
|
1373
|
+
selectorCount: selectors.length,
|
|
1374
|
+
selectors,
|
|
1375
|
+
broadSelectorCount: broadSelectors.length,
|
|
1376
|
+
broadSelectors,
|
|
1377
|
+
nextCommand: selectors.find((/** @type {AnyRecord} */ selector) => !selector.complete)?.previewCommand || plan.commands.status
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* @param {AnyRecord} payload
|
|
1383
|
+
* @returns {void}
|
|
1384
|
+
*/
|
|
1385
|
+
export function printBrownfieldImportAdoptList(payload) {
|
|
1386
|
+
console.log(`Import adoption selectors for ${payload.projectRoot}`);
|
|
1387
|
+
if (payload.selectors.length === 0) {
|
|
1388
|
+
console.log("No adoption selectors are available. Run `topogram import plan` to inspect reconcile artifacts.");
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
for (const selector of payload.selectors) {
|
|
1392
|
+
console.log(`- ${selector.selector}: ${selector.itemCount} item(s), ${selector.pendingItemCount} pending, ${selector.appliedItemCount} applied`);
|
|
1393
|
+
console.log(` Preview: ${selector.previewCommand}`);
|
|
1394
|
+
console.log(` Write: ${selector.writeCommand}`);
|
|
1395
|
+
}
|
|
1396
|
+
if (payload.broadSelectors.length > 0) {
|
|
1397
|
+
console.log("");
|
|
1398
|
+
console.log("Broad selectors:");
|
|
1399
|
+
for (const selector of payload.broadSelectors) {
|
|
1400
|
+
console.log(`- ${selector.selector}: ${selector.itemCount} ${selector.label}`);
|
|
1401
|
+
console.log(` Preview: ${selector.previewCommand}`);
|
|
1402
|
+
console.log(` Write: ${selector.writeCommand}`);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
console.log("");
|
|
1406
|
+
console.log(`Next: ${payload.nextCommand}`);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* @param {string} outputRoot
|
|
1411
|
+
* @param {string[]} writtenFiles
|
|
1412
|
+
* @returns {AnyRecord[]}
|
|
1413
|
+
*/
|
|
1414
|
+
function writtenFileHashesForReceipt(outputRoot, writtenFiles) {
|
|
1415
|
+
return (writtenFiles || []).map((relativePath) => {
|
|
1416
|
+
const filePath = path.join(outputRoot, relativePath);
|
|
1417
|
+
const hash = fs.existsSync(filePath) ? projectFileHash(filePath) : null;
|
|
1418
|
+
return {
|
|
1419
|
+
path: relativePath,
|
|
1420
|
+
sha256: hash?.sha256 || null,
|
|
1421
|
+
size: hash?.size || null
|
|
1422
|
+
};
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* @param {{ artifacts: AnyRecord, selector: string, options: AnyRecord, importStatus: AnyRecord, summary: AnyRecord, writtenFiles: string[], outputRoot: string }} input
|
|
1428
|
+
* @returns {AnyRecord}
|
|
1429
|
+
*/
|
|
1430
|
+
function buildImportAdoptionReceipt({ artifacts, selector, options, importStatus, summary, writtenFiles, outputRoot }) {
|
|
1431
|
+
return {
|
|
1432
|
+
type: "topogram_import_adoption_receipt",
|
|
1433
|
+
version: "0.1",
|
|
1434
|
+
timestamp: new Date().toISOString(),
|
|
1435
|
+
cli: {
|
|
1436
|
+
packageName: CLI_PACKAGE_NAME,
|
|
1437
|
+
version: readInstalledCliPackageVersion()
|
|
1438
|
+
},
|
|
1439
|
+
projectRoot: artifacts.projectRoot,
|
|
1440
|
+
topogramRoot: artifacts.topogramRoot,
|
|
1441
|
+
selector,
|
|
1442
|
+
mode: "write",
|
|
1443
|
+
dryRun: false,
|
|
1444
|
+
forced: Boolean(options.force),
|
|
1445
|
+
reason: options.reason || null,
|
|
1446
|
+
sourceProvenance: {
|
|
1447
|
+
ok: importStatus.ok,
|
|
1448
|
+
status: importStatus.status,
|
|
1449
|
+
path: importStatus.path || null,
|
|
1450
|
+
changed: importStatus.content?.changed || [],
|
|
1451
|
+
added: importStatus.content?.added || [],
|
|
1452
|
+
removed: importStatus.content?.removed || []
|
|
1453
|
+
},
|
|
1454
|
+
promotedCanonicalItems: (summary.promoted_canonical_items || []).map((/** @type {AnyRecord} */ item) => ({
|
|
1455
|
+
bundle: item.bundle || null,
|
|
1456
|
+
kind: item.kind || null,
|
|
1457
|
+
item: item.item || null,
|
|
1458
|
+
canonicalRelPath: item.canonical_rel_path || null,
|
|
1459
|
+
sourcePath: item.source_path || null,
|
|
1460
|
+
changeType: item.change_type || null
|
|
1461
|
+
})),
|
|
1462
|
+
writtenFiles,
|
|
1463
|
+
writtenFileHashes: writtenFileHashesForReceipt(outputRoot, writtenFiles),
|
|
1464
|
+
outputRoot
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* @param {string} selector
|
|
1470
|
+
* @param {string} inputPath
|
|
1471
|
+
* @param {{ write?: boolean, dryRun?: boolean, force?: boolean, reason?: string|null, refreshAdopted?: boolean }} [options]
|
|
1472
|
+
* @returns {AnyRecord}
|
|
1473
|
+
*/
|
|
1474
|
+
export function buildBrownfieldImportAdoptPayload(selector, inputPath, options = {}) {
|
|
1475
|
+
if (!selector) {
|
|
1476
|
+
throw new Error("Missing required <selector>. Example: topogram import adopt bundle:task --dry-run");
|
|
1477
|
+
}
|
|
1478
|
+
if (options.write && options.dryRun) {
|
|
1479
|
+
throw new Error("Use either --dry-run or --write, not both.");
|
|
1480
|
+
}
|
|
1481
|
+
if (options.write && options.force && !options.reason) {
|
|
1482
|
+
throw new Error("Forced import adoption writes require --reason <text>.");
|
|
1483
|
+
}
|
|
1484
|
+
const artifacts = readImportAdoptionArtifacts(inputPath);
|
|
1485
|
+
const importStatus = buildTopogramImportStatus(artifacts.projectRoot);
|
|
1486
|
+
if (options.write && !options.force && !importStatus.ok) {
|
|
1487
|
+
throw new Error(`Refusing to write import adoption because brownfield source provenance is ${importStatus.status}. Run 'topogram import check ${importProjectCommandPath(artifacts.projectRoot)}', review the changed source evidence, rerun import, or pass --force --reason <text> after review.`);
|
|
1488
|
+
}
|
|
1489
|
+
const result = runWorkflow("reconcile", artifacts.projectRoot, {
|
|
1490
|
+
adopt: selector,
|
|
1491
|
+
write: Boolean(options.write),
|
|
1492
|
+
refreshAdopted: Boolean(options.refreshAdopted)
|
|
1493
|
+
});
|
|
1494
|
+
const outputRoot = path.resolve(result.defaultOutDir || artifacts.topogramRoot);
|
|
1495
|
+
const writtenFiles = options.write ? writeRelativeFiles(outputRoot, result.files || {}) : [];
|
|
1496
|
+
const summary = result.summary || {};
|
|
1497
|
+
const receipt = options.write
|
|
1498
|
+
? buildImportAdoptionReceipt({ artifacts, selector, options, importStatus, summary, writtenFiles, outputRoot })
|
|
1499
|
+
: null;
|
|
1500
|
+
const receiptPath = receipt ? appendImportAdoptionReceipt(artifacts.projectRoot, receipt) : null;
|
|
1501
|
+
return {
|
|
1502
|
+
ok: true,
|
|
1503
|
+
projectRoot: artifacts.projectRoot,
|
|
1504
|
+
topogramRoot: artifacts.topogramRoot,
|
|
1505
|
+
selector,
|
|
1506
|
+
dryRun: !options.write,
|
|
1507
|
+
write: Boolean(options.write),
|
|
1508
|
+
forced: Boolean(options.force),
|
|
1509
|
+
reason: options.reason || null,
|
|
1510
|
+
outputRoot,
|
|
1511
|
+
promotedCanonicalItemCount: (summary.promoted_canonical_items || []).length,
|
|
1512
|
+
promotedCanonicalItems: summary.promoted_canonical_items || [],
|
|
1513
|
+
writtenFiles,
|
|
1514
|
+
receipt,
|
|
1515
|
+
receiptPath,
|
|
1516
|
+
adoption: summary,
|
|
1517
|
+
import: importStatus,
|
|
1518
|
+
warnings: options.write && options.force && !importStatus.ok
|
|
1519
|
+
? [`Brownfield source provenance is ${importStatus.status}; adoption write was forced with reason: ${options.reason}.`]
|
|
1520
|
+
: [],
|
|
1521
|
+
nextCommands: options.write
|
|
1522
|
+
? [
|
|
1523
|
+
`topogram import history ${importProjectCommandPath(artifacts.projectRoot)}`,
|
|
1524
|
+
`topogram import status ${importProjectCommandPath(artifacts.projectRoot)}`,
|
|
1525
|
+
`topogram check ${importProjectCommandPath(artifacts.projectRoot)}`
|
|
1526
|
+
]
|
|
1527
|
+
: [
|
|
1528
|
+
importAdoptCommand(artifacts.projectRoot, selector, true),
|
|
1529
|
+
`topogram import status ${importProjectCommandPath(artifacts.projectRoot)}`
|
|
1530
|
+
]
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* @param {AnyRecord} payload
|
|
1536
|
+
* @returns {void}
|
|
1537
|
+
*/
|
|
1538
|
+
export function printBrownfieldImportAdopt(payload) {
|
|
1539
|
+
console.log(`${payload.dryRun ? "Previewed" : "Applied"} import adoption for ${payload.selector}.`);
|
|
1540
|
+
console.log(`Project: ${payload.projectRoot}`);
|
|
1541
|
+
console.log(`Promoted canonical items: ${payload.promotedCanonicalItemCount}`);
|
|
1542
|
+
console.log(`Written files: ${payload.writtenFiles.length}`);
|
|
1543
|
+
if (payload.receiptPath) {
|
|
1544
|
+
console.log(`Receipt: ${payload.receiptPath}`);
|
|
1545
|
+
}
|
|
1546
|
+
if (payload.dryRun) {
|
|
1547
|
+
console.log("No files were written. Re-run with --write to promote these candidates.");
|
|
1548
|
+
}
|
|
1549
|
+
for (const warning of payload.warnings || []) {
|
|
1550
|
+
console.log(`Warning: ${warning}`);
|
|
1551
|
+
}
|
|
1552
|
+
console.log("");
|
|
1553
|
+
console.log("Next steps:");
|
|
1554
|
+
for (const command of payload.nextCommands) {
|
|
1555
|
+
console.log(` ${command}`);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* @param {string} inputPath
|
|
1561
|
+
* @returns {AnyRecord}
|
|
1562
|
+
*/
|
|
1563
|
+
export function buildBrownfieldImportStatusPayload(inputPath) {
|
|
1564
|
+
const artifacts = readImportAdoptionArtifacts(inputPath);
|
|
1565
|
+
const importCheck = buildBrownfieldImportCheckPayload(artifacts.projectRoot);
|
|
1566
|
+
const adoptionStatus = runWorkflow("adoption-status", artifacts.projectRoot).summary || artifacts.adoptionStatus || {};
|
|
1567
|
+
const adoption = summarizeImportAdoption(artifacts.adoptionPlan, adoptionStatus, artifacts.projectRoot);
|
|
1568
|
+
const history = buildBrownfieldImportHistoryPayload(artifacts.projectRoot);
|
|
1569
|
+
return {
|
|
1570
|
+
ok: importCheck.ok,
|
|
1571
|
+
projectRoot: artifacts.projectRoot,
|
|
1572
|
+
topogramRoot: artifacts.topogramRoot,
|
|
1573
|
+
import: importCheck.import,
|
|
1574
|
+
topogram: importCheck.topogram,
|
|
1575
|
+
adoption: {
|
|
1576
|
+
status: adoptionStatus,
|
|
1577
|
+
summary: adoption.summary,
|
|
1578
|
+
bundles: adoption.bundles,
|
|
1579
|
+
risks: adoption.risks,
|
|
1580
|
+
nextCommand: adoption.nextCommand,
|
|
1581
|
+
history: history.summary
|
|
1582
|
+
},
|
|
1583
|
+
errors: importCheck.errors
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* @param {AnyRecord} payload
|
|
1589
|
+
* @returns {void}
|
|
1590
|
+
*/
|
|
1591
|
+
export function printBrownfieldImportStatus(payload) {
|
|
1592
|
+
console.log(`Import status: ${payload.import.status}`);
|
|
1593
|
+
console.log(`Topogram check: ${payload.topogram.ok ? "passed" : "failed"}`);
|
|
1594
|
+
console.log(`Adoption: ${payload.adoption.summary.appliedItemCount} applied, ${payload.adoption.summary.pendingItemCount} pending, ${payload.adoption.summary.blockedItemCount} blocked`);
|
|
1595
|
+
const next = payload.adoption.nextCommand;
|
|
1596
|
+
if (next) {
|
|
1597
|
+
console.log(`Next: ${next}`);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* @param {string} projectRoot
|
|
1603
|
+
* @param {AnyRecord[]} receipts
|
|
1604
|
+
* @returns {AnyRecord}
|
|
1605
|
+
*/
|
|
1606
|
+
function verifyImportAdoptionReceipts(projectRoot, receipts) {
|
|
1607
|
+
const topogramRoot = normalizeTopogramPath(projectRoot);
|
|
1608
|
+
const files = [];
|
|
1609
|
+
for (const receipt of receipts || []) {
|
|
1610
|
+
const hashedFiles = Array.isArray(receipt.writtenFileHashes) ? receipt.writtenFileHashes : [];
|
|
1611
|
+
const hashedPaths = new Set(hashedFiles.map((/** @type {AnyRecord} */ item) => item.path));
|
|
1612
|
+
for (const item of hashedFiles) {
|
|
1613
|
+
const relativePath = item.path;
|
|
1614
|
+
const filePath = path.join(topogramRoot, relativePath);
|
|
1615
|
+
if (!fs.existsSync(filePath)) {
|
|
1616
|
+
files.push({
|
|
1617
|
+
receiptTimestamp: receipt.timestamp || null,
|
|
1618
|
+
selector: receipt.selector || null,
|
|
1619
|
+
path: relativePath,
|
|
1620
|
+
status: "removed",
|
|
1621
|
+
expectedSha256: item.sha256 || null,
|
|
1622
|
+
currentSha256: null,
|
|
1623
|
+
expectedSize: item.size ?? null,
|
|
1624
|
+
currentSize: null
|
|
1625
|
+
});
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
const currentHash = projectFileHash(filePath);
|
|
1629
|
+
const matches = item.sha256 === currentHash.sha256 && item.size === currentHash.size;
|
|
1630
|
+
files.push({
|
|
1631
|
+
receiptTimestamp: receipt.timestamp || null,
|
|
1632
|
+
selector: receipt.selector || null,
|
|
1633
|
+
path: relativePath,
|
|
1634
|
+
status: matches ? "matched" : "changed",
|
|
1635
|
+
expectedSha256: item.sha256 || null,
|
|
1636
|
+
currentSha256: currentHash.sha256,
|
|
1637
|
+
expectedSize: item.size ?? null,
|
|
1638
|
+
currentSize: currentHash.size
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
for (const relativePath of receipt.writtenFiles || []) {
|
|
1642
|
+
if (hashedPaths.has(relativePath)) {
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
files.push({
|
|
1646
|
+
receiptTimestamp: receipt.timestamp || null,
|
|
1647
|
+
selector: receipt.selector || null,
|
|
1648
|
+
path: relativePath,
|
|
1649
|
+
status: "unverifiable",
|
|
1650
|
+
expectedSha256: null,
|
|
1651
|
+
currentSha256: null,
|
|
1652
|
+
expectedSize: null,
|
|
1653
|
+
currentSize: null
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
const summary = {
|
|
1658
|
+
checkedFileCount: files.length,
|
|
1659
|
+
matchedFileCount: files.filter((item) => item.status === "matched").length,
|
|
1660
|
+
changedFileCount: files.filter((item) => item.status === "changed").length,
|
|
1661
|
+
removedFileCount: files.filter((item) => item.status === "removed").length,
|
|
1662
|
+
unverifiableFileCount: files.filter((item) => item.status === "unverifiable").length
|
|
1663
|
+
};
|
|
1664
|
+
const status = summary.changedFileCount > 0 || summary.removedFileCount > 0
|
|
1665
|
+
? "changed"
|
|
1666
|
+
: summary.unverifiableFileCount > 0
|
|
1667
|
+
? "unverifiable"
|
|
1668
|
+
: "matched";
|
|
1669
|
+
return {
|
|
1670
|
+
status,
|
|
1671
|
+
summary,
|
|
1672
|
+
files,
|
|
1673
|
+
auditOnly: true,
|
|
1674
|
+
note: "History verification is audit-only. Imported/adopted Topogram files are project-owned, and edits do not make the workspace invalid."
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/**
|
|
1679
|
+
* @param {string} inputPath
|
|
1680
|
+
* @param {{ verify?: boolean }} [options]
|
|
1681
|
+
* @returns {AnyRecord}
|
|
1682
|
+
*/
|
|
1683
|
+
export function buildBrownfieldImportHistoryPayload(inputPath, options = {}) {
|
|
1684
|
+
const projectRoot = normalizeProjectRoot(inputPath);
|
|
1685
|
+
const historyPath = importAdoptionsPath(projectRoot);
|
|
1686
|
+
const receipts = readImportAdoptionReceipts(projectRoot);
|
|
1687
|
+
const forcedWrites = receipts.filter((receipt) => receipt.forced);
|
|
1688
|
+
const verification = options.verify ? verifyImportAdoptionReceipts(projectRoot, receipts) : null;
|
|
1689
|
+
return {
|
|
1690
|
+
ok: true,
|
|
1691
|
+
projectRoot,
|
|
1692
|
+
path: historyPath,
|
|
1693
|
+
exists: fs.existsSync(historyPath),
|
|
1694
|
+
verified: Boolean(options.verify),
|
|
1695
|
+
summary: {
|
|
1696
|
+
receiptCount: receipts.length,
|
|
1697
|
+
writeCount: receipts.filter((receipt) => receipt.mode === "write").length,
|
|
1698
|
+
forcedWriteCount: forcedWrites.length,
|
|
1699
|
+
lastTimestamp: receipts[receipts.length - 1]?.timestamp || null,
|
|
1700
|
+
lastSelector: receipts[receipts.length - 1]?.selector || null
|
|
1701
|
+
},
|
|
1702
|
+
verification,
|
|
1703
|
+
receipts
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
/**
|
|
1708
|
+
* @param {AnyRecord} payload
|
|
1709
|
+
* @returns {void}
|
|
1710
|
+
*/
|
|
1711
|
+
export function printBrownfieldImportHistory(payload) {
|
|
1712
|
+
console.log(`Import adoption history for ${payload.projectRoot}`);
|
|
1713
|
+
console.log(`Receipts: ${payload.summary.receiptCount}`);
|
|
1714
|
+
console.log(`Forced writes: ${payload.summary.forcedWriteCount}`);
|
|
1715
|
+
if (!payload.exists) {
|
|
1716
|
+
console.log(`No history file found at ${payload.path}.`);
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
for (const receipt of payload.receipts) {
|
|
1720
|
+
const forced = receipt.forced ? " forced" : "";
|
|
1721
|
+
const reason = receipt.reason ? ` reason="${receipt.reason}"` : "";
|
|
1722
|
+
console.log(`- ${receipt.timestamp}: ${receipt.selector}${forced}, ${receipt.writtenFiles?.length || 0} file(s), source=${receipt.sourceProvenance?.status || "unknown"}${reason}`);
|
|
1723
|
+
}
|
|
1724
|
+
if (payload.verification) {
|
|
1725
|
+
const summary = payload.verification.summary;
|
|
1726
|
+
console.log("");
|
|
1727
|
+
console.log(`Verification: ${payload.verification.status}`);
|
|
1728
|
+
console.log(`Matched: ${summary.matchedFileCount}; changed: ${summary.changedFileCount}; removed: ${summary.removedFileCount}; unverifiable: ${summary.unverifiableFileCount}`);
|
|
1729
|
+
for (const file of payload.verification.files.filter((/** @type {AnyRecord} */ item) => item.status !== "matched")) {
|
|
1730
|
+
console.log(`- ${file.path}: ${file.status}`);
|
|
1731
|
+
}
|
|
1732
|
+
console.log(payload.verification.note);
|
|
1733
|
+
}
|
|
1734
|
+
}
|