@topogram/cli 0.3.80 → 0.3.82
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/agent-ops/query-builders/common.js +2 -0
- package/src/cli/command-parsers/sdlc.js +3 -0
- package/src/cli/commands/extractor.js +196 -26
- package/src/cli/commands/sdlc.js +42 -0
- package/src/cli/help.js +4 -0
- package/src/extractor/first-party.js +93 -0
- package/src/generator/context/diff.js +97 -0
- package/src/generator/registry/index.js +23 -1
- package/src/sdlc/audit.js +205 -0
- package/src/topogram-config.js +14 -0
- package/src/ui/taxonomy.js +45 -0
- package/src/validator/kinds.d.ts +5 -0
- package/src/validator/kinds.js +6 -1
- package/src/validator/per-kind/widget.js +7 -45
package/package.json
CHANGED
|
@@ -15,6 +15,8 @@ export function summarizeDiffArtifact(diffArtifact) {
|
|
|
15
15
|
review_boundary_change_count: (diffArtifact.review_boundary_changes || []).length,
|
|
16
16
|
maintained_file_count: (diffArtifact.affected_maintained_surfaces?.maintained_files_in_scope || []).length,
|
|
17
17
|
affected_verification_count: (diffArtifact.affected_verifications || []).length,
|
|
18
|
+
widget_migration_count: (diffArtifact.widget_contract_migration_plan?.widgets || []).length,
|
|
19
|
+
widget_migration_projection_count: (diffArtifact.widget_contract_migration_plan?.affected_projection_ids || []).length,
|
|
18
20
|
affected_output_count: maintainedOutputs.length,
|
|
19
21
|
affected_seam_count: maintainedSeams.length,
|
|
20
22
|
highest_maintained_severity: highestMaintainedSeverity
|
|
@@ -19,6 +19,9 @@ export function parseSdlcCommandArgs(args) {
|
|
|
19
19
|
if (args[0] === "sdlc" && args[1] === "prep" && args[2] === "commit") {
|
|
20
20
|
return { sdlcCommand: "prep:commit", inputPath: commandPath(args, 3, ".") };
|
|
21
21
|
}
|
|
22
|
+
if (args[0] === "sdlc" && args[1] === "audit") {
|
|
23
|
+
return { sdlcCommand: "audit", inputPath: commandPath(args, 2, ".") };
|
|
24
|
+
}
|
|
22
25
|
if (args[0] === "sdlc" && args[1] === "link") {
|
|
23
26
|
return {
|
|
24
27
|
sdlcCommand: "link",
|
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import { stableStringify } from "../../format.js";
|
|
7
7
|
import { checkExtractorPack } from "../../extractor/check.js";
|
|
8
|
+
import { FIRST_PARTY_EXTRACTOR_PACKAGES, firstPartyExtractorInfo } from "../../extractor/first-party.js";
|
|
8
9
|
import {
|
|
9
10
|
EXTRACTOR_MANIFESTS,
|
|
10
11
|
getExtractorManifest,
|
|
@@ -23,6 +24,8 @@ import {
|
|
|
23
24
|
writeExtractorPolicy
|
|
24
25
|
} from "../../extractor-policy.js";
|
|
25
26
|
|
|
27
|
+
const EXTRACTOR_TRACK_ORDER = ["db", "api", "ui", "cli", "workflows", "verification", "unknown"];
|
|
28
|
+
|
|
26
29
|
export function printExtractorHelp() {
|
|
27
30
|
console.log("Usage: topogram extractor list [--json]");
|
|
28
31
|
console.log(" or: topogram extractor show <id-or-package> [--json]");
|
|
@@ -43,9 +46,11 @@ export function printExtractorHelp() {
|
|
|
43
46
|
console.log("Examples:");
|
|
44
47
|
console.log(" topogram extractor list");
|
|
45
48
|
console.log(" topogram extractor show topogram/api-extractors");
|
|
49
|
+
console.log(" topogram extractor show @topogram/extractor-prisma-db");
|
|
46
50
|
console.log(" topogram extractor check ./extractor-package");
|
|
47
51
|
console.log(" topogram extractor policy init");
|
|
48
52
|
console.log(" topogram extractor policy pin @topogram/extractor-node-cli@1");
|
|
53
|
+
console.log(" topogram extract ./express-api --out ./imported-topogram --from api --extractor @topogram/extractor-express-api");
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/**
|
|
@@ -78,27 +83,105 @@ function declaredExtractorPackages(cwd) {
|
|
|
78
83
|
return [...packages].sort();
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
/**
|
|
87
|
+
* @param {string|null|undefined} packageName
|
|
88
|
+
* @param {string|null|undefined} version
|
|
89
|
+
* @returns {string|null}
|
|
90
|
+
*/
|
|
91
|
+
function extractorPolicyPinCommand(packageName, version) {
|
|
92
|
+
return packageName ? `topogram extractor policy pin ${packageName}@${version || "1"}` : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {string|null|undefined} packageName
|
|
97
|
+
* @param {string[]} tracks
|
|
98
|
+
* @param {string|null|undefined} exampleSource
|
|
99
|
+
* @returns {string|null}
|
|
100
|
+
*/
|
|
101
|
+
function extractorRunCommand(packageName, tracks, exampleSource) {
|
|
102
|
+
if (!packageName) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const trackList = tracks.length > 0 ? tracks.join(",") : "db,api,ui,cli";
|
|
106
|
+
return `topogram extract ${exampleSource || "./existing-app"} --out ./imported-topogram --from ${trackList} --extractor ${packageName}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {Record<string, any>} extractor
|
|
111
|
+
* @returns {{ id: string|null, package: string|null, label: string|null, source: string, installed: boolean, knownFirstParty: boolean, useWhen: string|null, extractCommand: string|null }}
|
|
112
|
+
*/
|
|
113
|
+
function groupExtractorEntry(extractor) {
|
|
114
|
+
return {
|
|
115
|
+
id: extractor.id || null,
|
|
116
|
+
package: extractor.package || null,
|
|
117
|
+
label: extractor.label || null,
|
|
118
|
+
source: extractor.source,
|
|
119
|
+
installed: Boolean(extractor.installed),
|
|
120
|
+
knownFirstParty: Boolean(extractor.knownFirstParty),
|
|
121
|
+
useWhen: extractor.useWhen || null,
|
|
122
|
+
extractCommand: extractor.extractCommand || null
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {Record<string, any>[]} extractors
|
|
128
|
+
* @returns {Record<string, ReturnType<typeof groupExtractorEntry>[]>}
|
|
129
|
+
*/
|
|
130
|
+
function groupExtractorsByTrack(extractors) {
|
|
131
|
+
/** @type {Record<string, ReturnType<typeof groupExtractorEntry>[]>} */
|
|
132
|
+
const groups = {};
|
|
133
|
+
for (const track of EXTRACTOR_TRACK_ORDER) {
|
|
134
|
+
groups[track] = [];
|
|
135
|
+
}
|
|
136
|
+
for (const extractor of extractors) {
|
|
137
|
+
const tracks = Array.isArray(extractor.tracks) && extractor.tracks.length > 0 ? extractor.tracks : ["unknown"];
|
|
138
|
+
for (const track of tracks) {
|
|
139
|
+
if (!groups[track]) {
|
|
140
|
+
groups[track] = [];
|
|
141
|
+
}
|
|
142
|
+
groups[track].push(groupExtractorEntry(extractor));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const entries of Object.values(groups)) {
|
|
146
|
+
entries.sort((left, right) => String(left.label || left.id || left.package || "").localeCompare(String(right.label || right.id || right.package || "")));
|
|
147
|
+
}
|
|
148
|
+
return Object.fromEntries(Object.entries(groups).filter(([, entries]) => entries.length > 0));
|
|
149
|
+
}
|
|
150
|
+
|
|
81
151
|
/**
|
|
82
152
|
* @param {any} manifest
|
|
83
153
|
* @param {{ installed?: boolean, manifestPath?: string|null, packageRoot?: string|null, errors?: string[] }} [metadata]
|
|
84
154
|
* @returns {Record<string, any>}
|
|
85
155
|
*/
|
|
86
156
|
function extractorManifestSummary(manifest, metadata = {}) {
|
|
87
|
-
const
|
|
157
|
+
const firstParty = firstPartyExtractorInfo(manifest.package || manifest.id);
|
|
158
|
+
const packageName = manifest.package || null;
|
|
159
|
+
const tracks = manifest.tracks || [];
|
|
160
|
+
const version = manifest.version || firstParty?.version || "1";
|
|
161
|
+
const installCommand = packageName ? packageExtractorInstallCommand(packageName) : null;
|
|
162
|
+
const policyPinCommand = extractorPolicyPinCommand(packageName, version);
|
|
163
|
+
const extractCommand = extractorRunCommand(packageName, tracks, firstParty?.exampleSource);
|
|
88
164
|
return {
|
|
89
165
|
id: manifest.id,
|
|
90
|
-
version
|
|
91
|
-
|
|
166
|
+
version,
|
|
167
|
+
label: firstParty?.label || null,
|
|
168
|
+
tracks,
|
|
92
169
|
extractors: manifest.extractors || [],
|
|
93
170
|
stack: manifest.stack || {},
|
|
94
171
|
capabilities: manifest.capabilities || {},
|
|
95
172
|
candidateKinds: manifest.candidateKinds || [],
|
|
96
173
|
evidenceTypes: manifest.evidenceTypes || [],
|
|
174
|
+
useWhen: firstParty?.useWhen || null,
|
|
175
|
+
extracts: firstParty?.extracts || [],
|
|
176
|
+
knownFirstParty: Boolean(firstParty),
|
|
97
177
|
source: manifest.source,
|
|
98
178
|
loadsAdapter: false,
|
|
99
179
|
executesPackageCode: false,
|
|
100
|
-
...(
|
|
180
|
+
...(packageName ? { package: packageName } : {}),
|
|
101
181
|
...(installCommand ? { installCommand } : {}),
|
|
182
|
+
...(policyPinCommand ? { policyPinCommand } : {}),
|
|
183
|
+
...(extractCommand ? { extractCommand } : {}),
|
|
184
|
+
...(packageName ? { showCommand: `topogram extractor show ${packageName}` } : {}),
|
|
102
185
|
installed: metadata.installed !== false,
|
|
103
186
|
manifestPath: metadata.manifestPath || null,
|
|
104
187
|
packageRoot: metadata.packageRoot || null,
|
|
@@ -106,25 +189,64 @@ function extractorManifestSummary(manifest, metadata = {}) {
|
|
|
106
189
|
};
|
|
107
190
|
}
|
|
108
191
|
|
|
192
|
+
/**
|
|
193
|
+
* @param {typeof FIRST_PARTY_EXTRACTOR_PACKAGES[number]} info
|
|
194
|
+
* @returns {Record<string, any>}
|
|
195
|
+
*/
|
|
196
|
+
function firstPartyExtractorPlaceholder(info) {
|
|
197
|
+
return {
|
|
198
|
+
id: info.id,
|
|
199
|
+
version: info.version,
|
|
200
|
+
label: info.label,
|
|
201
|
+
tracks: info.tracks,
|
|
202
|
+
extractors: info.extractors,
|
|
203
|
+
stack: info.stack,
|
|
204
|
+
capabilities: info.capabilities,
|
|
205
|
+
candidateKinds: info.candidateKinds,
|
|
206
|
+
evidenceTypes: info.evidenceTypes,
|
|
207
|
+
useWhen: info.useWhen,
|
|
208
|
+
extracts: info.extracts,
|
|
209
|
+
knownFirstParty: true,
|
|
210
|
+
source: "package",
|
|
211
|
+
loadsAdapter: false,
|
|
212
|
+
executesPackageCode: false,
|
|
213
|
+
package: info.package,
|
|
214
|
+
installCommand: packageExtractorInstallCommand(info.package),
|
|
215
|
+
policyPinCommand: extractorPolicyPinCommand(info.package, info.version),
|
|
216
|
+
extractCommand: extractorRunCommand(info.package, info.tracks, info.exampleSource),
|
|
217
|
+
showCommand: `topogram extractor show ${info.package}`,
|
|
218
|
+
installed: false,
|
|
219
|
+
manifestPath: null,
|
|
220
|
+
packageRoot: null,
|
|
221
|
+
errors: []
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
109
225
|
/**
|
|
110
226
|
* @param {string} cwd
|
|
111
|
-
* @returns {{ ok: boolean, cwd: string, extractors: Record<string, any>[], summary: Record<string, number> }}
|
|
227
|
+
* @returns {{ ok: boolean, cwd: string, extractors: Record<string, any>[], groups: Record<string, ReturnType<typeof groupExtractorEntry>[]>, summary: Record<string, number> }}
|
|
112
228
|
*/
|
|
113
229
|
export function buildExtractorListPayload(cwd) {
|
|
114
230
|
const extractors = EXTRACTOR_MANIFESTS
|
|
115
231
|
.map((manifest) => extractorManifestSummary(manifest))
|
|
116
232
|
.sort((left, right) => left.id.localeCompare(right.id));
|
|
233
|
+
const seenPackages = new Set(extractors.map((extractor) => extractor.package).filter(Boolean));
|
|
234
|
+
const seenIds = new Set(extractors.map((extractor) => extractor.id).filter(Boolean));
|
|
117
235
|
for (const packageName of declaredExtractorPackages(cwd)) {
|
|
118
236
|
const loaded = loadPackageExtractorManifest(packageName, cwd);
|
|
119
237
|
if (loaded.manifest) {
|
|
120
|
-
|
|
238
|
+
const summary = extractorManifestSummary(loaded.manifest, {
|
|
121
239
|
installed: true,
|
|
122
240
|
manifestPath: loaded.manifestPath,
|
|
123
241
|
packageRoot: loaded.packageRoot,
|
|
124
242
|
errors: loaded.errors
|
|
125
|
-
})
|
|
243
|
+
});
|
|
244
|
+
extractors.push(summary);
|
|
245
|
+
if (summary.package) seenPackages.add(summary.package);
|
|
246
|
+
if (summary.id) seenIds.add(summary.id);
|
|
126
247
|
} else {
|
|
127
|
-
|
|
248
|
+
const firstParty = firstPartyExtractorInfo(packageName);
|
|
249
|
+
const fallback = firstParty ? firstPartyExtractorPlaceholder(firstParty) : {
|
|
128
250
|
id: null,
|
|
129
251
|
version: null,
|
|
130
252
|
tracks: [],
|
|
@@ -133,26 +255,44 @@ export function buildExtractorListPayload(cwd) {
|
|
|
133
255
|
capabilities: {},
|
|
134
256
|
candidateKinds: [],
|
|
135
257
|
evidenceTypes: [],
|
|
258
|
+
useWhen: null,
|
|
259
|
+
extracts: [],
|
|
260
|
+
knownFirstParty: false,
|
|
136
261
|
source: "package",
|
|
137
262
|
package: packageName,
|
|
138
263
|
installCommand: packageExtractorInstallCommand(packageName),
|
|
264
|
+
policyPinCommand: null,
|
|
265
|
+
extractCommand: null,
|
|
266
|
+
showCommand: `topogram extractor show ${packageName}`,
|
|
139
267
|
installed: false,
|
|
140
268
|
manifestPath: loaded.manifestPath,
|
|
141
269
|
packageRoot: loaded.packageRoot,
|
|
142
270
|
errors: loaded.errors
|
|
143
|
-
}
|
|
271
|
+
};
|
|
272
|
+
extractors.push({ ...fallback, errors: loaded.errors });
|
|
273
|
+
seenPackages.add(packageName);
|
|
274
|
+
if (fallback.id) seenIds.add(fallback.id);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const firstParty of FIRST_PARTY_EXTRACTOR_PACKAGES) {
|
|
278
|
+
if (!seenPackages.has(firstParty.package) && !seenIds.has(firstParty.id)) {
|
|
279
|
+
extractors.push(firstPartyExtractorPlaceholder(firstParty));
|
|
144
280
|
}
|
|
145
281
|
}
|
|
146
282
|
extractors.sort((left, right) => String(left.id || left.package || "").localeCompare(String(right.id || right.package || "")));
|
|
283
|
+
const groups = groupExtractorsByTrack(extractors);
|
|
147
284
|
return {
|
|
148
285
|
ok: extractors.every((extractor) => extractor.errors.length === 0),
|
|
149
286
|
cwd,
|
|
150
287
|
extractors,
|
|
288
|
+
groups,
|
|
151
289
|
summary: {
|
|
152
290
|
total: extractors.length,
|
|
153
291
|
bundled: extractors.filter((extractor) => extractor.source === "bundled").length,
|
|
154
292
|
package: extractors.filter((extractor) => extractor.source === "package").length,
|
|
155
|
-
installed: extractors.filter((extractor) => extractor.installed).length
|
|
293
|
+
installed: extractors.filter((extractor) => extractor.installed).length,
|
|
294
|
+
knownFirstParty: extractors.filter((extractor) => extractor.knownFirstParty).length,
|
|
295
|
+
missingFirstParty: extractors.filter((extractor) => extractor.knownFirstParty && !extractor.installed).length
|
|
156
296
|
}
|
|
157
297
|
};
|
|
158
298
|
}
|
|
@@ -184,6 +324,10 @@ export function buildExtractorShowPayload(spec, cwd) {
|
|
|
184
324
|
errors: []
|
|
185
325
|
};
|
|
186
326
|
}
|
|
327
|
+
const firstParty = firstPartyExtractorInfo(spec);
|
|
328
|
+
if (firstParty) {
|
|
329
|
+
return { ok: true, sourceSpec: spec, extractor: firstPartyExtractorPlaceholder(firstParty), errors: [] };
|
|
330
|
+
}
|
|
187
331
|
return { ok: false, sourceSpec: spec, extractor: null, errors: loaded.errors };
|
|
188
332
|
}
|
|
189
333
|
|
|
@@ -193,23 +337,36 @@ export function buildExtractorShowPayload(spec, cwd) {
|
|
|
193
337
|
*/
|
|
194
338
|
export function printExtractorList(payload) {
|
|
195
339
|
console.log("Topogram extractors");
|
|
196
|
-
console.log(`Bundled: ${payload.summary.bundled}; package-backed: ${payload.summary.package}; installed: ${payload.summary.installed}`);
|
|
340
|
+
console.log(`Bundled: ${payload.summary.bundled}; package-backed: ${payload.summary.package}; installed: ${payload.summary.installed}; first-party missing: ${payload.summary.missingFirstParty || 0}`);
|
|
341
|
+
console.log("Package-backed extractors are listed for discovery even before they are installed.");
|
|
197
342
|
console.log("");
|
|
198
|
-
for (const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
343
|
+
for (const track of EXTRACTOR_TRACK_ORDER) {
|
|
344
|
+
const entries = (payload.groups || {})[track] || [];
|
|
345
|
+
if (entries.length === 0) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
console.log(`${track}:`);
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
const extractor = /** @type {Record<string, any>} */ (payload.extractors.find((item) => (entry.package && item.package === entry.package) || (entry.id && item.id === entry.id)) || entry);
|
|
351
|
+
const id = extractor.id || extractor.package || "unknown";
|
|
352
|
+
const status = extractor.errors.length > 0
|
|
353
|
+
? "invalid"
|
|
354
|
+
: extractor.source === "package"
|
|
355
|
+
? (extractor.installed ? "package installed" : "package missing")
|
|
356
|
+
: "bundled";
|
|
357
|
+
console.log(`- ${extractor.label ? `${extractor.label} ` : ""}${id}${extractor.version ? `@${extractor.version}` : ""} (${status})`);
|
|
358
|
+
if (extractor.useWhen) console.log(` Use: ${extractor.useWhen}`);
|
|
359
|
+
console.log(` Source: ${extractor.source}`);
|
|
360
|
+
console.log(" Adapter loaded: no");
|
|
361
|
+
console.log(" Executes package code: no");
|
|
362
|
+
console.log(` Extractors: ${extractor.extractors.join(", ") || "none"}`);
|
|
363
|
+
if (extractor.package) console.log(` Package: ${extractor.package}`);
|
|
364
|
+
if (extractor.installCommand) console.log(` Install: ${extractor.installCommand}`);
|
|
365
|
+
if (extractor.policyPinCommand) console.log(` Policy: ${extractor.policyPinCommand}`);
|
|
366
|
+
if (extractor.extractCommand) console.log(` Extract: ${extractor.extractCommand}`);
|
|
367
|
+
for (const error of extractor.errors || []) console.log(` Error: ${error}`);
|
|
368
|
+
}
|
|
369
|
+
console.log("");
|
|
213
370
|
}
|
|
214
371
|
}
|
|
215
372
|
|
|
@@ -229,8 +386,14 @@ export function printExtractorShow(payload) {
|
|
|
229
386
|
console.log(`Source: ${extractor.source}`);
|
|
230
387
|
console.log("Adapter loaded: no");
|
|
231
388
|
console.log("Executes package code: no");
|
|
389
|
+
if (extractor.label) console.log(`Label: ${extractor.label}`);
|
|
390
|
+
if (extractor.useWhen) console.log(`Use: ${extractor.useWhen}`);
|
|
391
|
+
if (extractor.extracts?.length) console.log(`Extracts: ${extractor.extracts.join(", ")}`);
|
|
392
|
+
console.log(`Installed: ${extractor.installed ? "yes" : "no"}`);
|
|
232
393
|
if (extractor.package) console.log(`Package: ${extractor.package}`);
|
|
233
394
|
if (extractor.installCommand) console.log(`Install: ${extractor.installCommand}`);
|
|
395
|
+
if (extractor.policyPinCommand) console.log(`Policy: ${extractor.policyPinCommand}`);
|
|
396
|
+
if (extractor.extractCommand) console.log(`Extract: ${extractor.extractCommand}`);
|
|
234
397
|
if (extractor.manifestPath) console.log(`Manifest: ${extractor.manifestPath}`);
|
|
235
398
|
console.log(`Extractors: ${extractor.extractors.join(", ") || "none"}`);
|
|
236
399
|
console.log(`Candidate kinds: ${extractor.candidateKinds.join(", ") || "none"}`);
|
|
@@ -282,6 +445,9 @@ export function buildExtractorPolicyStatusPayload(projectPath) {
|
|
|
282
445
|
version,
|
|
283
446
|
allowed: extractorPackageAllowed(policy, packageName),
|
|
284
447
|
installed: Boolean(loaded.manifest),
|
|
448
|
+
knownFirstParty: Boolean(firstPartyExtractorInfo(packageName)),
|
|
449
|
+
installCommand: packageExtractorInstallCommand(packageName),
|
|
450
|
+
showCommand: `topogram extractor show ${packageName}`,
|
|
285
451
|
manifestPath: loaded.manifestPath,
|
|
286
452
|
packageRoot: loaded.packageRoot,
|
|
287
453
|
errors: [...loaded.errors, ...diagnostics.filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.message)]
|
|
@@ -371,8 +537,12 @@ export function printExtractorPolicyStatus(payload) {
|
|
|
371
537
|
console.log(`Policy file exists: ${payload.exists ? "yes" : "no"}`);
|
|
372
538
|
console.log(`Default policy active: ${payload.defaulted ? "yes" : "no"}`);
|
|
373
539
|
console.log(`Enabled packages: ${payload.summary.enabledPackages}`);
|
|
540
|
+
console.log("Default allowlist: bundled topogram/* extractors and first-party @topogram/extractor-* packages.");
|
|
541
|
+
console.log("Install behavior: Topogram does not install extractor packages automatically.");
|
|
374
542
|
for (const item of payload.packages) {
|
|
375
543
|
console.log(`- ${item.packageName}@${item.version}: ${item.installed ? "installed" : "missing"}, ${item.allowed ? "allowed" : "denied"}`);
|
|
544
|
+
if (!item.installed && item.installCommand) console.log(` Install: ${item.installCommand}`);
|
|
545
|
+
if (item.showCommand) console.log(` Show: ${item.showCommand}`);
|
|
376
546
|
}
|
|
377
547
|
for (const diagnostic of payload.diagnostics) {
|
|
378
548
|
console.log(`[${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`);
|
package/src/cli/commands/sdlc.js
CHANGED
|
@@ -220,6 +220,21 @@ export async function runSdlcCommand(context) {
|
|
|
220
220
|
return result.ok ? 0 : 1;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
if (commandArgs.sdlcCommand === "audit") {
|
|
224
|
+
const { auditWorkspace } = await import("../../sdlc/audit.js");
|
|
225
|
+
const resolved = resolveSdlcWorkspace(sdlcRoot);
|
|
226
|
+
if (!resolved) {
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
const result = auditWorkspace(sdlcRoot, resolved);
|
|
230
|
+
if (json) {
|
|
231
|
+
console.log(stableStringify(result));
|
|
232
|
+
} else {
|
|
233
|
+
printSdlcAudit(result);
|
|
234
|
+
}
|
|
235
|
+
return result.ok ? 0 : 1;
|
|
236
|
+
}
|
|
237
|
+
|
|
223
238
|
if (commandArgs.sdlcCommand === "link") {
|
|
224
239
|
const { linkSdlcRecord } = await import("../../sdlc/link.js");
|
|
225
240
|
const result = linkSdlcRecord(sdlcRoot, commandArgs.sdlcFromId, commandArgs.sdlcToId, {
|
|
@@ -374,3 +389,30 @@ export async function runSdlcCommand(context) {
|
|
|
374
389
|
|
|
375
390
|
throw new Error(`Unknown sdlc command '${commandArgs.sdlcCommand}'`);
|
|
376
391
|
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* @param {AnyRecord} result
|
|
395
|
+
*/
|
|
396
|
+
function printSdlcAudit(result) {
|
|
397
|
+
console.log("SDLC audit");
|
|
398
|
+
console.log(`Workspace: ${result.workspaceRoot}`);
|
|
399
|
+
console.log(`Draft requirements with completed task evidence: ${result.counts?.draftRequirementsWithCompletedTasks || 0}`);
|
|
400
|
+
console.log(`Draft acceptance criteria with completed task evidence: ${result.counts?.draftAcceptanceCriteriaWithCompletedTasks || 0}`);
|
|
401
|
+
console.log(`Approved acceptance criteria with draft parent requirements: ${result.counts?.approvedAcceptanceCriteriaWithDraftRequirements || 0}`);
|
|
402
|
+
console.log(`Done tasks with draft refs: ${result.counts?.doneTasksWithDraftReferences || 0}`);
|
|
403
|
+
console.log(`Remaining draft backlog: ${result.counts?.remainingDraftPitches || 0} pitch(es), ${result.counts?.remainingDraftRequirements || 0} requirement(s), ${result.counts?.remainingDraftAcceptanceCriteria || 0} acceptance criterion/criteria`);
|
|
404
|
+
const findings = [
|
|
405
|
+
...(result.findings?.draftRequirementsWithCompletedTasks || []),
|
|
406
|
+
...(result.findings?.draftAcceptanceCriteriaWithCompletedTasks || []),
|
|
407
|
+
...(result.findings?.approvedAcceptanceCriteriaWithDraftRequirements || []),
|
|
408
|
+
...(result.findings?.doneTasksWithDraftReferences || [])
|
|
409
|
+
];
|
|
410
|
+
if (findings.length > 0) {
|
|
411
|
+
console.log("Actionable findings:");
|
|
412
|
+
for (const finding of findings.slice(0, 10)) {
|
|
413
|
+
console.log(`- ${finding.id}: ${finding.recommendedCommand || "review status"}`);
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
console.log("Actionable findings: none");
|
|
417
|
+
}
|
|
418
|
+
}
|
package/src/cli/help.js
CHANGED
|
@@ -19,6 +19,7 @@ export function printUsage(options = {}) {
|
|
|
19
19
|
console.log(" or: topogram sdlc policy init|check|explain [path] [--json]");
|
|
20
20
|
console.log(" or: topogram sdlc gate [path] --base <ref> --head <ref> [--sdlc-id <id>] [--exemption <text>] [--require-adopted] [--json]");
|
|
21
21
|
console.log(" or: topogram sdlc prep commit [path] [--base <ref> --head <ref>] [--json]");
|
|
22
|
+
console.log(" or: topogram sdlc audit [path] [--json]");
|
|
22
23
|
console.log(" or: topogram sdlc link <from-id> <to-id> [path] [--write]");
|
|
23
24
|
console.log(" or: topogram sdlc complete <task-id> [path] --verification <verification-id> [--dry-run|--write]");
|
|
24
25
|
console.log(" or: topogram sdlc plan create <task-id> <slug> [path] [--write]");
|
|
@@ -94,6 +95,7 @@ export function printUsage(options = {}) {
|
|
|
94
95
|
console.log(" topogram agent brief");
|
|
95
96
|
console.log(" topogram agent brief --json");
|
|
96
97
|
console.log(" topogram sdlc policy explain");
|
|
98
|
+
console.log(" topogram sdlc audit . --json");
|
|
97
99
|
console.log(" topogram sdlc prep commit . --base origin/main --head HEAD");
|
|
98
100
|
console.log(" topogram sdlc gate . --require-adopted");
|
|
99
101
|
console.log(" topogram sdlc plan explain plan_example --json");
|
|
@@ -107,8 +109,10 @@ export function printUsage(options = {}) {
|
|
|
107
109
|
console.log(" topogram generator check ./generator-package");
|
|
108
110
|
console.log(" topogram generator policy check");
|
|
109
111
|
console.log(" topogram extractor list");
|
|
112
|
+
console.log(" topogram extractor show @topogram/extractor-prisma-db");
|
|
110
113
|
console.log(" topogram extractor check ./extractor-package");
|
|
111
114
|
console.log(" topogram extractor policy check");
|
|
115
|
+
console.log(" topogram extract ./express-api --out ./extracted-topogram --from api --extractor @topogram/extractor-express-api");
|
|
112
116
|
console.log(" topogram generate");
|
|
113
117
|
console.log(" topogram extract ./existing-app --out ./extracted-topogram");
|
|
114
118
|
console.log(" topogram extract diff ./extracted-topogram");
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
export const FIRST_PARTY_EXTRACTOR_PACKAGES = [
|
|
4
|
+
{
|
|
5
|
+
package: "@topogram/extractor-prisma-db",
|
|
6
|
+
id: "@topogram/extractor-prisma-db",
|
|
7
|
+
version: "1",
|
|
8
|
+
label: "Prisma DB",
|
|
9
|
+
tracks: ["db"],
|
|
10
|
+
stack: { orm: "prisma", domain: "database" },
|
|
11
|
+
capabilities: { schema: true, migrations: true, maintainedSeams: true },
|
|
12
|
+
candidateKinds: ["entity", "enum", "relation", "index", "maintained_db_migration_seam"],
|
|
13
|
+
evidenceTypes: ["runtime_source", "parser_config"],
|
|
14
|
+
extractors: ["db.prisma"],
|
|
15
|
+
useWhen: "Use for Prisma schema.prisma models plus Prisma migration evidence.",
|
|
16
|
+
extracts: ["entities", "enums", "relations", "indexes", "maintained DB seam proposals"],
|
|
17
|
+
exampleSource: "./prisma-app"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
package: "@topogram/extractor-express-api",
|
|
21
|
+
id: "@topogram/extractor-express-api",
|
|
22
|
+
version: "1",
|
|
23
|
+
label: "Express API",
|
|
24
|
+
tracks: ["api"],
|
|
25
|
+
stack: { runtime: "node", framework: "express" },
|
|
26
|
+
capabilities: { routes: true, parameters: true, authHints: true },
|
|
27
|
+
candidateKinds: ["capability", "route", "stack"],
|
|
28
|
+
evidenceTypes: ["runtime_source", "parser_config"],
|
|
29
|
+
extractors: ["api.express"],
|
|
30
|
+
useWhen: "Use for Express route files, routers, params, middleware, and auth hints.",
|
|
31
|
+
extracts: ["API route candidates", "capability suggestions", "parameter evidence", "auth hints", "stack evidence"],
|
|
32
|
+
exampleSource: "./express-api"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
package: "@topogram/extractor-drizzle-db",
|
|
36
|
+
id: "@topogram/extractor-drizzle-db",
|
|
37
|
+
version: "1",
|
|
38
|
+
label: "Drizzle DB",
|
|
39
|
+
tracks: ["db"],
|
|
40
|
+
stack: { orm: "drizzle", domain: "database" },
|
|
41
|
+
capabilities: { schema: true, migrations: true, maintainedSeams: true },
|
|
42
|
+
candidateKinds: ["entity", "enum", "relation", "index", "maintained_db_migration_seam"],
|
|
43
|
+
evidenceTypes: ["runtime_source", "parser_config"],
|
|
44
|
+
extractors: ["db.drizzle"],
|
|
45
|
+
useWhen: "Use for Drizzle config, schema modules, table definitions, and migration output.",
|
|
46
|
+
extracts: ["entities", "relations", "indexes", "maintained DB seam proposals", "schema/migration evidence"],
|
|
47
|
+
exampleSource: "./drizzle-app"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
package: "@topogram/extractor-node-cli",
|
|
51
|
+
id: "@topogram/extractor-node-cli",
|
|
52
|
+
version: "1",
|
|
53
|
+
label: "Node CLI",
|
|
54
|
+
tracks: ["cli"],
|
|
55
|
+
stack: { runtime: "node", domain: "cli" },
|
|
56
|
+
capabilities: { commands: true, options: true, effects: true },
|
|
57
|
+
candidateKinds: ["command", "capability", "cli_surface"],
|
|
58
|
+
evidenceTypes: ["runtime_source", "parser_config"],
|
|
59
|
+
extractors: ["cli.node-package"],
|
|
60
|
+
useWhen: "Use for Node package CLIs with bin entries, scripts, command modules, and help text.",
|
|
61
|
+
extracts: ["command candidates", "options", "effects", "CLI surface projections", "capability suggestions"],
|
|
62
|
+
exampleSource: "./existing-cli"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
package: "@topogram/extractor-react-router",
|
|
66
|
+
id: "@topogram/extractor-react-router",
|
|
67
|
+
version: "1",
|
|
68
|
+
label: "React Router UI",
|
|
69
|
+
tracks: ["ui"],
|
|
70
|
+
stack: { framework: "react-router", domain: "ui" },
|
|
71
|
+
capabilities: { routes: true, screens: true, flows: true, widgets: true },
|
|
72
|
+
candidateKinds: ["screen", "route", "action", "flow", "widget", "shape", "stack"],
|
|
73
|
+
evidenceTypes: ["runtime_source", "parser_config"],
|
|
74
|
+
extractors: ["ui.react-router"],
|
|
75
|
+
useWhen: "Use for React Router route trees, route modules, screen hints, and non-resource UI flows.",
|
|
76
|
+
extracts: ["screens", "routes", "flow candidates", "widget evidence", "stack evidence"],
|
|
77
|
+
exampleSource: "./react-router-app"
|
|
78
|
+
}
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
export const FIRST_PARTY_EXTRACTORS_BY_PACKAGE = new Map(FIRST_PARTY_EXTRACTOR_PACKAGES.map((item) => [item.package, item]));
|
|
82
|
+
export const FIRST_PARTY_EXTRACTORS_BY_ID = new Map(FIRST_PARTY_EXTRACTOR_PACKAGES.map((item) => [item.id, item]));
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string|null|undefined} value
|
|
86
|
+
* @returns {typeof FIRST_PARTY_EXTRACTOR_PACKAGES[number]|null}
|
|
87
|
+
*/
|
|
88
|
+
export function firstPartyExtractorInfo(value) {
|
|
89
|
+
if (!value) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return FIRST_PARTY_EXTRACTORS_BY_PACKAGE.get(value) || FIRST_PARTY_EXTRACTORS_BY_ID.get(value) || null;
|
|
93
|
+
}
|
|
@@ -128,6 +128,101 @@ function collectAffectedProjectionIds(graph, baselineGraph, diff) {
|
|
|
128
128
|
]);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
const WIDGET_CONTRACT_SECTIONS = [
|
|
132
|
+
"category",
|
|
133
|
+
"version",
|
|
134
|
+
"status",
|
|
135
|
+
"props",
|
|
136
|
+
"events",
|
|
137
|
+
"slots",
|
|
138
|
+
"behavior",
|
|
139
|
+
"behaviors",
|
|
140
|
+
"patterns",
|
|
141
|
+
"regions",
|
|
142
|
+
"approvals",
|
|
143
|
+
"lookups",
|
|
144
|
+
"dependencies"
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
function changedWidgetContractSections(entry) {
|
|
148
|
+
if (entry.classification === "additive") {
|
|
149
|
+
return WIDGET_CONTRACT_SECTIONS.filter((section) => entry.current?.[section] != null);
|
|
150
|
+
}
|
|
151
|
+
if (entry.classification === "removed") {
|
|
152
|
+
return WIDGET_CONTRACT_SECTIONS.filter((section) => entry.baseline?.[section] != null);
|
|
153
|
+
}
|
|
154
|
+
return WIDGET_CONTRACT_SECTIONS.filter((section) =>
|
|
155
|
+
JSON.stringify(entry.current?.[section] ?? null) !== JSON.stringify(entry.baseline?.[section] ?? null)
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function affectedProjectionIdsForWidgetChange(graph, baselineGraph, entry) {
|
|
160
|
+
if (entry.classification === "additive") {
|
|
161
|
+
return relatedProjectionsForWidget(graph, entry.id);
|
|
162
|
+
}
|
|
163
|
+
if (entry.classification === "removed") {
|
|
164
|
+
return relatedProjectionsForWidget(baselineGraph, entry.id);
|
|
165
|
+
}
|
|
166
|
+
return stableSortedStrings([
|
|
167
|
+
...relatedProjectionsForWidget(graph, entry.id),
|
|
168
|
+
...relatedProjectionsForWidget(baselineGraph, entry.id)
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function projectionMigrationCommands(widgetId, projectionId) {
|
|
173
|
+
return [
|
|
174
|
+
{
|
|
175
|
+
target: "ui-widget-contract",
|
|
176
|
+
command: `topogram emit ui-widget-contract ./topo --widget ${widgetId} --json`,
|
|
177
|
+
reason: `Refresh the normalized widget contract for ${widgetId}.`
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
target: "widget-conformance-report",
|
|
181
|
+
command: `topogram emit widget-conformance-report ./topo --projection ${projectionId} --json`,
|
|
182
|
+
reason: `Review projection ${projectionId} widget placement, required props, events, and region/pattern compatibility.`
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
target: "widget-behavior-report",
|
|
186
|
+
command: `topogram widget behavior ./topo --projection ${projectionId} --widget ${widgetId} --json`,
|
|
187
|
+
reason: `Review behavior data/event/action wiring for ${widgetId} on projection ${projectionId}.`
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
target: "ui-surface-contract",
|
|
191
|
+
command: `topogram emit ui-surface-contract ./topo --projection ${projectionId} --json`,
|
|
192
|
+
reason: `Refresh the surface contract consumed by stack generators for projection ${projectionId}.`
|
|
193
|
+
}
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function buildWidgetContractMigrationPlan(graph, baselineGraph, diff) {
|
|
198
|
+
const widgets = (diff.widgets || []).map((entry) => {
|
|
199
|
+
const affectedProjectionIds = affectedProjectionIdsForWidgetChange(graph, baselineGraph, entry);
|
|
200
|
+
return {
|
|
201
|
+
widget_id: entry.id,
|
|
202
|
+
classification: entry.classification,
|
|
203
|
+
changed_sections: changedWidgetContractSections(entry),
|
|
204
|
+
affected_projection_ids: affectedProjectionIds,
|
|
205
|
+
affected_projections: affectedProjectionIds
|
|
206
|
+
.map((id) => summarizeById(graph, id) || summarizeById(baselineGraph, id))
|
|
207
|
+
.filter(Boolean),
|
|
208
|
+
review_commands: affectedProjectionIds.flatMap((projectionId) => projectionMigrationCommands(entry.id, projectionId)),
|
|
209
|
+
migration_steps: [
|
|
210
|
+
"Review changed_sections to understand the semantic widget contract delta.",
|
|
211
|
+
"Refresh the widget contract and affected surface contracts.",
|
|
212
|
+
"Run conformance and behavior reports for every affected projection.",
|
|
213
|
+
"Regenerate generated-owned outputs or manually update maintained surfaces after review."
|
|
214
|
+
]
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
widgets,
|
|
220
|
+
affected_widget_ids: stableSortedStrings(widgets.map((entry) => entry.widget_id)),
|
|
221
|
+
affected_projection_ids: stableSortedStrings(widgets.flatMap((entry) => entry.affected_projection_ids)),
|
|
222
|
+
review_command_count: widgets.reduce((count, entry) => count + entry.review_commands.length, 0)
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
131
226
|
function collectAffectedCapabilityIds(graph, diff) {
|
|
132
227
|
const changedCapabilities = stableSortedStrings((diff.capabilities || []).map((entry) => entry.id));
|
|
133
228
|
const changedEntities = stableSortedStrings((diff.entities || []).map((entry) => entry.id));
|
|
@@ -213,6 +308,7 @@ export function generateContextDiff(graph, options = {}) {
|
|
|
213
308
|
const affectedCapabilities = collectAffectedCapabilityIds(graph, diff);
|
|
214
309
|
const affectedProjections = collectAffectedProjectionIds(graph, baselineGraph, diff);
|
|
215
310
|
const affectedVerifications = collectAffectedVerificationIds(graph, diff);
|
|
311
|
+
const widgetContractMigrationPlan = buildWidgetContractMigrationPlan(graph, baselineGraph, diff);
|
|
216
312
|
const changedSemanticIds = changedIdsForDiffSections(diff);
|
|
217
313
|
const affectedMaintainedStories = maintainedProofMetadata(graph).filter((item) => {
|
|
218
314
|
const emittedDependencies = item.emittedDependencies || [];
|
|
@@ -257,6 +353,7 @@ export function generateContextDiff(graph, options = {}) {
|
|
|
257
353
|
}))
|
|
258
354
|
},
|
|
259
355
|
affected_verifications: affectedVerifications.map((id) => summarizeById(graph, id) || summarizeById(baselineGraph, id)).filter(Boolean),
|
|
356
|
+
widget_contract_migration_plan: widgetContractMigrationPlan,
|
|
260
357
|
review_boundary_changes: reviewBoundaryChangeItems,
|
|
261
358
|
sdlc: sdlcChanges
|
|
262
359
|
};
|
|
@@ -6,7 +6,11 @@ import {
|
|
|
6
6
|
packageInstallHint,
|
|
7
7
|
resolvePackageManifestPath
|
|
8
8
|
} from "../../package-adapters/index.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
UI_GENERATOR_RENDERED_COMPONENT_PATTERNS,
|
|
11
|
+
UI_PATTERN_KINDS,
|
|
12
|
+
WIDGET_BEHAVIOR_KINDS
|
|
13
|
+
} from "../../ui/taxonomy.js";
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* @typedef {Object} GeneratorManifest
|
|
@@ -193,6 +197,18 @@ function isStringArray(value, nonEmpty = false) {
|
|
|
193
197
|
value.every((entry) => typeof entry === "string" && entry.length > 0);
|
|
194
198
|
}
|
|
195
199
|
|
|
200
|
+
/**
|
|
201
|
+
* @param {any} value
|
|
202
|
+
* @param {Set<string>} allowed
|
|
203
|
+
* @returns {string[]}
|
|
204
|
+
*/
|
|
205
|
+
function unsupportedStringValues(value, allowed) {
|
|
206
|
+
if (!Array.isArray(value)) {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
return value.filter((entry) => typeof entry === "string" && !allowed.has(entry));
|
|
210
|
+
}
|
|
211
|
+
|
|
196
212
|
/**
|
|
197
213
|
* @param {string} oldName
|
|
198
214
|
* @param {string} newName
|
|
@@ -377,9 +393,15 @@ export function validateGeneratorManifest(manifest) {
|
|
|
377
393
|
if (manifest.widgetSupport.patterns != null && !isStringArray(manifest.widgetSupport.patterns)) {
|
|
378
394
|
errors.push(`${label} widgetSupport.patterns must be a string array`);
|
|
379
395
|
}
|
|
396
|
+
for (const pattern of unsupportedStringValues(manifest.widgetSupport.patterns, UI_PATTERN_KINDS)) {
|
|
397
|
+
errors.push(`${label} widgetSupport.patterns contains unsupported pattern '${pattern}'`);
|
|
398
|
+
}
|
|
380
399
|
if (manifest.widgetSupport.behaviors != null && !isStringArray(manifest.widgetSupport.behaviors)) {
|
|
381
400
|
errors.push(`${label} widgetSupport.behaviors must be a string array`);
|
|
382
401
|
}
|
|
402
|
+
for (const behavior of unsupportedStringValues(manifest.widgetSupport.behaviors, WIDGET_BEHAVIOR_KINDS)) {
|
|
403
|
+
errors.push(`${label} widgetSupport.behaviors contains unsupported behavior '${behavior}'`);
|
|
404
|
+
}
|
|
383
405
|
if (
|
|
384
406
|
manifest.widgetSupport.unsupported != null &&
|
|
385
407
|
!["error", "warning", "contract-only"].includes(manifest.widgetSupport.unsupported)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Record<string, any>} AnyRecord
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const DONE_TASK_STATUSES = new Set(["done"]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {unknown} refs
|
|
11
|
+
* @returns {string[]}
|
|
12
|
+
*/
|
|
13
|
+
function refIds(refs) {
|
|
14
|
+
if (!Array.isArray(refs)) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
return refs
|
|
18
|
+
.map((ref) => {
|
|
19
|
+
if (typeof ref === "string") {
|
|
20
|
+
return ref;
|
|
21
|
+
}
|
|
22
|
+
if (ref && typeof ref === "object" && "id" in ref) {
|
|
23
|
+
return String(ref.id);
|
|
24
|
+
}
|
|
25
|
+
return "";
|
|
26
|
+
})
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {AnyRecord} statement
|
|
32
|
+
* @returns {{ id: string, name: string|null, status: string|null, file: string|null }}
|
|
33
|
+
*/
|
|
34
|
+
function summary(statement) {
|
|
35
|
+
return {
|
|
36
|
+
id: String(statement.id || ""),
|
|
37
|
+
name: typeof statement.name === "string" ? statement.name : null,
|
|
38
|
+
status: typeof statement.status === "string" ? statement.status : null,
|
|
39
|
+
file: typeof statement.loc?.file === "string" ? statement.loc.file : null
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {Map<string, AnyRecord[]>} map
|
|
45
|
+
* @param {string} key
|
|
46
|
+
* @param {AnyRecord} value
|
|
47
|
+
*/
|
|
48
|
+
function pushMap(map, key, value) {
|
|
49
|
+
const values = map.get(key) || [];
|
|
50
|
+
values.push(value);
|
|
51
|
+
map.set(key, values);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {AnyRecord} graph
|
|
56
|
+
* @param {string} id
|
|
57
|
+
* @returns {AnyRecord|null}
|
|
58
|
+
*/
|
|
59
|
+
function byId(graph, id) {
|
|
60
|
+
const value = graph?.byId?.get ? graph.byId.get(id) : graph?.byId?.[id];
|
|
61
|
+
if (value && typeof value === "object") {
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
const byKind = graph?.byKind || {};
|
|
65
|
+
for (const statements of Object.values(byKind)) {
|
|
66
|
+
if (!Array.isArray(statements)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const found = statements.find((statement) => statement?.id === id);
|
|
70
|
+
if (found && typeof found === "object") {
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return value && typeof value === "object" ? value : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reports SDLC status hygiene issues that are easy to miss after implementation:
|
|
79
|
+
* draft requirements or acceptance criteria that already have completed task
|
|
80
|
+
* evidence, and approved acceptance criteria whose parent requirement is still
|
|
81
|
+
* draft. The audit is read-only.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} workspaceRoot
|
|
84
|
+
* @param {AnyRecord} resolved
|
|
85
|
+
* @returns {AnyRecord}
|
|
86
|
+
*/
|
|
87
|
+
export function auditWorkspace(workspaceRoot, resolved) {
|
|
88
|
+
const graph = resolved.graph || {};
|
|
89
|
+
const byKind = graph.byKind || {};
|
|
90
|
+
/** @type {AnyRecord[]} */
|
|
91
|
+
const tasks = Array.isArray(byKind.task) ? byKind.task : [];
|
|
92
|
+
/** @type {AnyRecord[]} */
|
|
93
|
+
const requirements = Array.isArray(byKind.requirement) ? byKind.requirement : [];
|
|
94
|
+
/** @type {AnyRecord[]} */
|
|
95
|
+
const acceptanceCriteria = Array.isArray(byKind.acceptance_criterion) ? byKind.acceptance_criterion : [];
|
|
96
|
+
/** @type {AnyRecord[]} */
|
|
97
|
+
const pitches = Array.isArray(byKind.pitch) ? byKind.pitch : [];
|
|
98
|
+
|
|
99
|
+
const doneTasks = tasks.filter((task) => DONE_TASK_STATUSES.has(String(task.status || "")));
|
|
100
|
+
/** @type {Map<string, AnyRecord[]>} */
|
|
101
|
+
const doneTasksByRequirement = new Map();
|
|
102
|
+
/** @type {Map<string, AnyRecord[]>} */
|
|
103
|
+
const doneTasksByAcceptance = new Map();
|
|
104
|
+
|
|
105
|
+
for (const task of doneTasks) {
|
|
106
|
+
for (const id of refIds(task.satisfies)) {
|
|
107
|
+
pushMap(doneTasksByRequirement, id, task);
|
|
108
|
+
}
|
|
109
|
+
for (const id of refIds(task.acceptanceRefs)) {
|
|
110
|
+
pushMap(doneTasksByAcceptance, id, task);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const draftRequirementsWithCompletedTasks = requirements
|
|
115
|
+
.filter((requirement) => requirement.status === "draft" && doneTasksByRequirement.has(String(requirement.id || "")))
|
|
116
|
+
.map((requirement) => ({
|
|
117
|
+
...summary(requirement),
|
|
118
|
+
completedTasks: (doneTasksByRequirement.get(String(requirement.id || "")) || []).map(summary),
|
|
119
|
+
recommendedCommand: `topogram sdlc transition ${requirement.id} in-review . --actor <actor> --note "<reason>"`
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
const draftAcceptanceCriteriaWithCompletedTasks = acceptanceCriteria
|
|
123
|
+
.filter((criterion) => criterion.status === "draft" && doneTasksByAcceptance.has(String(criterion.id || "")))
|
|
124
|
+
.map((criterion) => ({
|
|
125
|
+
...summary(criterion),
|
|
126
|
+
completedTasks: (doneTasksByAcceptance.get(String(criterion.id || "")) || []).map(summary),
|
|
127
|
+
recommendedCommand: `topogram sdlc transition ${criterion.id} approved . --actor <actor> --note "<reason>"`
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
const approvedAcceptanceCriteriaWithDraftRequirements = acceptanceCriteria
|
|
131
|
+
.filter((criterion) => {
|
|
132
|
+
if (criterion.status !== "approved") {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const requirementId = criterion.requirement?.id || "";
|
|
136
|
+
const requirement = requirementId ? byId(graph, requirementId) : null;
|
|
137
|
+
return requirement?.status === "draft";
|
|
138
|
+
})
|
|
139
|
+
.map((criterion) => {
|
|
140
|
+
const requirement = byId(graph, criterion.requirement?.id || "");
|
|
141
|
+
return {
|
|
142
|
+
...summary(criterion),
|
|
143
|
+
requirement: requirement ? summary(requirement) : null,
|
|
144
|
+
recommendedCommand: requirement
|
|
145
|
+
? `topogram sdlc transition ${requirement.id} in-review . --actor <actor> --note "<reason>"`
|
|
146
|
+
: null
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const doneTasksWithDraftReferences = doneTasks
|
|
151
|
+
.map((task) => {
|
|
152
|
+
const draftRequirements = refIds(task.satisfies)
|
|
153
|
+
.map((id) => byId(graph, id))
|
|
154
|
+
.filter((statement) => statement?.status === "draft")
|
|
155
|
+
.map((statement) => summary(/** @type {AnyRecord} */ (statement)));
|
|
156
|
+
const draftAcceptanceCriteria = refIds(task.acceptanceRefs)
|
|
157
|
+
.map((id) => byId(graph, id))
|
|
158
|
+
.filter((statement) => statement?.status === "draft")
|
|
159
|
+
.map((statement) => summary(/** @type {AnyRecord} */ (statement)));
|
|
160
|
+
return {
|
|
161
|
+
...summary(task),
|
|
162
|
+
draftRequirements,
|
|
163
|
+
draftAcceptanceCriteria
|
|
164
|
+
};
|
|
165
|
+
})
|
|
166
|
+
.filter((task) => task.draftRequirements.length > 0 || task.draftAcceptanceCriteria.length > 0);
|
|
167
|
+
|
|
168
|
+
const counts = {
|
|
169
|
+
draftRequirementsWithCompletedTasks: draftRequirementsWithCompletedTasks.length,
|
|
170
|
+
draftAcceptanceCriteriaWithCompletedTasks: draftAcceptanceCriteriaWithCompletedTasks.length,
|
|
171
|
+
approvedAcceptanceCriteriaWithDraftRequirements: approvedAcceptanceCriteriaWithDraftRequirements.length,
|
|
172
|
+
doneTasksWithDraftReferences: doneTasksWithDraftReferences.length,
|
|
173
|
+
remainingDraftPitches: pitches.filter((pitch) => pitch.status === "draft").length,
|
|
174
|
+
remainingDraftRequirements: requirements.filter((requirement) => requirement.status === "draft").length,
|
|
175
|
+
remainingDraftAcceptanceCriteria: acceptanceCriteria.filter((criterion) => criterion.status === "draft").length
|
|
176
|
+
};
|
|
177
|
+
const actionableFindings =
|
|
178
|
+
counts.draftRequirementsWithCompletedTasks +
|
|
179
|
+
counts.draftAcceptanceCriteriaWithCompletedTasks +
|
|
180
|
+
counts.approvedAcceptanceCriteriaWithDraftRequirements +
|
|
181
|
+
counts.doneTasksWithDraftReferences;
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
type: "sdlc_audit",
|
|
185
|
+
version: "1",
|
|
186
|
+
ok: actionableFindings === 0,
|
|
187
|
+
workspaceRoot,
|
|
188
|
+
counts,
|
|
189
|
+
findings: {
|
|
190
|
+
draftRequirementsWithCompletedTasks,
|
|
191
|
+
draftAcceptanceCriteriaWithCompletedTasks,
|
|
192
|
+
approvedAcceptanceCriteriaWithDraftRequirements,
|
|
193
|
+
doneTasksWithDraftReferences
|
|
194
|
+
},
|
|
195
|
+
remainingDrafts: {
|
|
196
|
+
pitches: pitches.filter((pitch) => pitch.status === "draft").map(summary),
|
|
197
|
+
requirements: requirements.filter((requirement) => requirement.status === "draft").map(summary),
|
|
198
|
+
acceptanceCriteria: acceptanceCriteria.filter((criterion) => criterion.status === "draft").map(summary)
|
|
199
|
+
},
|
|
200
|
+
nextCommands: [
|
|
201
|
+
"topogram sdlc check --strict",
|
|
202
|
+
"topogram sdlc prep commit . --json"
|
|
203
|
+
]
|
|
204
|
+
};
|
|
205
|
+
}
|
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",
|
package/src/ui/taxonomy.js
CHANGED
|
@@ -155,6 +155,51 @@ export const UI_GENERATOR_RENDERED_COMPONENT_PATTERNS = new Set([
|
|
|
155
155
|
"data_grid_view"
|
|
156
156
|
]);
|
|
157
157
|
|
|
158
|
+
export const WIDGET_CATEGORIES = new Set([
|
|
159
|
+
"collection",
|
|
160
|
+
"form",
|
|
161
|
+
"display",
|
|
162
|
+
"navigation",
|
|
163
|
+
"dialog",
|
|
164
|
+
"feedback",
|
|
165
|
+
"lookup",
|
|
166
|
+
"layout",
|
|
167
|
+
"service"
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
export const WIDGET_BEHAVIOR_KINDS = new Set([
|
|
171
|
+
"selection",
|
|
172
|
+
"sorting",
|
|
173
|
+
"filtering",
|
|
174
|
+
"search",
|
|
175
|
+
"pagination",
|
|
176
|
+
"grouping",
|
|
177
|
+
"drag_drop",
|
|
178
|
+
"inline_edit",
|
|
179
|
+
"bulk_action",
|
|
180
|
+
"optimistic_update",
|
|
181
|
+
"realtime_update",
|
|
182
|
+
"keyboard_navigation"
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
export const WIDGET_BEHAVIOR_DIRECTIVES = {
|
|
186
|
+
selection: new Set(["mode", "state", "emits"]),
|
|
187
|
+
sorting: new Set(["fields", "default"]),
|
|
188
|
+
filtering: new Set(["fields"]),
|
|
189
|
+
search: new Set(["fields"]),
|
|
190
|
+
pagination: new Set(["mode", "page_size"]),
|
|
191
|
+
grouping: new Set(["fields"]),
|
|
192
|
+
drag_drop: new Set(["axis", "reorder"]),
|
|
193
|
+
inline_edit: new Set(["fields", "submit", "emits"]),
|
|
194
|
+
bulk_action: new Set(["actions", "state", "emits"]),
|
|
195
|
+
optimistic_update: new Set(["actions", "rollback"]),
|
|
196
|
+
realtime_update: new Set(["source", "merge"]),
|
|
197
|
+
keyboard_navigation: new Set(["scope", "shortcuts"])
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const WIDGET_SELECTION_MODES = new Set(["single", "multi", "none"]);
|
|
201
|
+
export const WIDGET_PAGINATION_MODES = new Set(["cursor", "paged", "infinite", "none"]);
|
|
202
|
+
|
|
158
203
|
/**
|
|
159
204
|
* @param {string[]} presentations
|
|
160
205
|
* @returns {string}
|
package/src/validator/kinds.d.ts
CHANGED
|
@@ -47,4 +47,9 @@ export const UI_DESIGN_COLOR_ROLES: Set<string>;
|
|
|
47
47
|
export const UI_DESIGN_TYPOGRAPHY_ROLES: Set<string>;
|
|
48
48
|
export const UI_DESIGN_ACTION_ROLES: Set<string>;
|
|
49
49
|
export const UI_DESIGN_ACCESSIBILITY_VALUES: Record<string, Set<string>>;
|
|
50
|
+
export const WIDGET_CATEGORIES: Set<string>;
|
|
51
|
+
export const WIDGET_BEHAVIOR_KINDS: Set<string>;
|
|
52
|
+
export const WIDGET_BEHAVIOR_DIRECTIVES: Record<string, Set<string>>;
|
|
53
|
+
export const WIDGET_SELECTION_MODES: Set<string>;
|
|
54
|
+
export const WIDGET_PAGINATION_MODES: Set<string>;
|
|
50
55
|
export const FIELD_SPECS: Record<string, { required: string[]; allowed: string[] }>;
|
package/src/validator/kinds.js
CHANGED
|
@@ -107,7 +107,12 @@ export {
|
|
|
107
107
|
UI_DESIGN_COLOR_ROLES,
|
|
108
108
|
UI_DESIGN_TYPOGRAPHY_ROLES,
|
|
109
109
|
UI_DESIGN_ACTION_ROLES,
|
|
110
|
-
UI_DESIGN_ACCESSIBILITY_VALUES
|
|
110
|
+
UI_DESIGN_ACCESSIBILITY_VALUES,
|
|
111
|
+
WIDGET_CATEGORIES,
|
|
112
|
+
WIDGET_BEHAVIOR_KINDS,
|
|
113
|
+
WIDGET_BEHAVIOR_DIRECTIVES,
|
|
114
|
+
WIDGET_SELECTION_MODES,
|
|
115
|
+
WIDGET_PAGINATION_MODES
|
|
111
116
|
} from "../ui/taxonomy.js";
|
|
112
117
|
|
|
113
118
|
// Kinds that may carry an optional singular `domain dom_x` field. Keep this
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import {
|
|
3
|
+
WIDGET_BEHAVIOR_DIRECTIVES,
|
|
4
|
+
WIDGET_BEHAVIOR_KINDS,
|
|
5
|
+
WIDGET_CATEGORIES,
|
|
6
|
+
WIDGET_PAGINATION_MODES,
|
|
7
|
+
WIDGET_SELECTION_MODES,
|
|
3
8
|
UI_PATTERN_KINDS,
|
|
4
9
|
UI_REGION_KINDS
|
|
5
10
|
} from "../kinds.js";
|
|
@@ -10,49 +15,6 @@ import {
|
|
|
10
15
|
symbolValues
|
|
11
16
|
} from "../utils.js";
|
|
12
17
|
|
|
13
|
-
const WIDGET_CATEGORIES = new Set([
|
|
14
|
-
"collection",
|
|
15
|
-
"form",
|
|
16
|
-
"display",
|
|
17
|
-
"navigation",
|
|
18
|
-
"dialog",
|
|
19
|
-
"feedback",
|
|
20
|
-
"lookup",
|
|
21
|
-
"layout",
|
|
22
|
-
"service"
|
|
23
|
-
]);
|
|
24
|
-
|
|
25
|
-
const WIDGET_BEHAVIOR_KINDS = new Set([
|
|
26
|
-
"selection",
|
|
27
|
-
"sorting",
|
|
28
|
-
"filtering",
|
|
29
|
-
"search",
|
|
30
|
-
"pagination",
|
|
31
|
-
"grouping",
|
|
32
|
-
"drag_drop",
|
|
33
|
-
"inline_edit",
|
|
34
|
-
"bulk_action",
|
|
35
|
-
"optimistic_update",
|
|
36
|
-
"realtime_update",
|
|
37
|
-
"keyboard_navigation"
|
|
38
|
-
]);
|
|
39
|
-
|
|
40
|
-
/** @type {Record<string, Set<string>>} */
|
|
41
|
-
const WIDGET_BEHAVIOR_DIRECTIVES = {
|
|
42
|
-
selection: new Set(["mode", "state", "emits"]),
|
|
43
|
-
sorting: new Set(["fields", "default"]),
|
|
44
|
-
filtering: new Set(["fields"]),
|
|
45
|
-
search: new Set(["fields"]),
|
|
46
|
-
pagination: new Set(["mode", "page_size"]),
|
|
47
|
-
grouping: new Set(["fields"]),
|
|
48
|
-
drag_drop: new Set(["axis", "reorder"]),
|
|
49
|
-
inline_edit: new Set(["fields", "submit", "emits"]),
|
|
50
|
-
bulk_action: new Set(["actions", "state", "emits"]),
|
|
51
|
-
optimistic_update: new Set(["actions", "rollback"]),
|
|
52
|
-
realtime_update: new Set(["source", "merge"]),
|
|
53
|
-
keyboard_navigation: new Set(["scope", "shortcuts"])
|
|
54
|
-
};
|
|
55
|
-
|
|
56
18
|
/** @param {TopogramToken | null | undefined} token @returns {any} */
|
|
57
19
|
function tokenValue(token) {
|
|
58
20
|
return token?.value ?? null;
|
|
@@ -262,10 +224,10 @@ function validateWidgetBehaviors(errors, statement, fieldMap, registry) {
|
|
|
262
224
|
if (directive === "actions" || directive === "submit") {
|
|
263
225
|
validateBehaviorActionReferences(errors, statement, registry, kind, directive, valueToken, eventNames);
|
|
264
226
|
}
|
|
265
|
-
if (kind === "selection" && directive === "mode" && !
|
|
227
|
+
if (kind === "selection" && directive === "mode" && !WIDGET_SELECTION_MODES.has(tokenValue(valueToken))) {
|
|
266
228
|
pushError(errors, `Widget ${statement.id} behavior 'selection' has invalid mode '${tokenValue(valueToken)}'`, valueToken.loc);
|
|
267
229
|
}
|
|
268
|
-
if (kind === "pagination" && directive === "mode" && !
|
|
230
|
+
if (kind === "pagination" && directive === "mode" && !WIDGET_PAGINATION_MODES.has(tokenValue(valueToken))) {
|
|
269
231
|
pushError(errors, `Widget ${statement.id} behavior 'pagination' has invalid mode '${tokenValue(valueToken)}'`, valueToken.loc);
|
|
270
232
|
}
|
|
271
233
|
}
|