@topogram/cli 0.3.78 → 0.3.80
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/CHANGELOG.md +20 -0
- package/package.json +2 -2
- package/src/agent-brief.js +29 -23
- package/src/agent-ops/query-builders/change-risk/{import-plan.js → extract-plan.js} +1 -1
- package/src/agent-ops/query-builders/change-risk/review-packets.js +5 -5
- package/src/agent-ops/query-builders/change-risk.js +1 -1
- package/src/agent-ops/query-builders/common.js +2 -2
- package/src/agent-ops/query-builders/multi-agent.js +1 -1
- package/src/agent-ops/query-builders/workflow-context-shared.js +4 -4
- package/src/catalog/provenance.js +1 -1
- package/src/cli/catalog-alias.d.ts +2 -0
- package/src/cli/catalog-alias.js +2 -2
- package/src/cli/command-parser.js +2 -0
- package/src/cli/command-parsers/core.js +9 -5
- package/src/cli/command-parsers/extractor.js +40 -0
- package/src/cli/command-parsers/import.js +11 -17
- package/src/cli/command-parsers/project.js +0 -3
- package/src/cli/commands/catalog/copy.js +3 -3
- package/src/cli/commands/catalog/help.js +1 -2
- package/src/cli/commands/catalog/list.js +7 -4
- package/src/cli/commands/catalog/show.js +4 -4
- package/src/cli/commands/copy.js +356 -0
- package/src/cli/commands/doctor.js +1 -1
- package/src/cli/commands/extractor.js +451 -0
- package/src/cli/commands/import/adopt.js +9 -9
- package/src/cli/commands/import/check.js +15 -15
- package/src/cli/commands/import/diff.js +6 -6
- package/src/cli/commands/import/help.js +45 -34
- package/src/cli/commands/import/paths.js +3 -3
- package/src/cli/commands/import/plan.js +8 -8
- package/src/cli/commands/import/refresh.js +25 -24
- package/src/cli/commands/import/status-history.js +4 -4
- package/src/cli/commands/import/workspace.js +24 -18
- package/src/cli/commands/import-runner.js +10 -7
- package/src/cli/commands/import.js +4 -1
- package/src/cli/commands/init.js +67 -0
- package/src/cli/commands/query/{import-adopt.js → extract-adopt.js} +2 -2
- package/src/cli/commands/query/runner/change.js +2 -2
- package/src/cli/commands/query/runner/{import-adopt.js → extract-adopt.js} +9 -9
- package/src/cli/commands/query/runner/index.js +1 -1
- package/src/cli/commands/query/runner/workflow.js +7 -7
- package/src/cli/commands/query/workspace.js +4 -4
- package/src/cli/commands/release-status.js +2 -2
- package/src/cli/commands/source.js +2 -2
- package/src/cli/commands/template/check.js +2 -2
- package/src/cli/commands/template/list-show.js +4 -4
- package/src/cli/dispatcher.js +32 -3
- package/src/cli/help-dispatch.js +33 -8
- package/src/cli/help.js +79 -52
- package/src/cli/migration-guidance.js +9 -0
- package/src/cli/options.js +17 -0
- package/src/extractor/check.js +155 -0
- package/src/extractor/packages.js +295 -0
- package/src/extractor/registry.js +196 -0
- package/src/extractor-policy.js +249 -0
- package/src/generator/check.js +24 -87
- package/src/generator/context/bundle.js +14 -7
- package/src/generator/context/diff.js +8 -1
- package/src/generator/context/digest.js +10 -1
- package/src/generator/context/shared/domain-sdlc.js +5 -1
- package/src/generator/context/shared/relationships.js +20 -5
- package/src/generator/context/shared/summaries.js +26 -0
- package/src/generator/context/shared.d.ts +1 -0
- package/src/generator/context/shared.js +1 -0
- package/src/generator/context/slice/core.js +9 -5
- package/src/generator/context/slice/sdlc.js +31 -2
- package/src/generator/context/task-mode.js +3 -3
- package/src/generator/registry/index.js +16 -75
- package/src/generator-policy.js +9 -57
- package/src/import/core/registry.d.ts +3 -0
- package/src/import/core/registry.js +82 -8
- package/src/import/core/runner/reports.js +4 -4
- package/src/import/core/runner/run.js +2 -0
- package/src/import/core/runner/tracks.js +66 -4
- package/src/import/provenance.js +18 -17
- package/src/init-project.js +215 -0
- package/src/new-project/constants.js +1 -1
- package/src/new-project/create.js +2 -2
- package/src/new-project/project-files.js +7 -7
- package/src/package-adapters/adapter.js +64 -0
- package/src/package-adapters/file-map.js +30 -0
- package/src/package-adapters/index.js +27 -0
- package/src/package-adapters/manifest.js +108 -0
- package/src/package-adapters/policy.js +81 -0
- package/src/package-adapters/spec.js +51 -0
- package/src/reconcile/journeys.js +8 -3
- package/src/record-blocks.js +125 -0
- package/src/resolver/index.js +3 -0
- package/src/resolver/journeys.js +74 -0
- package/src/resolver/normalize.js +25 -0
- package/src/sdlc/adopt.js +1 -1
- package/src/validator/common.js +34 -1
- package/src/validator/index.js +4 -0
- package/src/validator/kinds.d.ts +2 -0
- package/src/validator/kinds.js +34 -1
- package/src/validator/per-kind/journey.js +233 -0
- package/src/workflows/docs-generate.js +4 -1
- package/src/workflows/reconcile/bundle-core/index.js +4 -2
- package/src/workflows/reconcile/canonical-surface.js +4 -1
- package/src/cli/commands/new.js +0 -94
package/src/import/provenance.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
import { listFilesRecursive, relativeTo } from "./core/shared.js";
|
|
6
6
|
|
|
7
|
-
export const TOPOGRAM_IMPORT_FILE = ".topogram-
|
|
7
|
+
export const TOPOGRAM_IMPORT_FILE = ".topogram-extract.json";
|
|
8
8
|
|
|
9
9
|
function fileHash(filePath) {
|
|
10
10
|
const bytes = fs.readFileSync(filePath);
|
|
@@ -42,22 +42,23 @@ export function writeTopogramImportRecord(projectRoot, input) {
|
|
|
42
42
|
const timestamp = input.timestamp || new Date().toISOString();
|
|
43
43
|
const record = {
|
|
44
44
|
version: "0.1",
|
|
45
|
-
kind: "brownfield-
|
|
46
|
-
|
|
45
|
+
kind: "brownfield-extract",
|
|
46
|
+
extractedAt: input.importedAt || timestamp,
|
|
47
47
|
...(input.refreshedAt ? { refreshedAt: input.refreshedAt } : {}),
|
|
48
48
|
source: {
|
|
49
49
|
path: path.resolve(input.sourceRoot),
|
|
50
50
|
hashAlgorithm: "sha256",
|
|
51
51
|
ignoredRoots: (input.ignoredRoots || []).map((item) => path.resolve(item))
|
|
52
52
|
},
|
|
53
|
-
|
|
53
|
+
extract: {
|
|
54
54
|
tracks: input.tracks || [],
|
|
55
55
|
findingsCount: input.findingsCount || 0,
|
|
56
|
-
candidateCounts: input.candidateCounts || {}
|
|
56
|
+
candidateCounts: input.candidateCounts || {},
|
|
57
|
+
extractorPackages: input.extractorPackages || []
|
|
57
58
|
},
|
|
58
59
|
ownership: {
|
|
59
|
-
|
|
60
|
-
note: "Topogram artifacts created by
|
|
60
|
+
extractedArtifacts: "project-owned",
|
|
61
|
+
note: "Topogram artifacts created by extraction are editable after extraction. Source hashes record the brownfield app evidence trusted at extraction time."
|
|
61
62
|
},
|
|
62
63
|
...(input.refresh ? { refresh: input.refresh } : {}),
|
|
63
64
|
files: input.files || []
|
|
@@ -79,11 +80,11 @@ export function buildTopogramImportStatus(projectRoot) {
|
|
|
79
80
|
source: null,
|
|
80
81
|
content: { changed: [], added: [], removed: [] },
|
|
81
82
|
diagnostics: [{
|
|
82
|
-
code: "
|
|
83
|
+
code: "topogram_extract_missing",
|
|
83
84
|
severity: "error",
|
|
84
|
-
message: `${TOPOGRAM_IMPORT_FILE} was not found. This workspace does not have brownfield
|
|
85
|
+
message: `${TOPOGRAM_IMPORT_FILE} was not found. This workspace does not have brownfield extraction provenance.`,
|
|
85
86
|
path: importPath,
|
|
86
|
-
suggestedFix: "Run `topogram
|
|
87
|
+
suggestedFix: "Run `topogram extract <app-path> --out <target>` to create an extracted Topogram workspace."
|
|
87
88
|
}],
|
|
88
89
|
errors: [`${TOPOGRAM_IMPORT_FILE} was not found.`]
|
|
89
90
|
};
|
|
@@ -92,7 +93,7 @@ export function buildTopogramImportStatus(projectRoot) {
|
|
|
92
93
|
const source = JSON.parse(fs.readFileSync(importPath, "utf8"));
|
|
93
94
|
const sourceRoot = path.resolve(source.source?.path || "");
|
|
94
95
|
if (!sourceRoot || !fs.existsSync(sourceRoot) || !fs.statSync(sourceRoot).isDirectory()) {
|
|
95
|
-
const message = `
|
|
96
|
+
const message = `Extracted source path was not found: ${source.source?.path || "unknown"}`;
|
|
96
97
|
return {
|
|
97
98
|
ok: false,
|
|
98
99
|
exists: true,
|
|
@@ -101,11 +102,11 @@ export function buildTopogramImportStatus(projectRoot) {
|
|
|
101
102
|
source,
|
|
102
103
|
content: { changed: [], added: [], removed: [] },
|
|
103
104
|
diagnostics: [{
|
|
104
|
-
|
|
105
|
+
code: "topogram_extract_source_missing",
|
|
105
106
|
severity: "error",
|
|
106
107
|
message,
|
|
107
108
|
path: source.source?.path || null,
|
|
108
|
-
suggestedFix: "Restore the
|
|
109
|
+
suggestedFix: "Restore the extracted source path or rerun extract from the current brownfield app location."
|
|
109
110
|
}],
|
|
110
111
|
errors: [message]
|
|
111
112
|
};
|
|
@@ -147,12 +148,12 @@ export function buildTopogramImportStatus(projectRoot) {
|
|
|
147
148
|
source,
|
|
148
149
|
content,
|
|
149
150
|
diagnostics: clean ? [] : [{
|
|
150
|
-
code: "
|
|
151
|
+
code: "topogram_extract_source_changed",
|
|
151
152
|
severity: "error",
|
|
152
|
-
message: "
|
|
153
|
+
message: "Extracted source files changed since they were trusted for this extraction.",
|
|
153
154
|
path: sourceRoot,
|
|
154
|
-
suggestedFix: "Review the source changes. If they should drive Topogram changes, rerun
|
|
155
|
+
suggestedFix: "Review the source changes. If they should drive Topogram changes, rerun extract or update the Topogram artifacts manually."
|
|
155
156
|
}],
|
|
156
|
-
errors: clean ? [] : ["
|
|
157
|
+
errors: clean ? [] : ["Extracted source files changed since extraction."]
|
|
157
158
|
};
|
|
158
159
|
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { defaultSdlcPolicy, SDLC_POLICY_FILE } from "./sdlc/policy.js";
|
|
7
|
+
import { DEFAULT_TOPO_FOLDER_NAME, DEFAULT_WORKSPACE_PATH, PROJECT_CONFIG_FILE } from "./workspace-paths.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} InitProjectOptions
|
|
11
|
+
* @property {string} [targetPath]
|
|
12
|
+
* @property {boolean} [withSdlc]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} InitProjectResult
|
|
17
|
+
* @property {boolean} ok
|
|
18
|
+
* @property {string} projectRoot
|
|
19
|
+
* @property {string} workspaceRoot
|
|
20
|
+
* @property {string} projectConfigPath
|
|
21
|
+
* @property {string[]} created
|
|
22
|
+
* @property {string[]} skipped
|
|
23
|
+
* @property {Record<string, any>} projectConfig
|
|
24
|
+
* @property {{ enabled: boolean, path: string|null }} sdlc
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} projectRoot
|
|
29
|
+
* @param {string} targetPath
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function relativeProjectPath(projectRoot, targetPath) {
|
|
33
|
+
const relative = path.relative(projectRoot, targetPath);
|
|
34
|
+
return relative ? relative.split(path.sep).join("/") : ".";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} projectRoot
|
|
39
|
+
* @param {string} filePath
|
|
40
|
+
* @param {string} content
|
|
41
|
+
* @param {string[]} created
|
|
42
|
+
* @param {string[]} skipped
|
|
43
|
+
* @returns {void}
|
|
44
|
+
*/
|
|
45
|
+
function writeIfMissing(projectRoot, filePath, content, created, skipped) {
|
|
46
|
+
if (fs.existsSync(filePath)) {
|
|
47
|
+
skipped.push(relativeProjectPath(projectRoot, filePath));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
51
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
52
|
+
created.push(relativeProjectPath(projectRoot, filePath));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @returns {Record<string, any>}
|
|
57
|
+
*/
|
|
58
|
+
function defaultMaintainedProjectConfig() {
|
|
59
|
+
return {
|
|
60
|
+
version: "0.1",
|
|
61
|
+
workspace: DEFAULT_WORKSPACE_PATH,
|
|
62
|
+
outputs: {
|
|
63
|
+
app: {
|
|
64
|
+
path: ".",
|
|
65
|
+
ownership: "maintained"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
topology: {
|
|
69
|
+
runtimes: []
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
function initializedReadme() {
|
|
78
|
+
return `# Topogram Project
|
|
79
|
+
|
|
80
|
+
Initialized with \`topogram init\`.
|
|
81
|
+
|
|
82
|
+
This repository is treated as a maintained app or workspace: Topogram will not
|
|
83
|
+
overwrite source code under \`./\`. Use \`topogram emit\` for contracts, reports,
|
|
84
|
+
snapshots, and proposals, and edit maintained app code directly after reading
|
|
85
|
+
focused query packets.
|
|
86
|
+
|
|
87
|
+
## First Commands
|
|
88
|
+
|
|
89
|
+
\`\`\`bash
|
|
90
|
+
topogram agent brief --json
|
|
91
|
+
topogram check --json
|
|
92
|
+
topogram query list --json
|
|
93
|
+
\`\`\`
|
|
94
|
+
|
|
95
|
+
To adopt enforced SDLC after initialization, run:
|
|
96
|
+
|
|
97
|
+
\`\`\`bash
|
|
98
|
+
topogram sdlc policy init .
|
|
99
|
+
\`\`\`
|
|
100
|
+
|
|
101
|
+
## Source
|
|
102
|
+
|
|
103
|
+
- \`topo/\` is the project-owned Topogram workspace.
|
|
104
|
+
- \`topogram.project.json\` declares workspace, output ownership, and runtime topology.
|
|
105
|
+
- Output \`app\` points at \`.\` with \`maintained\` ownership.
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function initializedAgentsGuide() {
|
|
113
|
+
return `# Agent Guide
|
|
114
|
+
|
|
115
|
+
This repository was initialized with \`topogram init\`.
|
|
116
|
+
|
|
117
|
+
Start with:
|
|
118
|
+
|
|
119
|
+
\`\`\`bash
|
|
120
|
+
topogram agent brief --json
|
|
121
|
+
topogram check --json
|
|
122
|
+
topogram query list --json
|
|
123
|
+
\`\`\`
|
|
124
|
+
|
|
125
|
+
Edit \`topo/**\` and \`topogram.project.json\` for Topogram source. The project
|
|
126
|
+
output is maintained, so app/source files under \`./\` are human-owned and may be
|
|
127
|
+
edited directly after reading focused packets.
|
|
128
|
+
|
|
129
|
+
Use \`topogram emit <target>\` for contracts, reports, snapshots, migration
|
|
130
|
+
plans, and agent context. Do not expect \`topogram generate\` to overwrite this
|
|
131
|
+
maintained app unless output ownership is deliberately changed.
|
|
132
|
+
|
|
133
|
+
If \`topogram.sdlc-policy.json\` exists, use SDLC commands for task and status
|
|
134
|
+
work before protected edits:
|
|
135
|
+
|
|
136
|
+
\`\`\`bash
|
|
137
|
+
topogram sdlc policy explain --json
|
|
138
|
+
topogram sdlc prep commit . --json
|
|
139
|
+
\`\`\`
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {string} projectRoot
|
|
145
|
+
* @returns {void}
|
|
146
|
+
*/
|
|
147
|
+
function assertInitTarget(projectRoot) {
|
|
148
|
+
if (fs.existsSync(projectRoot) && !fs.statSync(projectRoot).isDirectory()) {
|
|
149
|
+
throw new Error(`Cannot initialize Topogram at '${projectRoot}' because it is not a directory.`);
|
|
150
|
+
}
|
|
151
|
+
const configPath = path.join(projectRoot, PROJECT_CONFIG_FILE);
|
|
152
|
+
if (fs.existsSync(configPath)) {
|
|
153
|
+
throw new Error(`Refusing to initialize Topogram because ${PROJECT_CONFIG_FILE} already exists.`);
|
|
154
|
+
}
|
|
155
|
+
const workspaceRoot = path.join(projectRoot, DEFAULT_TOPO_FOLDER_NAME);
|
|
156
|
+
if (fs.existsSync(workspaceRoot)) {
|
|
157
|
+
if (!fs.statSync(workspaceRoot).isDirectory()) {
|
|
158
|
+
throw new Error(`Refusing to initialize Topogram because ${DEFAULT_TOPO_FOLDER_NAME}/ exists and is not a directory.`);
|
|
159
|
+
}
|
|
160
|
+
const entries = fs.readdirSync(workspaceRoot).filter(/** @param {string} entry */ (entry) => entry !== ".DS_Store");
|
|
161
|
+
if (entries.length > 0) {
|
|
162
|
+
throw new Error(`Refusing to initialize Topogram because ${DEFAULT_TOPO_FOLDER_NAME}/ already exists and is not empty.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {InitProjectOptions} [options]
|
|
169
|
+
* @returns {InitProjectResult}
|
|
170
|
+
*/
|
|
171
|
+
export function initTopogramProject(options = {}) {
|
|
172
|
+
const projectRoot = path.resolve(options.targetPath || ".");
|
|
173
|
+
assertInitTarget(projectRoot);
|
|
174
|
+
fs.mkdirSync(projectRoot, { recursive: true });
|
|
175
|
+
|
|
176
|
+
/** @type {string[]} */
|
|
177
|
+
const created = [];
|
|
178
|
+
/** @type {string[]} */
|
|
179
|
+
const skipped = [];
|
|
180
|
+
const workspaceRoot = path.join(projectRoot, DEFAULT_TOPO_FOLDER_NAME);
|
|
181
|
+
fs.mkdirSync(workspaceRoot, { recursive: true });
|
|
182
|
+
created.push(DEFAULT_TOPO_FOLDER_NAME);
|
|
183
|
+
|
|
184
|
+
writeIfMissing(projectRoot, path.join(workspaceRoot, ".gitkeep"), "", created, skipped);
|
|
185
|
+
const projectConfig = defaultMaintainedProjectConfig();
|
|
186
|
+
const projectConfigPath = path.join(projectRoot, PROJECT_CONFIG_FILE);
|
|
187
|
+
fs.writeFileSync(projectConfigPath, `${JSON.stringify(projectConfig, null, 2)}\n`, "utf8");
|
|
188
|
+
created.push(PROJECT_CONFIG_FILE);
|
|
189
|
+
writeIfMissing(projectRoot, path.join(projectRoot, "README.md"), initializedReadme(), created, skipped);
|
|
190
|
+
writeIfMissing(projectRoot, path.join(projectRoot, "AGENTS.md"), initializedAgentsGuide(), created, skipped);
|
|
191
|
+
const sdlcPolicyPath = path.join(projectRoot, SDLC_POLICY_FILE);
|
|
192
|
+
if (options.withSdlc) {
|
|
193
|
+
writeIfMissing(
|
|
194
|
+
projectRoot,
|
|
195
|
+
sdlcPolicyPath,
|
|
196
|
+
`${JSON.stringify(defaultSdlcPolicy(), null, 2)}\n`,
|
|
197
|
+
created,
|
|
198
|
+
skipped
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
projectRoot,
|
|
205
|
+
workspaceRoot,
|
|
206
|
+
projectConfigPath,
|
|
207
|
+
created,
|
|
208
|
+
skipped,
|
|
209
|
+
projectConfig,
|
|
210
|
+
sdlc: {
|
|
211
|
+
enabled: options.withSdlc ? fs.existsSync(sdlcPolicyPath) : false,
|
|
212
|
+
path: options.withSdlc ? sdlcPolicyPath : null
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
@@ -31,7 +31,7 @@ export const SURFACE_ORDER = new Map([
|
|
|
31
31
|
* @returns {string}
|
|
32
32
|
*/
|
|
33
33
|
export function unsupportedTemplateSymlinkMessage(templateId, relativePath) {
|
|
34
|
-
return `Template '${templateId}' contains unsupported symlink '${relativePath}'. Template packs must copy real files because Topogram records hashes for copied workspace and implementation/ content; symlinks can point outside the trusted template root. Replace the symlink with a real file or directory before running topogram
|
|
34
|
+
return `Template '${templateId}' contains unsupported symlink '${relativePath}'. Template packs must copy real files because Topogram records hashes for copied workspace and implementation/ content; symlinks can point outside the trusted template root. Replace the symlink with a real file or directory before running topogram copy or topogram template check.`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
@@ -36,7 +36,7 @@ export function createNewProject({
|
|
|
36
36
|
templateProvenance = null
|
|
37
37
|
}) {
|
|
38
38
|
if (!targetPath) {
|
|
39
|
-
throw new Error("topogram
|
|
39
|
+
throw new Error("topogram copy requires <target>.");
|
|
40
40
|
}
|
|
41
41
|
const projectRoot = path.resolve(targetPath);
|
|
42
42
|
assertProjectOutsideEngine(projectRoot, engineRoot);
|
|
@@ -68,7 +68,7 @@ export function createNewProject({
|
|
|
68
68
|
writeTemplateTrustRecord(projectRoot, projectConfig);
|
|
69
69
|
warnings.push(
|
|
70
70
|
`Template '${template.manifest.id}' copied implementation/ code into this project. ` +
|
|
71
|
-
"topogram
|
|
71
|
+
"topogram copy did not execute it, but topogram generate may load it later. " +
|
|
72
72
|
"Recorded local trust in .topogram-template-trust.json."
|
|
73
73
|
);
|
|
74
74
|
}
|
|
@@ -247,7 +247,7 @@ export function writeProjectReadme(projectRoot, projectConfig) {
|
|
|
247
247
|
provenanceLines.push(`- Executable implementation: \`${template.includesExecutableImplementation ? "yes" : "no"}\``);
|
|
248
248
|
const readme = `# ${packageNameFromPath(projectRoot)}
|
|
249
249
|
|
|
250
|
-
|
|
250
|
+
Copied by \`topogram copy\`.
|
|
251
251
|
|
|
252
252
|
## Template
|
|
253
253
|
|
|
@@ -263,7 +263,7 @@ Edit the workspace folder \`${DEFAULT_TOPO_FOLDER_NAME}/\` and \`topogram.projec
|
|
|
263
263
|
Generated app code is written to \`app/\`.
|
|
264
264
|
Use \`topogram emit <target>\` to inspect contracts, reports, snapshots, and other artifacts without regenerating the app.
|
|
265
265
|
Agents should start with \`AGENTS.md\` and \`npm run agent:brief\`. The direct \`topogram agent brief --json\` command is the canonical machine-readable first-run guidance.
|
|
266
|
-
${template.includesExecutableImplementation ? "\nThis template copied `implementation/` code. `topogram
|
|
266
|
+
${template.includesExecutableImplementation ? "\nThis template copied `implementation/` code. `topogram copy` did not execute it; review `implementation/`, `topogram.template-policy.json`, and `.topogram-template-trust.json` before regenerating after edits.\n" : ""}
|
|
267
267
|
`;
|
|
268
268
|
fs.writeFileSync(path.join(projectRoot, "README.md"), readme, "utf8");
|
|
269
269
|
}
|
|
@@ -339,12 +339,12 @@ npm run query:show -- widget-behavior
|
|
|
339
339
|
|
|
340
340
|
- Local edits to template-derived Topogram files are project-owned.
|
|
341
341
|
- Use \`npm run source:status\` and \`npm run template:update:recommend\` before applying template updates.
|
|
342
|
-
${hasImplementation ? "- This project has executable `implementation/` code. `topogram
|
|
343
|
-
##
|
|
342
|
+
${hasImplementation ? "- This project has executable `implementation/` code. `topogram copy` did not execute it. Do not refresh trust until the implementation has been reviewed.\n" : "- This template does not declare executable implementation code.\n"}
|
|
343
|
+
## Extract And Adopt
|
|
344
344
|
|
|
345
|
-
- If \`.topogram-
|
|
346
|
-
-
|
|
347
|
-
-
|
|
345
|
+
- If \`.topogram-extract.json\` exists, agents should run \`topogram extract check . --json\`, \`topogram extract plan . --json\`, \`topogram adopt --list . --json\`, \`topogram extract status . --json\`, and \`topogram extract history . --verify --json\`.
|
|
346
|
+
- Extract JSON payloads expose \`workspaceRoot\`; prefer it as the canonical project-owned workspace path.
|
|
347
|
+
- Extracted Topogram files are project-owned after adoption; source hashes record trusted source evidence at the time of extraction.
|
|
348
348
|
|
|
349
349
|
## Verification Gates
|
|
350
350
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {any} moduleValue
|
|
8
|
+
* @param {string|null|undefined} exportName
|
|
9
|
+
* @returns {any}
|
|
10
|
+
*/
|
|
11
|
+
export function selectPackageExport(moduleValue, exportName) {
|
|
12
|
+
if (exportName) {
|
|
13
|
+
return moduleValue?.[exportName] || moduleValue?.default?.[exportName] || null;
|
|
14
|
+
}
|
|
15
|
+
return moduleValue?.default || moduleValue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{
|
|
20
|
+
* packageRoot: string,
|
|
21
|
+
* exportName?: string|null,
|
|
22
|
+
* packageLabel: string
|
|
23
|
+
* }} options
|
|
24
|
+
* @returns {{ adapter: any|null, error: string|null }}
|
|
25
|
+
*/
|
|
26
|
+
export function loadLocalPackageAdapter(options) {
|
|
27
|
+
try {
|
|
28
|
+
const packageJsonPath = path.join(options.packageRoot, "package.json");
|
|
29
|
+
const requireFromPackage = createRequire(packageJsonPath);
|
|
30
|
+
return {
|
|
31
|
+
adapter: selectPackageExport(requireFromPackage(options.packageRoot), options.exportName),
|
|
32
|
+
error: null
|
|
33
|
+
};
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return {
|
|
36
|
+
adapter: null,
|
|
37
|
+
error: `${options.packageLabel} export could not be loaded from '${options.packageRoot}': ${error instanceof Error ? error.message : String(error)}`
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {{
|
|
44
|
+
* packageName: string,
|
|
45
|
+
* rootDir: string,
|
|
46
|
+
* exportName?: string|null,
|
|
47
|
+
* packageLabel: string
|
|
48
|
+
* }} options
|
|
49
|
+
* @returns {{ adapter: any|null, error: string|null }}
|
|
50
|
+
*/
|
|
51
|
+
export function loadInstalledPackageAdapter(options) {
|
|
52
|
+
try {
|
|
53
|
+
const requireFromRoot = createRequire(path.join(options.rootDir, "package.json"));
|
|
54
|
+
return {
|
|
55
|
+
adapter: selectPackageExport(requireFromRoot(options.packageName), options.exportName),
|
|
56
|
+
error: null
|
|
57
|
+
};
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return {
|
|
60
|
+
adapter: null,
|
|
61
|
+
error: `${options.packageLabel} '${options.packageName}' export could not be loaded from '${options.rootDir}': ${error instanceof Error ? error.message : String(error)}`
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {any} files
|
|
7
|
+
* @param {{ filePathMessage: string, contentMessage: (filePath: string) => string }} messages
|
|
8
|
+
* @returns {{ ok: boolean, message: string }}
|
|
9
|
+
*/
|
|
10
|
+
export function validateRelativeStringFileMap(files, messages) {
|
|
11
|
+
if (!files || typeof files !== "object" || Array.isArray(files)) {
|
|
12
|
+
return { ok: false, message: "files must be an object" };
|
|
13
|
+
}
|
|
14
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
15
|
+
const normalizedPath = typeof filePath === "string" ? path.normalize(filePath) : "";
|
|
16
|
+
if (
|
|
17
|
+
typeof filePath !== "string" ||
|
|
18
|
+
filePath.length === 0 ||
|
|
19
|
+
path.isAbsolute(filePath) ||
|
|
20
|
+
normalizedPath === ".." ||
|
|
21
|
+
normalizedPath.startsWith(`..${path.sep}`)
|
|
22
|
+
) {
|
|
23
|
+
return { ok: false, message: messages.filePathMessage };
|
|
24
|
+
}
|
|
25
|
+
if (typeof content !== "string") {
|
|
26
|
+
return { ok: false, message: messages.contentMessage(filePath) };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { ok: true, message: "files are valid" };
|
|
30
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
loadInstalledPackageAdapter,
|
|
5
|
+
loadLocalPackageAdapter,
|
|
6
|
+
selectPackageExport
|
|
7
|
+
} from "./adapter.js";
|
|
8
|
+
export {
|
|
9
|
+
loadPackageManifest,
|
|
10
|
+
resolvePackageManifestPath
|
|
11
|
+
} from "./manifest.js";
|
|
12
|
+
export {
|
|
13
|
+
isPathSpec,
|
|
14
|
+
packageInstallCommand,
|
|
15
|
+
packageInstallHint,
|
|
16
|
+
packageNameFromSpec,
|
|
17
|
+
packageResolutionBase
|
|
18
|
+
} from "./spec.js";
|
|
19
|
+
export {
|
|
20
|
+
optionalStringArray,
|
|
21
|
+
optionalStringRecord,
|
|
22
|
+
packageAllowedByPolicy,
|
|
23
|
+
packageScopeFromName
|
|
24
|
+
} from "./policy.js";
|
|
25
|
+
export {
|
|
26
|
+
validateRelativeStringFileMap
|
|
27
|
+
} from "./file-map.js";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
|
|
7
|
+
import { packageInstallHint, packageResolutionBase } from "./spec.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} PackageManifestResolution
|
|
11
|
+
* @property {string|null} manifestPath
|
|
12
|
+
* @property {string|null} packageRoot
|
|
13
|
+
* @property {string|null} error
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {{ ok: boolean, errors: string[] }} ManifestValidation
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} packageName
|
|
22
|
+
* @param {string} manifestFile
|
|
23
|
+
* @param {string|null|undefined} rootDir
|
|
24
|
+
* @param {string} packageLabel
|
|
25
|
+
* @returns {PackageManifestResolution}
|
|
26
|
+
*/
|
|
27
|
+
export function resolvePackageManifestPath(packageName, manifestFile, rootDir = process.cwd(), packageLabel = "Package") {
|
|
28
|
+
const requireFromRoot = createRequire(packageResolutionBase(rootDir));
|
|
29
|
+
try {
|
|
30
|
+
const manifestPath = requireFromRoot.resolve(`${packageName}/${manifestFile}`);
|
|
31
|
+
return {
|
|
32
|
+
manifestPath,
|
|
33
|
+
packageRoot: path.dirname(manifestPath),
|
|
34
|
+
error: null
|
|
35
|
+
};
|
|
36
|
+
} catch (manifestError) {
|
|
37
|
+
try {
|
|
38
|
+
const packageJsonPath = requireFromRoot.resolve(`${packageName}/package.json`);
|
|
39
|
+
const packageRoot = path.dirname(packageJsonPath);
|
|
40
|
+
const manifestPath = path.join(packageRoot, manifestFile);
|
|
41
|
+
if (!fs.existsSync(manifestPath)) {
|
|
42
|
+
return {
|
|
43
|
+
manifestPath: null,
|
|
44
|
+
packageRoot,
|
|
45
|
+
error: `${packageLabel} '${packageName}' is missing ${manifestFile}`
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
manifestPath,
|
|
50
|
+
packageRoot,
|
|
51
|
+
error: null
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
const detail = manifestError instanceof Error ? manifestError.message : String(manifestError);
|
|
55
|
+
const installHint = packageInstallHint(packageName);
|
|
56
|
+
return {
|
|
57
|
+
manifestPath: null,
|
|
58
|
+
packageRoot: null,
|
|
59
|
+
error: `${packageLabel} '${packageName}' could not be resolved from '${rootDir || process.cwd()}': ${detail}${installHint ? `. ${installHint}` : ""}`
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @template T
|
|
67
|
+
* @param {{
|
|
68
|
+
* packageName: string,
|
|
69
|
+
* rootDir?: string|null,
|
|
70
|
+
* manifestFile: string,
|
|
71
|
+
* packageLabel: string,
|
|
72
|
+
* validateManifest: (manifest: any) => ManifestValidation
|
|
73
|
+
* }} options
|
|
74
|
+
* @returns {{ manifest: T|null, errors: string[], manifestPath: string|null, packageRoot: string|null }}
|
|
75
|
+
*/
|
|
76
|
+
export function loadPackageManifest(options) {
|
|
77
|
+
const resolved = resolvePackageManifestPath(
|
|
78
|
+
options.packageName,
|
|
79
|
+
options.manifestFile,
|
|
80
|
+
options.rootDir || process.cwd(),
|
|
81
|
+
options.packageLabel
|
|
82
|
+
);
|
|
83
|
+
if (!resolved.manifestPath) {
|
|
84
|
+
return {
|
|
85
|
+
manifest: null,
|
|
86
|
+
errors: [resolved.error || `${options.packageLabel} '${options.packageName}' could not be resolved`],
|
|
87
|
+
manifestPath: null,
|
|
88
|
+
packageRoot: resolved.packageRoot
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const manifest = JSON.parse(fs.readFileSync(resolved.manifestPath, "utf8"));
|
|
93
|
+
const validation = options.validateManifest(manifest);
|
|
94
|
+
return {
|
|
95
|
+
manifest: validation.ok ? /** @type {T} */ (manifest) : null,
|
|
96
|
+
errors: validation.errors,
|
|
97
|
+
manifestPath: resolved.manifestPath,
|
|
98
|
+
packageRoot: resolved.packageRoot
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return {
|
|
102
|
+
manifest: null,
|
|
103
|
+
errors: [`${options.packageLabel} '${options.packageName}' manifest could not be read: ${error instanceof Error ? error.message : String(error)}`],
|
|
104
|
+
manifestPath: resolved.manifestPath,
|
|
105
|
+
packageRoot: resolved.packageRoot
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} PackagePolicy
|
|
5
|
+
* @property {string} version
|
|
6
|
+
* @property {string[]} allowedPackageScopes
|
|
7
|
+
* @property {string[]} allowedPackages
|
|
8
|
+
* @property {Record<string, string>} pinnedVersions
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {unknown} value
|
|
13
|
+
* @param {string} fieldName
|
|
14
|
+
* @param {string} policyPath
|
|
15
|
+
* @returns {string[]}
|
|
16
|
+
*/
|
|
17
|
+
export function optionalStringArray(value, fieldName, policyPath) {
|
|
18
|
+
if (value == null) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
if (!Array.isArray(value)) {
|
|
22
|
+
throw new Error(`${policyPath} ${fieldName} must be an array of strings.`);
|
|
23
|
+
}
|
|
24
|
+
return value.map((item) => {
|
|
25
|
+
if (typeof item !== "string" || item.length === 0) {
|
|
26
|
+
throw new Error(`${policyPath} ${fieldName} must contain only non-empty strings.`);
|
|
27
|
+
}
|
|
28
|
+
return item;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {unknown} value
|
|
34
|
+
* @param {string} policyPath
|
|
35
|
+
* @param {string} [pinnedLabel]
|
|
36
|
+
* @returns {Record<string, string>}
|
|
37
|
+
*/
|
|
38
|
+
export function optionalStringRecord(value, policyPath, pinnedLabel = "package ids") {
|
|
39
|
+
if (value == null) {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
43
|
+
throw new Error(`${policyPath} pinnedVersions must be an object of ${pinnedLabel} to versions.`);
|
|
44
|
+
}
|
|
45
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => {
|
|
46
|
+
if (typeof item !== "string" || item.length === 0) {
|
|
47
|
+
throw new Error(`${policyPath} pinnedVersions['${key}'] must be a non-empty string.`);
|
|
48
|
+
}
|
|
49
|
+
return [key, item];
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} packageName
|
|
55
|
+
* @returns {string|null}
|
|
56
|
+
*/
|
|
57
|
+
export function packageScopeFromName(packageName) {
|
|
58
|
+
return packageName.startsWith("@") ? packageName.split("/")[0] || null : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} allowed
|
|
63
|
+
* @param {string|null} scope
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
function packageScopeMatches(allowed, scope) {
|
|
67
|
+
return Boolean(scope && (allowed === scope || allowed === `${scope}/*`));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {PackagePolicy} policy
|
|
72
|
+
* @param {string} packageName
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
export function packageAllowedByPolicy(policy, packageName) {
|
|
76
|
+
if (policy.allowedPackages.includes(packageName)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
const scope = packageScopeFromName(packageName);
|
|
80
|
+
return policy.allowedPackageScopes.some((allowed) => packageScopeMatches(allowed, scope));
|
|
81
|
+
}
|