@topogram/cli 0.3.79 → 0.3.81
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 +621 -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 +14 -1
- package/src/cli/options.js +17 -0
- package/src/extractor/check.js +155 -0
- package/src/extractor/first-party.js +93 -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
- package/src/topogram-config.js +14 -0
|
@@ -57,14 +57,80 @@ import { railsControllerEnricher } from "../enrichers/rails-controllers.js";
|
|
|
57
57
|
import { workflowTargetStateEnricher } from "../enrichers/workflow-target-state.js";
|
|
58
58
|
import { docLinkingEnricher } from "../enrichers/doc-linking.js";
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
function extractorPack(id, tracks, extractors, candidateKinds, stack = {}, capabilities = {}) {
|
|
61
|
+
return {
|
|
62
|
+
manifest: {
|
|
63
|
+
id,
|
|
64
|
+
version: "1",
|
|
65
|
+
tracks,
|
|
66
|
+
source: "bundled",
|
|
67
|
+
extractors: extractors.map((extractor) => extractor.id),
|
|
68
|
+
stack,
|
|
69
|
+
capabilities,
|
|
70
|
+
candidateKinds,
|
|
71
|
+
evidenceTypes: ["runtime_source", "parser_config", "docs", "tests", "fixtures"]
|
|
72
|
+
},
|
|
73
|
+
extractors
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const BUILTIN_EXTRACTOR_PACKS = [
|
|
78
|
+
extractorPack(
|
|
79
|
+
"topogram/db-extractors",
|
|
80
|
+
["db"],
|
|
81
|
+
[prismaExtractor, djangoModelsExtractor, efCoreExtractor, roomExtractor, swiftDataExtractor, dotnetModelsExtractor, flutterEntitiesExtractor, reactNativeEntitiesExtractor, railsSchemaExtractor, liquibaseExtractor, myBatisXmlExtractor, jpaExtractor, drizzleExtractor, sqlExtractor, snapshotExtractor],
|
|
82
|
+
["entity", "enum", "relation", "index", "maintained_db_migration_seam"],
|
|
83
|
+
{ domain: "database" },
|
|
84
|
+
{ schema: true, migrations: true, maintainedSeams: true }
|
|
85
|
+
),
|
|
86
|
+
extractorPack(
|
|
87
|
+
"topogram/api-extractors",
|
|
88
|
+
["api"],
|
|
89
|
+
[openApiExtractor, openApiCodeExtractor, graphQlSdlExtractor, graphQlCodeFirstExtractor, trpcExtractor, aspNetCoreExtractor, retrofitExtractor, swiftWebApiExtractor, flutterDioExtractor, reactNativeRepositoryExtractor, fastifyExtractor, expressExtractor, djangoRoutesExtractor, railsRoutesExtractor, micronautExtractor, jaxRsExtractor, springWebExtractor, nextRouteExtractor, genericRouteFallbackExtractor, nextServerActionExtractor, nextAuthExtractor],
|
|
90
|
+
["capability", "route", "stack"],
|
|
91
|
+
{ domain: "api" },
|
|
92
|
+
{ routes: true, openapi: true, graphql: true }
|
|
93
|
+
),
|
|
94
|
+
extractorPack(
|
|
95
|
+
"topogram/ui-extractors",
|
|
96
|
+
["ui"],
|
|
97
|
+
[nextAppRouterUiExtractor, nextPagesRouterUiExtractor, androidComposeUiExtractor, blazorUiExtractor, razorPagesUiExtractor, swiftUiExtractor, uiKitExtractor, mauiXamlUiExtractor, flutterScreensUiExtractor, reactNativeScreensExtractor, reactRouterUiExtractor, svelteKitUiExtractor, backendOnlyUiExtractor],
|
|
98
|
+
["screen", "route", "action", "flow", "widget", "shape", "stack"],
|
|
99
|
+
{ domain: "ui" },
|
|
100
|
+
{ screens: true, widgets: true, flows: true }
|
|
101
|
+
),
|
|
102
|
+
extractorPack(
|
|
103
|
+
"topogram/cli-extractors",
|
|
104
|
+
["cli"],
|
|
105
|
+
[genericCliExtractor],
|
|
106
|
+
["command", "capability", "cli_surface"],
|
|
107
|
+
{ domain: "cli" },
|
|
108
|
+
{ commands: true, options: true, effects: true }
|
|
109
|
+
),
|
|
110
|
+
extractorPack(
|
|
111
|
+
"topogram/workflow-extractors",
|
|
112
|
+
["workflows"],
|
|
113
|
+
[genericWorkflowExtractor],
|
|
114
|
+
["workflow", "workflow_state", "workflow_transition"],
|
|
115
|
+
{ domain: "workflow" },
|
|
116
|
+
{ workflows: true }
|
|
117
|
+
),
|
|
118
|
+
extractorPack(
|
|
119
|
+
"topogram/verification-extractors",
|
|
120
|
+
["verification"],
|
|
121
|
+
[genericVerificationExtractor],
|
|
122
|
+
["verification", "scenario", "framework", "script"],
|
|
123
|
+
{ domain: "verification" },
|
|
124
|
+
{ verifications: true }
|
|
125
|
+
)
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
export const extractorRegistry = Object.fromEntries(
|
|
129
|
+
["db", "api", "ui", "cli", "workflows", "verification"].map((track) => [
|
|
130
|
+
track,
|
|
131
|
+
BUILTIN_EXTRACTOR_PACKS.flatMap((pack) => pack.extractors).filter((extractor) => extractor.track === track)
|
|
132
|
+
])
|
|
133
|
+
);
|
|
68
134
|
|
|
69
135
|
export const enricherRegistry = {
|
|
70
136
|
db: [railsModelEnricher],
|
|
@@ -82,3 +148,11 @@ export function getExtractorsForTrack(track) {
|
|
|
82
148
|
export function getEnrichersForTrack(track) {
|
|
83
149
|
return enricherRegistry[track] || [];
|
|
84
150
|
}
|
|
151
|
+
|
|
152
|
+
export function getBundledExtractorPack(id) {
|
|
153
|
+
return BUILTIN_EXTRACTOR_PACKS.find((pack) => pack.manifest.id === id) || null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function getBundledExtractorById(id) {
|
|
157
|
+
return BUILTIN_EXTRACTOR_PACKS.flatMap((pack) => pack.extractors).find((extractor) => extractor.id === id) || null;
|
|
158
|
+
}
|
|
@@ -6,6 +6,7 @@ import { parseImportTracks } from "./options.js";
|
|
|
6
6
|
import { appReportMarkdown, reportMarkdown } from "./reports.js";
|
|
7
7
|
import { runTrack } from "./tracks.js";
|
|
8
8
|
import { draftUiProjectionFiles } from "./ui-drafts.js";
|
|
9
|
+
import { packageExtractorsForContext } from "../../../extractor/packages.js";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* @param {string} inputPath
|
|
@@ -64,6 +65,7 @@ export function runImportApp(inputPath, options = {}) {
|
|
|
64
65
|
tracks,
|
|
65
66
|
findings_count: Object.values(findings).reduce((total, entries) => total + entries.length, 0),
|
|
66
67
|
extractor_detections: Object.fromEntries(Object.entries(resultsByTrack).map(([track, result]) => [track, result.extractor_detections])),
|
|
68
|
+
package_extractors: packageExtractorsForContext(context).provenance,
|
|
67
69
|
candidates
|
|
68
70
|
};
|
|
69
71
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { getEnrichersForTrack, getExtractorsForTrack } from "../registry.js";
|
|
4
4
|
import { normalizeCandidatesForTrack } from "./candidates.js";
|
|
5
|
+
import { packageExtractorsForContext } from "../../../extractor/packages.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* @param {any} context
|
|
@@ -10,7 +11,12 @@ import { normalizeCandidatesForTrack } from "./candidates.js";
|
|
|
10
11
|
*/
|
|
11
12
|
function sortExtractors(context, extractors) {
|
|
12
13
|
return extractors
|
|
13
|
-
.map((extractor) =>
|
|
14
|
+
.map((extractor) => {
|
|
15
|
+
const extractorContext = extractor.source === "package" && typeof extractor.packageContext === "function"
|
|
16
|
+
? extractor.packageContext(context)
|
|
17
|
+
: context;
|
|
18
|
+
return { extractor, detection: extractor.detect(extractorContext) || { score: 0, reasons: [] } };
|
|
19
|
+
})
|
|
14
20
|
.filter((entry) => entry.detection.score > 0)
|
|
15
21
|
.sort((a, b) => b.detection.score - a.detection.score || a.extractor.id.localeCompare(b.extractor.id));
|
|
16
22
|
}
|
|
@@ -20,7 +26,7 @@ function sortExtractors(context, extractors) {
|
|
|
20
26
|
* @param {Array<{ extractor: any, detection: any }>} detections
|
|
21
27
|
* @returns {Array<{ extractor: any, detection: any }>}
|
|
22
28
|
*/
|
|
23
|
-
function
|
|
29
|
+
function selectBundledDetectionsForTrack(track, detections) {
|
|
24
30
|
if (track === "db") {
|
|
25
31
|
const prisma = detections.find((entry) => entry.extractor.id === "db.prisma");
|
|
26
32
|
if (prisma) return [prisma];
|
|
@@ -90,6 +96,20 @@ function selectDetectionsForTrack(track, detections) {
|
|
|
90
96
|
return detections;
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} track
|
|
101
|
+
* @param {Array<{ extractor: any, detection: any }>} detections
|
|
102
|
+
* @returns {Array<{ extractor: any, detection: any }>}
|
|
103
|
+
*/
|
|
104
|
+
function selectDetectionsForTrack(track, detections) {
|
|
105
|
+
const packageDetections = detections.filter((entry) => entry.extractor.source === "package");
|
|
106
|
+
const bundledDetections = selectBundledDetectionsForTrack(
|
|
107
|
+
track,
|
|
108
|
+
detections.filter((entry) => entry.extractor.source !== "package")
|
|
109
|
+
);
|
|
110
|
+
return [...bundledDetections, ...packageDetections];
|
|
111
|
+
}
|
|
112
|
+
|
|
93
113
|
/**
|
|
94
114
|
* @param {string} track
|
|
95
115
|
* @returns {any}
|
|
@@ -113,6 +133,35 @@ function initialCandidatesForTrack(track) {
|
|
|
113
133
|
return { workflows: [], workflow_states: [], workflow_transitions: [] };
|
|
114
134
|
}
|
|
115
135
|
|
|
136
|
+
/**
|
|
137
|
+
* @param {any} extractor
|
|
138
|
+
* @param {any} result
|
|
139
|
+
* @returns {void}
|
|
140
|
+
*/
|
|
141
|
+
function assertExtractorResultShape(extractor, result) {
|
|
142
|
+
const label = extractor?.id || "unknown";
|
|
143
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
144
|
+
throw new Error(`Extractor '${label}' extract(context) must return an object.`);
|
|
145
|
+
}
|
|
146
|
+
if (result.findings != null && !Array.isArray(result.findings)) {
|
|
147
|
+
throw new Error(`Extractor '${label}' extract(context) findings must be an array when present.`);
|
|
148
|
+
}
|
|
149
|
+
if (result.diagnostics != null && !Array.isArray(result.diagnostics)) {
|
|
150
|
+
throw new Error(`Extractor '${label}' extract(context) diagnostics must be an array when present.`);
|
|
151
|
+
}
|
|
152
|
+
if (result.candidates == null) {
|
|
153
|
+
result.candidates = {};
|
|
154
|
+
}
|
|
155
|
+
if (!result.candidates || typeof result.candidates !== "object" || Array.isArray(result.candidates)) {
|
|
156
|
+
throw new Error(`Extractor '${label}' extract(context) candidates must be an object.`);
|
|
157
|
+
}
|
|
158
|
+
for (const [key, value] of Object.entries(result.candidates)) {
|
|
159
|
+
if (!Array.isArray(value)) {
|
|
160
|
+
throw new Error(`Extractor '${label}' extract(context) candidates.${key} must be an array.`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
116
165
|
/**
|
|
117
166
|
* @param {any} context
|
|
118
167
|
* @param {string} track
|
|
@@ -121,9 +170,22 @@ function initialCandidatesForTrack(track) {
|
|
|
121
170
|
export function runTrack(context, track) {
|
|
122
171
|
const findings = [];
|
|
123
172
|
const rawCandidates = initialCandidatesForTrack(track);
|
|
173
|
+
const packageState = packageExtractorsForContext(context);
|
|
174
|
+
const packageErrors = packageState.diagnostics.filter((diagnostic) => diagnostic.severity !== "warning");
|
|
175
|
+
if (packageErrors.length > 0) {
|
|
176
|
+
throw new Error(packageErrors.map((diagnostic) => diagnostic.message || String(diagnostic)).join("\n"));
|
|
177
|
+
}
|
|
178
|
+
const extractors = [
|
|
179
|
+
...getExtractorsForTrack(track),
|
|
180
|
+
...packageState.extractors.filter((extractor) => extractor.track === track)
|
|
181
|
+
];
|
|
124
182
|
|
|
125
|
-
for (const { extractor, detection } of selectDetectionsForTrack(track, sortExtractors(context,
|
|
126
|
-
const
|
|
183
|
+
for (const { extractor, detection } of selectDetectionsForTrack(track, sortExtractors(context, extractors))) {
|
|
184
|
+
const extractorContext = extractor.source === "package" && typeof extractor.packageContext === "function"
|
|
185
|
+
? extractor.packageContext(context)
|
|
186
|
+
: context;
|
|
187
|
+
const result = extractor.extract(extractorContext) || { findings: [], candidates: {} };
|
|
188
|
+
assertExtractorResultShape(extractor, result);
|
|
127
189
|
findings.push({
|
|
128
190
|
extractor: extractor.id,
|
|
129
191
|
detection,
|
package/src/import/provenance.js
CHANGED
|
@@ -53,7 +53,8 @@ export function writeTopogramImportRecord(projectRoot, input) {
|
|
|
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
|
extractedArtifacts: "project-owned",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} spec
|
|
8
|
+
* @param {string} cwd
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export function isPathSpec(spec, cwd) {
|
|
12
|
+
return spec.startsWith(".") || spec.startsWith("/") || fs.existsSync(path.resolve(cwd, spec));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} spec
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function packageNameFromSpec(spec) {
|
|
20
|
+
if (spec.startsWith("@")) {
|
|
21
|
+
const versionIndex = spec.indexOf("@", 1);
|
|
22
|
+
return versionIndex > 0 ? spec.slice(0, versionIndex) : spec;
|
|
23
|
+
}
|
|
24
|
+
const versionIndex = spec.indexOf("@");
|
|
25
|
+
return versionIndex > 0 ? spec.slice(0, versionIndex) : spec;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string|null|undefined} rootDir
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function packageResolutionBase(rootDir) {
|
|
33
|
+
return path.join(rootDir || process.cwd(), "package.json");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string|null|undefined} packageName
|
|
38
|
+
* @returns {string|null}
|
|
39
|
+
*/
|
|
40
|
+
export function packageInstallCommand(packageName) {
|
|
41
|
+
return packageName ? `npm install -D ${packageName}` : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string|null|undefined} packageName
|
|
46
|
+
* @returns {string|null}
|
|
47
|
+
*/
|
|
48
|
+
export function packageInstallHint(packageName) {
|
|
49
|
+
const command = packageInstallCommand(packageName);
|
|
50
|
+
return command ? `Install it from the project root with: ${command}` : null;
|
|
51
|
+
}
|
package/src/topogram-config.js
CHANGED
|
@@ -16,8 +16,17 @@ export const DEFAULT_FIRST_PARTY_GENERATOR_REPOS = [
|
|
|
16
16
|
"topogram-generator-vanilla-web"
|
|
17
17
|
];
|
|
18
18
|
|
|
19
|
+
export const DEFAULT_FIRST_PARTY_EXTRACTOR_REPOS = [
|
|
20
|
+
"topogram-extractor-node-cli",
|
|
21
|
+
"topogram-extractor-react-router",
|
|
22
|
+
"topogram-extractor-prisma-db",
|
|
23
|
+
"topogram-extractor-express-api",
|
|
24
|
+
"topogram-extractor-drizzle-db"
|
|
25
|
+
];
|
|
26
|
+
|
|
19
27
|
export const DEFAULT_RELEASE_CONSUMER_REPOS = [
|
|
20
28
|
...DEFAULT_FIRST_PARTY_GENERATOR_REPOS,
|
|
29
|
+
...DEFAULT_FIRST_PARTY_EXTRACTOR_REPOS,
|
|
21
30
|
"topogram-starters",
|
|
22
31
|
"topogram-template-todo",
|
|
23
32
|
"topogram-demo-todo",
|
|
@@ -34,6 +43,11 @@ export const DEFAULT_RELEASE_CONSUMER_WORKFLOWS = {
|
|
|
34
43
|
"topogram-generator-sveltekit-web": "Generator Verification",
|
|
35
44
|
"topogram-generator-swiftui-native": "Generator Verification",
|
|
36
45
|
"topogram-generator-vanilla-web": "Generator Verification",
|
|
46
|
+
"topogram-extractor-node-cli": "Extractor Verification",
|
|
47
|
+
"topogram-extractor-react-router": "Extractor Verification",
|
|
48
|
+
"topogram-extractor-prisma-db": "Extractor Verification",
|
|
49
|
+
"topogram-extractor-express-api": "Extractor Verification",
|
|
50
|
+
"topogram-extractor-drizzle-db": "Extractor Verification",
|
|
37
51
|
"topogram-starters": "Starter Verification",
|
|
38
52
|
"topogram-template-todo": "Template Verification",
|
|
39
53
|
"topogram-demo-todo": "Demo Verification",
|