@topogram/cli 0.3.79 → 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/package.json +1 -1
- package/src/cli/command-parser.js +2 -0
- package/src/cli/command-parsers/extractor.js +40 -0
- package/src/cli/commands/extractor.js +451 -0
- package/src/cli/commands/import/help.js +3 -1
- package/src/cli/commands/import/workspace.js +8 -2
- package/src/cli/commands/import-runner.js +4 -2
- package/src/cli/dispatcher.js +14 -0
- package/src/cli/help-dispatch.js +11 -0
- package/src/cli/help.js +12 -1
- 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/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/run.js +2 -0
- package/src/import/core/runner/tracks.js +66 -4
- package/src/import/provenance.js +2 -1
- 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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
loadPackageManifest,
|
|
5
|
+
packageInstallCommand,
|
|
6
|
+
packageInstallHint,
|
|
7
|
+
resolvePackageManifestPath
|
|
8
|
+
} from "../package-adapters/index.js";
|
|
9
|
+
import { BUILTIN_EXTRACTOR_PACKS } from "../import/core/registry.js";
|
|
10
|
+
|
|
11
|
+
export const EXTRACTOR_TRACKS = ["db", "api", "ui", "cli", "workflows", "verification"];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} ExtractorManifest
|
|
15
|
+
* @property {string} id
|
|
16
|
+
* @property {string} version
|
|
17
|
+
* @property {string[]} tracks
|
|
18
|
+
* @property {"bundled"|"package"} source
|
|
19
|
+
* @property {string[]} extractors
|
|
20
|
+
* @property {Record<string, string>} stack
|
|
21
|
+
* @property {Record<string, boolean>} capabilities
|
|
22
|
+
* @property {string[]} candidateKinds
|
|
23
|
+
* @property {string[]} evidenceTypes
|
|
24
|
+
* @property {string} [package]
|
|
25
|
+
* @property {string} [export]
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} ExtractorBinding
|
|
30
|
+
* @property {string} id
|
|
31
|
+
* @property {string} track
|
|
32
|
+
* @property {string} packageName
|
|
33
|
+
* @property {string} version
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {Object} ResolvedExtractorManifest
|
|
38
|
+
* @property {ExtractorManifest|null} manifest
|
|
39
|
+
* @property {string[]} errors
|
|
40
|
+
* @property {"bundled"|"package"|null} source
|
|
41
|
+
* @property {string|null} manifestPath
|
|
42
|
+
* @property {string|null} packageRoot
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** @type {ExtractorManifest[]} */
|
|
46
|
+
export const EXTRACTOR_MANIFESTS = BUILTIN_EXTRACTOR_PACKS.map((pack) => pack.manifest);
|
|
47
|
+
|
|
48
|
+
const EXTRACTOR_MANIFEST_BY_ID = new Map(EXTRACTOR_MANIFESTS.map((manifest) => [manifest.id, manifest]));
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {any} value
|
|
52
|
+
* @param {boolean} [nonEmpty]
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
function isStringArray(value, nonEmpty = false) {
|
|
56
|
+
return Array.isArray(value) &&
|
|
57
|
+
(!nonEmpty || value.length > 0) &&
|
|
58
|
+
value.every((entry) => typeof entry === "string" && entry.length > 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {any} value
|
|
63
|
+
* @returns {value is Record<string, string>}
|
|
64
|
+
*/
|
|
65
|
+
function isStringRecord(value) {
|
|
66
|
+
return Boolean(value) &&
|
|
67
|
+
typeof value === "object" &&
|
|
68
|
+
!Array.isArray(value) &&
|
|
69
|
+
Object.values(value).every((entry) => typeof entry === "string");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {any} value
|
|
74
|
+
* @returns {value is Record<string, boolean>}
|
|
75
|
+
*/
|
|
76
|
+
function isBooleanRecord(value) {
|
|
77
|
+
return Boolean(value) &&
|
|
78
|
+
typeof value === "object" &&
|
|
79
|
+
!Array.isArray(value) &&
|
|
80
|
+
Object.values(value).every((entry) => typeof entry === "boolean");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {any} manifest
|
|
85
|
+
* @returns {{ ok: boolean, errors: string[] }}
|
|
86
|
+
*/
|
|
87
|
+
export function validateExtractorManifest(manifest) {
|
|
88
|
+
const errors = [];
|
|
89
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
90
|
+
return { ok: false, errors: ["Extractor manifest must be an object."] };
|
|
91
|
+
}
|
|
92
|
+
if (typeof manifest.id !== "string" || manifest.id.length === 0) {
|
|
93
|
+
errors.push("Extractor manifest id must be a non-empty string.");
|
|
94
|
+
}
|
|
95
|
+
if (typeof manifest.version !== "string" || manifest.version.length === 0) {
|
|
96
|
+
errors.push("Extractor manifest version must be a non-empty string.");
|
|
97
|
+
}
|
|
98
|
+
if (!isStringArray(manifest.tracks, true)) {
|
|
99
|
+
errors.push("Extractor manifest tracks must be a non-empty array of strings.");
|
|
100
|
+
} else {
|
|
101
|
+
for (const track of manifest.tracks) {
|
|
102
|
+
if (!EXTRACTOR_TRACKS.includes(track)) {
|
|
103
|
+
errors.push(`Extractor manifest track '${track}' is not supported.`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (manifest.source !== "bundled" && manifest.source !== "package") {
|
|
108
|
+
errors.push("Extractor manifest source must be 'bundled' or 'package'.");
|
|
109
|
+
}
|
|
110
|
+
if (!isStringArray(manifest.extractors, true)) {
|
|
111
|
+
errors.push("Extractor manifest extractors must be a non-empty array of strings.");
|
|
112
|
+
}
|
|
113
|
+
if (!isStringRecord(manifest.stack)) {
|
|
114
|
+
errors.push("Extractor manifest stack must be an object of string values.");
|
|
115
|
+
}
|
|
116
|
+
if (!isBooleanRecord(manifest.capabilities)) {
|
|
117
|
+
errors.push("Extractor manifest capabilities must be an object of boolean values.");
|
|
118
|
+
}
|
|
119
|
+
if (!isStringArray(manifest.candidateKinds)) {
|
|
120
|
+
errors.push("Extractor manifest candidateKinds must be an array of strings.");
|
|
121
|
+
}
|
|
122
|
+
if (!isStringArray(manifest.evidenceTypes)) {
|
|
123
|
+
errors.push("Extractor manifest evidenceTypes must be an array of strings.");
|
|
124
|
+
}
|
|
125
|
+
if (manifest.package != null && (typeof manifest.package !== "string" || manifest.package.length === 0)) {
|
|
126
|
+
errors.push("Extractor manifest package must be a non-empty string when present.");
|
|
127
|
+
}
|
|
128
|
+
if (manifest.export != null && (typeof manifest.export !== "string" || manifest.export.length === 0)) {
|
|
129
|
+
errors.push("Extractor manifest export must be a non-empty string when present.");
|
|
130
|
+
}
|
|
131
|
+
return { ok: errors.length === 0, errors };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {string} extractorId
|
|
136
|
+
* @returns {ExtractorManifest|null}
|
|
137
|
+
*/
|
|
138
|
+
export function getExtractorManifest(extractorId) {
|
|
139
|
+
return EXTRACTOR_MANIFEST_BY_ID.get(extractorId) || null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {string|null|undefined} packageName
|
|
144
|
+
* @returns {string|null}
|
|
145
|
+
*/
|
|
146
|
+
export function packageExtractorInstallCommand(packageName) {
|
|
147
|
+
return packageInstallCommand(packageName);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {string|null|undefined} packageName
|
|
152
|
+
* @returns {string|null}
|
|
153
|
+
*/
|
|
154
|
+
export function packageExtractorInstallHint(packageName) {
|
|
155
|
+
return packageInstallHint(packageName);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {string} packageName
|
|
160
|
+
* @param {string|null|undefined} rootDir
|
|
161
|
+
* @returns {{ manifestPath: string|null, packageRoot: string|null, error: string|null }}
|
|
162
|
+
*/
|
|
163
|
+
export function resolvePackageExtractorManifestPath(packageName, rootDir = process.cwd()) {
|
|
164
|
+
return resolvePackageManifestPath(packageName, "topogram-extractor.json", rootDir, "Extractor package");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {string} packageName
|
|
169
|
+
* @param {string|null|undefined} rootDir
|
|
170
|
+
* @returns {{ manifest: ExtractorManifest|null, errors: string[], manifestPath: string|null, packageRoot: string|null }}
|
|
171
|
+
*/
|
|
172
|
+
export function loadPackageExtractorManifest(packageName, rootDir = process.cwd()) {
|
|
173
|
+
return loadPackageManifest({
|
|
174
|
+
packageName,
|
|
175
|
+
rootDir,
|
|
176
|
+
manifestFile: "topogram-extractor.json",
|
|
177
|
+
packageLabel: "Extractor package",
|
|
178
|
+
validateManifest: validateExtractorManifest
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {Record<string, any>|null|undefined} manifest
|
|
184
|
+
* @returns {ExtractorBinding[]}
|
|
185
|
+
*/
|
|
186
|
+
export function extractorBindingsForManifest(manifest) {
|
|
187
|
+
if (!manifest || manifest.source !== "package" || typeof manifest.package !== "string") {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
return (manifest.extractors || []).map((/** @type {any} */ extractorId) => ({
|
|
191
|
+
id: String(extractorId),
|
|
192
|
+
track: Array.isArray(manifest.tracks) && manifest.tracks.length === 1 ? String(manifest.tracks[0]) : "multiple",
|
|
193
|
+
packageName: String(manifest.package),
|
|
194
|
+
version: String(manifest.version || "unknown")
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { stableStringify } from "./format.js";
|
|
7
|
+
import {
|
|
8
|
+
optionalStringArray,
|
|
9
|
+
optionalStringRecord,
|
|
10
|
+
packageAllowedByPolicy,
|
|
11
|
+
packageScopeFromName as sharedPackageScopeFromName
|
|
12
|
+
} from "./package-adapters/index.js";
|
|
13
|
+
|
|
14
|
+
export const EXTRACTOR_POLICY_FILE = "topogram.extractor-policy.json";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} ExtractorPolicy
|
|
18
|
+
* @property {string} version
|
|
19
|
+
* @property {string[]} allowedPackageScopes
|
|
20
|
+
* @property {string[]} allowedPackages
|
|
21
|
+
* @property {Record<string, string>} pinnedVersions
|
|
22
|
+
* @property {string[]} enabledPackages
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} PackageExtractorBinding
|
|
27
|
+
* @property {string} packageName
|
|
28
|
+
* @property {string} version
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {Object} ExtractorPolicyInfo
|
|
33
|
+
* @property {string} path
|
|
34
|
+
* @property {ExtractorPolicy|null} policy
|
|
35
|
+
* @property {boolean} exists
|
|
36
|
+
* @property {ExtractorPolicyDiagnostic[]} diagnostics
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {Object} ExtractorPolicyDiagnostic
|
|
41
|
+
* @property {string} code
|
|
42
|
+
* @property {"error"|"warning"} severity
|
|
43
|
+
* @property {string} message
|
|
44
|
+
* @property {string|null} path
|
|
45
|
+
* @property {string|null} suggestedFix
|
|
46
|
+
* @property {string|null} step
|
|
47
|
+
* @property {string|null} [packageName]
|
|
48
|
+
* @property {string|null} [version]
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {Record<string, any>} input
|
|
53
|
+
* @returns {ExtractorPolicyDiagnostic}
|
|
54
|
+
*/
|
|
55
|
+
function extractorPolicyDiagnostic(input) {
|
|
56
|
+
return {
|
|
57
|
+
code: String(input.code || "extractor_policy_failed"),
|
|
58
|
+
severity: input.severity === "warning" ? "warning" : "error",
|
|
59
|
+
message: String(input.message || "Extractor policy check failed."),
|
|
60
|
+
path: typeof input.path === "string" ? input.path : null,
|
|
61
|
+
suggestedFix: typeof input.suggestedFix === "string" ? input.suggestedFix : null,
|
|
62
|
+
step: typeof input.step === "string" ? input.step : null,
|
|
63
|
+
packageName: typeof input.packageName === "string" ? input.packageName : null,
|
|
64
|
+
version: typeof input.version === "string" ? input.version : null
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @returns {ExtractorPolicy}
|
|
70
|
+
*/
|
|
71
|
+
export function defaultExtractorPolicy() {
|
|
72
|
+
return {
|
|
73
|
+
version: "0.1",
|
|
74
|
+
allowedPackageScopes: [],
|
|
75
|
+
allowedPackages: [],
|
|
76
|
+
pinnedVersions: {},
|
|
77
|
+
enabledPackages: []
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {unknown} value
|
|
83
|
+
* @param {string} policyPath
|
|
84
|
+
* @returns {ExtractorPolicy}
|
|
85
|
+
*/
|
|
86
|
+
export function validateExtractorPolicy(value, policyPath) {
|
|
87
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
88
|
+
throw new Error(`${EXTRACTOR_POLICY_FILE} must contain a JSON object.`);
|
|
89
|
+
}
|
|
90
|
+
const raw = /** @type {Record<string, unknown>} */ (value);
|
|
91
|
+
const defaults = defaultExtractorPolicy();
|
|
92
|
+
return {
|
|
93
|
+
version: typeof raw.version === "string" && raw.version ? raw.version : defaults.version,
|
|
94
|
+
allowedPackageScopes: raw.allowedPackageScopes == null
|
|
95
|
+
? defaults.allowedPackageScopes
|
|
96
|
+
: optionalStringArray(raw.allowedPackageScopes, "allowedPackageScopes", policyPath),
|
|
97
|
+
allowedPackages: optionalStringArray(raw.allowedPackages, "allowedPackages", policyPath),
|
|
98
|
+
pinnedVersions: optionalStringRecord(raw.pinnedVersions, policyPath, "package-or-extractor ids"),
|
|
99
|
+
enabledPackages: optionalStringArray(raw.enabledPackages, "enabledPackages", policyPath)
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string} packageName
|
|
105
|
+
* @returns {string|null}
|
|
106
|
+
*/
|
|
107
|
+
export function packageScopeFromName(packageName) {
|
|
108
|
+
return sharedPackageScopeFromName(packageName);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {ExtractorPolicy} policy
|
|
113
|
+
* @param {string} packageName
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
export function extractorPackageAllowed(policy, packageName) {
|
|
117
|
+
return packageName.startsWith("@topogram/extractor-") || packageAllowedByPolicy(policy, packageName);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} projectRoot
|
|
122
|
+
* @param {string|null|undefined} policyPath
|
|
123
|
+
* @returns {string}
|
|
124
|
+
*/
|
|
125
|
+
function resolvePolicyPath(projectRoot, policyPath) {
|
|
126
|
+
if (policyPath) {
|
|
127
|
+
const resolved = path.resolve(projectRoot, policyPath);
|
|
128
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
129
|
+
return path.join(resolved, EXTRACTOR_POLICY_FILE);
|
|
130
|
+
}
|
|
131
|
+
return resolved;
|
|
132
|
+
}
|
|
133
|
+
return path.join(projectRoot, EXTRACTOR_POLICY_FILE);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {string} projectRoot
|
|
138
|
+
* @param {string|null|undefined} [policyPath]
|
|
139
|
+
* @returns {ExtractorPolicyInfo}
|
|
140
|
+
*/
|
|
141
|
+
export function loadExtractorPolicy(projectRoot, policyPath = null) {
|
|
142
|
+
const resolvedPolicyPath = resolvePolicyPath(projectRoot, policyPath);
|
|
143
|
+
if (!fs.existsSync(resolvedPolicyPath)) {
|
|
144
|
+
return {
|
|
145
|
+
path: resolvedPolicyPath,
|
|
146
|
+
policy: null,
|
|
147
|
+
exists: false,
|
|
148
|
+
diagnostics: []
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return {
|
|
153
|
+
path: resolvedPolicyPath,
|
|
154
|
+
policy: validateExtractorPolicy(JSON.parse(fs.readFileSync(resolvedPolicyPath, "utf8")), resolvedPolicyPath),
|
|
155
|
+
exists: true,
|
|
156
|
+
diagnostics: []
|
|
157
|
+
};
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return {
|
|
160
|
+
path: resolvedPolicyPath,
|
|
161
|
+
policy: null,
|
|
162
|
+
exists: true,
|
|
163
|
+
diagnostics: [extractorPolicyDiagnostic({
|
|
164
|
+
code: "extractor_policy_invalid",
|
|
165
|
+
message: error instanceof Error ? error.message : String(error),
|
|
166
|
+
path: resolvedPolicyPath,
|
|
167
|
+
suggestedFix: `Fix ${EXTRACTOR_POLICY_FILE} or regenerate it with \`topogram extractor policy init\`.`,
|
|
168
|
+
step: "extractor-policy"
|
|
169
|
+
})]
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {ExtractorPolicyInfo} policyInfo
|
|
176
|
+
* @returns {ExtractorPolicy}
|
|
177
|
+
*/
|
|
178
|
+
export function effectiveExtractorPolicy(policyInfo) {
|
|
179
|
+
return policyInfo.policy || defaultExtractorPolicy();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {string} projectRoot
|
|
184
|
+
* @param {ExtractorPolicy} policy
|
|
185
|
+
* @param {string|null|undefined} [policyPath]
|
|
186
|
+
* @returns {ExtractorPolicy}
|
|
187
|
+
*/
|
|
188
|
+
export function writeExtractorPolicy(projectRoot, policy, policyPath = null) {
|
|
189
|
+
const resolvedPolicyPath = resolvePolicyPath(projectRoot, policyPath);
|
|
190
|
+
fs.writeFileSync(resolvedPolicyPath, `${stableStringify(policy)}\n`, "utf8");
|
|
191
|
+
return policy;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param {ExtractorPolicyInfo} policyInfo
|
|
196
|
+
* @param {PackageExtractorBinding[]} bindings
|
|
197
|
+
* @param {string} [step]
|
|
198
|
+
* @returns {ExtractorPolicyDiagnostic[]}
|
|
199
|
+
*/
|
|
200
|
+
export function extractorPolicyDiagnosticsForPackages(policyInfo, bindings, step = "extractor-policy") {
|
|
201
|
+
if (policyInfo.diagnostics.length > 0) {
|
|
202
|
+
return policyInfo.diagnostics;
|
|
203
|
+
}
|
|
204
|
+
const policy = effectiveExtractorPolicy(policyInfo);
|
|
205
|
+
/** @type {ExtractorPolicyDiagnostic[]} */
|
|
206
|
+
const diagnostics = [];
|
|
207
|
+
for (const binding of bindings) {
|
|
208
|
+
if (!extractorPackageAllowed(policy, binding.packageName)) {
|
|
209
|
+
const scope = packageScopeFromName(binding.packageName);
|
|
210
|
+
diagnostics.push(extractorPolicyDiagnostic({
|
|
211
|
+
code: "extractor_package_denied",
|
|
212
|
+
message: `Extractor package '${binding.packageName}' is not allowed by ${EXTRACTOR_POLICY_FILE}.`,
|
|
213
|
+
path: policyInfo.path,
|
|
214
|
+
suggestedFix: `Review '${binding.packageName}', then run \`topogram extractor policy pin ${binding.packageName}@${binding.version}\` or add '${scope || binding.packageName}' to ${EXTRACTOR_POLICY_FILE}.`,
|
|
215
|
+
step,
|
|
216
|
+
packageName: binding.packageName,
|
|
217
|
+
version: binding.version
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
const pinnedVersion = policy.pinnedVersions[binding.packageName] || null;
|
|
221
|
+
if (pinnedVersion && pinnedVersion !== binding.version) {
|
|
222
|
+
diagnostics.push(extractorPolicyDiagnostic({
|
|
223
|
+
code: "extractor_version_mismatch",
|
|
224
|
+
message: `Extractor package '${binding.packageName}' uses version '${binding.version}', but ${EXTRACTOR_POLICY_FILE} pins it to '${pinnedVersion}'.`,
|
|
225
|
+
path: policyInfo.path,
|
|
226
|
+
suggestedFix: `Use extractor version '${pinnedVersion}', or run \`topogram extractor policy pin ${binding.packageName}@${binding.version}\` after review.`,
|
|
227
|
+
step,
|
|
228
|
+
packageName: binding.packageName,
|
|
229
|
+
version: binding.version
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return diagnostics;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @param {string} spec
|
|
238
|
+
* @returns {{ packageName: string, version: string }}
|
|
239
|
+
*/
|
|
240
|
+
export function parseExtractorPolicyPin(spec) {
|
|
241
|
+
const separator = spec.lastIndexOf("@");
|
|
242
|
+
if (separator <= 0 || separator === spec.length - 1) {
|
|
243
|
+
throw new Error("Extractor policy pin requires a package name and extractor version, for example @topogram/extractor-node-cli@1.");
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
packageName: spec.slice(0, separator),
|
|
247
|
+
version: spec.slice(separator + 1)
|
|
248
|
+
};
|
|
249
|
+
}
|
package/src/generator/check.js
CHANGED
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { createRequire } from "node:module";
|
|
6
5
|
|
|
7
6
|
import {
|
|
8
7
|
loadPackageGeneratorManifest,
|
|
9
8
|
validateGeneratorManifest
|
|
10
9
|
} from "./registry.js";
|
|
10
|
+
import {
|
|
11
|
+
isPathSpec,
|
|
12
|
+
loadInstalledPackageAdapter,
|
|
13
|
+
loadLocalPackageAdapter,
|
|
14
|
+
packageNameFromSpec,
|
|
15
|
+
validateRelativeStringFileMap
|
|
16
|
+
} from "../package-adapters/index.js";
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* @typedef {import("./registry.js").GeneratorManifest} GeneratorManifest
|
|
@@ -28,82 +34,6 @@ import {
|
|
|
28
34
|
* @property {boolean} executesPackageCode
|
|
29
35
|
*/
|
|
30
36
|
|
|
31
|
-
/**
|
|
32
|
-
* @param {string} spec
|
|
33
|
-
* @param {string} cwd
|
|
34
|
-
* @returns {boolean}
|
|
35
|
-
*/
|
|
36
|
-
function isPathSpec(spec, cwd) {
|
|
37
|
-
return spec.startsWith(".") || spec.startsWith("/") || fs.existsSync(path.resolve(cwd, spec));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* @param {string} spec
|
|
42
|
-
* @returns {string}
|
|
43
|
-
*/
|
|
44
|
-
function packageNameFromSpec(spec) {
|
|
45
|
-
if (spec.startsWith("@")) {
|
|
46
|
-
const versionIndex = spec.indexOf("@", 1);
|
|
47
|
-
return versionIndex > 0 ? spec.slice(0, versionIndex) : spec;
|
|
48
|
-
}
|
|
49
|
-
const versionIndex = spec.indexOf("@");
|
|
50
|
-
return versionIndex > 0 ? spec.slice(0, versionIndex) : spec;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* @param {any} moduleValue
|
|
55
|
-
* @param {string|null|undefined} exportName
|
|
56
|
-
* @returns {any}
|
|
57
|
-
*/
|
|
58
|
-
function selectPackageExport(moduleValue, exportName) {
|
|
59
|
-
if (exportName) {
|
|
60
|
-
return moduleValue?.[exportName] || moduleValue?.default?.[exportName] || null;
|
|
61
|
-
}
|
|
62
|
-
return moduleValue?.default || moduleValue;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* @param {string} root
|
|
67
|
-
* @param {GeneratorManifest} manifest
|
|
68
|
-
* @returns {{ adapter: any|null, error: string|null }}
|
|
69
|
-
*/
|
|
70
|
-
function loadLocalAdapter(root, manifest) {
|
|
71
|
-
try {
|
|
72
|
-
const packageJsonPath = path.join(root, "package.json");
|
|
73
|
-
const requireFromPackage = createRequire(packageJsonPath);
|
|
74
|
-
return {
|
|
75
|
-
adapter: selectPackageExport(requireFromPackage(root), manifest.export),
|
|
76
|
-
error: null
|
|
77
|
-
};
|
|
78
|
-
} catch (error) {
|
|
79
|
-
return {
|
|
80
|
-
adapter: null,
|
|
81
|
-
error: `Generator package export could not be loaded from '${root}': ${error instanceof Error ? error.message : String(error)}`
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* @param {string} packageName
|
|
88
|
-
* @param {string} rootDir
|
|
89
|
-
* @param {GeneratorManifest} manifest
|
|
90
|
-
* @returns {{ adapter: any|null, error: string|null }}
|
|
91
|
-
*/
|
|
92
|
-
function loadInstalledAdapter(packageName, rootDir, manifest) {
|
|
93
|
-
try {
|
|
94
|
-
const requireFromRoot = createRequire(path.join(rootDir, "package.json"));
|
|
95
|
-
return {
|
|
96
|
-
adapter: selectPackageExport(requireFromRoot(packageName), manifest.export),
|
|
97
|
-
error: null
|
|
98
|
-
};
|
|
99
|
-
} catch (error) {
|
|
100
|
-
return {
|
|
101
|
-
adapter: null,
|
|
102
|
-
error: `Generator package '${packageName}' export could not be loaded from '${rootDir}': ${error instanceof Error ? error.message : String(error)}`
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
37
|
/**
|
|
108
38
|
* @param {GeneratorManifest} manifest
|
|
109
39
|
* @returns {Record<string, any>}
|
|
@@ -208,14 +138,12 @@ function validateSmokeResult(result) {
|
|
|
208
138
|
if (!result.files || typeof result.files !== "object" || Array.isArray(result.files)) {
|
|
209
139
|
return { ok: false, message: "generate(context) result must include a files object", smoke: null };
|
|
210
140
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
return { ok: false, message: `generated file '${filePath}' content must be a string`, smoke: null };
|
|
218
|
-
}
|
|
141
|
+
const fileMapValidation = validateRelativeStringFileMap(result.files, {
|
|
142
|
+
filePathMessage: "generated file paths must be non-empty relative paths",
|
|
143
|
+
contentMessage: (filePath) => `generated file '${filePath}' content must be a string`
|
|
144
|
+
});
|
|
145
|
+
if (!fileMapValidation.ok) {
|
|
146
|
+
return { ok: false, message: fileMapValidation.message, smoke: null };
|
|
219
147
|
}
|
|
220
148
|
return {
|
|
221
149
|
ok: true,
|
|
@@ -304,7 +232,11 @@ export function checkGeneratorPack(sourceSpec, options = {}) {
|
|
|
304
232
|
}
|
|
305
233
|
|
|
306
234
|
if (payload.source === "path") {
|
|
307
|
-
const loaded =
|
|
235
|
+
const loaded = loadLocalPackageAdapter({
|
|
236
|
+
packageRoot: payload.packageRoot || cwd,
|
|
237
|
+
exportName: payload.manifest.export,
|
|
238
|
+
packageLabel: "Generator package"
|
|
239
|
+
});
|
|
308
240
|
adapter = loaded.adapter;
|
|
309
241
|
if (loaded.error) {
|
|
310
242
|
payload.errors.push(loaded.error);
|
|
@@ -312,7 +244,12 @@ export function checkGeneratorPack(sourceSpec, options = {}) {
|
|
|
312
244
|
return payload;
|
|
313
245
|
}
|
|
314
246
|
} else if (payload.packageName) {
|
|
315
|
-
const loaded =
|
|
247
|
+
const loaded = loadInstalledPackageAdapter({
|
|
248
|
+
packageName: payload.packageName,
|
|
249
|
+
rootDir: cwd,
|
|
250
|
+
exportName: payload.manifest.export,
|
|
251
|
+
packageLabel: "Generator package"
|
|
252
|
+
});
|
|
316
253
|
adapter = loaded.adapter;
|
|
317
254
|
if (loaded.error) {
|
|
318
255
|
payload.errors.push(loaded.error);
|