@topogram/cli 0.3.84 → 0.3.85

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.84",
3
+ "version": "0.3.85",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -5,6 +5,7 @@ import {
5
5
  loadExtractorPackageAdapterForSpec,
6
6
  validateExtractorAdapter
7
7
  } from "./packages.js";
8
+ import { validateExtractorResult } from "./output.js";
8
9
 
9
10
  /**
10
11
  * @typedef {import("./registry.js").ExtractorManifest} ExtractorManifest
@@ -25,39 +26,6 @@ import {
25
26
  * @property {boolean} executesPackageCode
26
27
  */
27
28
 
28
- /**
29
- * @param {any} result
30
- * @returns {{ ok: boolean, message: string, smoke: { findings: number, candidateKeys: number, diagnostics: number }|null }}
31
- */
32
- function validateExtractResult(result) {
33
- if (!result || typeof result !== "object" || Array.isArray(result)) {
34
- return { ok: false, message: "extract(context) must return an object", smoke: null };
35
- }
36
- if (result.findings != null && !Array.isArray(result.findings)) {
37
- return { ok: false, message: "extract(context) findings must be an array when present", smoke: null };
38
- }
39
- if (result.diagnostics != null && !Array.isArray(result.diagnostics)) {
40
- return { ok: false, message: "extract(context) diagnostics must be an array when present", smoke: null };
41
- }
42
- if (!result.candidates || typeof result.candidates !== "object" || Array.isArray(result.candidates)) {
43
- return { ok: false, message: "extract(context) result must include a candidates object", smoke: null };
44
- }
45
- for (const [key, value] of Object.entries(result.candidates)) {
46
- if (!Array.isArray(value)) {
47
- return { ok: false, message: `extract(context) candidates.${key} must be an array`, smoke: null };
48
- }
49
- }
50
- return {
51
- ok: true,
52
- message: `extract(context) returned ${Object.keys(result.candidates).length} candidate bucket(s)`,
53
- smoke: {
54
- findings: Array.isArray(result.findings) ? result.findings.length : 0,
55
- candidateKeys: Object.keys(result.candidates).length,
56
- diagnostics: Array.isArray(result.diagnostics) ? result.diagnostics.length : 0
57
- }
58
- };
59
- }
60
-
61
29
  /**
62
30
  * @param {string} sourceSpec
63
31
  * @param {{ cwd?: string }} [options]
@@ -126,9 +94,9 @@ export function checkExtractorPack(sourceSpec, options = {}) {
126
94
  continue;
127
95
  }
128
96
  const result = extractor.extract(context) || { findings: [], candidates: {} };
129
- const validation = validateExtractResult(result);
97
+ const validation = validateExtractorResult(result, { track: extractor.track, strictCandidates: true });
130
98
  if (!validation.ok || !validation.smoke) {
131
- payload.errors.push(`Extractor '${extractor.id}' ${validation.message}.`);
99
+ payload.errors.push(...validation.errors.map((message) => `Extractor '${extractor.id}' ${message}.`));
132
100
  continue;
133
101
  }
134
102
  totalFindings += validation.smoke.findings;
@@ -152,4 +120,3 @@ export function checkExtractorPack(sourceSpec, options = {}) {
152
120
  payload.ok = payload.errors.length === 0;
153
121
  return payload;
154
122
  }
155
-
@@ -0,0 +1,220 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * @typedef {Object} ExtractorResultValidationOptions
5
+ * @property {string} [track]
6
+ * @property {boolean} [strictCandidates]
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} ExtractorResultValidation
11
+ * @property {boolean} ok
12
+ * @property {string[]} errors
13
+ * @property {{ findings: number, candidateKeys: number, diagnostics: number }|null} smoke
14
+ */
15
+
16
+ /** @type {Record<string, Set<string>>} */
17
+ const TRACK_CANDIDATE_BUCKETS = {
18
+ db: new Set(["entities", "enums", "relations", "indexes", "maintained_seams"]),
19
+ api: new Set(["capabilities", "routes", "stacks"]),
20
+ ui: new Set(["screens", "routes", "actions", "flows", "widgets", "shapes", "stacks"]),
21
+ cli: new Set(["commands", "capabilities", "surfaces"]),
22
+ workflows: new Set(["workflows", "workflow_states", "workflow_transitions"]),
23
+ verification: new Set(["verifications", "scenarios", "frameworks", "scripts"])
24
+ };
25
+
26
+ const DISALLOWED_BUCKETS = new Set([
27
+ "adoption",
28
+ "adoption_plan",
29
+ "adoptionPlan",
30
+ "canonical",
31
+ "canonical_files",
32
+ "canonicalFiles",
33
+ "files",
34
+ "patches",
35
+ "project_config",
36
+ "projectConfig",
37
+ "topo",
38
+ "topogram",
39
+ "topogram_project",
40
+ "topogramProject",
41
+ "writeFiles",
42
+ "writes",
43
+ "writtenFiles"
44
+ ]);
45
+
46
+ const DISALLOWED_RECORD_KEYS = new Set([
47
+ "adoption",
48
+ "adoptionPlan",
49
+ "canonical",
50
+ "canonicalFiles",
51
+ "files",
52
+ "patches",
53
+ "receipt",
54
+ "topo",
55
+ "topogram",
56
+ "write",
57
+ "writeFiles",
58
+ "writes",
59
+ "writtenFiles"
60
+ ]);
61
+
62
+ /**
63
+ * Keys that carry local source/package file references. Deliberately excludes
64
+ * command/route `path` and config target dotted `path` values.
65
+ */
66
+ const PATH_KEYS = new Set([
67
+ "configFile",
68
+ "configPath",
69
+ "file",
70
+ "filePath",
71
+ "migrationPath",
72
+ "migrationsPath",
73
+ "schemaPath",
74
+ "snapshotPath",
75
+ "sourceFile",
76
+ "sourcePath",
77
+ "source_path",
78
+ "targetFile",
79
+ "targetPath"
80
+ ]);
81
+
82
+ /**
83
+ * @param {unknown} value
84
+ * @returns {value is Record<string, unknown>}
85
+ */
86
+ function isPlainObject(value) {
87
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
88
+ }
89
+
90
+ /**
91
+ * @param {string} candidatePath
92
+ * @returns {boolean}
93
+ */
94
+ function isUnsafeRelativePath(candidatePath) {
95
+ return candidatePath.startsWith("/") || candidatePath === ".." || candidatePath.startsWith("../") || candidatePath.includes("/../");
96
+ }
97
+
98
+ /**
99
+ * @param {string} bucket
100
+ * @param {Record<string, unknown>} candidate
101
+ * @returns {string[]}
102
+ */
103
+ function identityFieldsForBucket(bucket, candidate) {
104
+ if (bucket === "commands") return ["command_id", "id_hint"];
105
+ if (bucket === "routes") {
106
+ if (typeof candidate.method === "string" && typeof candidate.path === "string") {
107
+ return [];
108
+ }
109
+ return ["id_hint", "id"];
110
+ }
111
+ return ["id_hint", "id", "name"];
112
+ }
113
+
114
+ /**
115
+ * @param {string} bucket
116
+ * @param {Record<string, unknown>} candidate
117
+ * @param {string} pathLabel
118
+ * @returns {string[]}
119
+ */
120
+ function validateCandidateIdentity(bucket, candidate, pathLabel) {
121
+ const fields = identityFieldsForBucket(bucket, candidate);
122
+ if (fields.length === 0) return [];
123
+ if (fields.some((field) => typeof candidate[field] === "string" && String(candidate[field]).trim().length > 0)) {
124
+ return [];
125
+ }
126
+ return [`${pathLabel} must include an identity field: ${fields.join(" or ")}`];
127
+ }
128
+
129
+ /**
130
+ * @param {unknown} value
131
+ * @param {string} pathLabel
132
+ * @param {string[]} errors
133
+ * @returns {void}
134
+ */
135
+ function validateNoUnsafeRecords(value, pathLabel, errors) {
136
+ if (Array.isArray(value)) {
137
+ for (let index = 0; index < value.length; index += 1) {
138
+ validateNoUnsafeRecords(value[index], `${pathLabel}[${index}]`, errors);
139
+ }
140
+ return;
141
+ }
142
+ if (!isPlainObject(value)) return;
143
+ for (const [key, child] of Object.entries(value)) {
144
+ const childPath = `${pathLabel}.${key}`;
145
+ if (DISALLOWED_RECORD_KEYS.has(key)) {
146
+ errors.push(`${childPath} is not allowed in extractor candidate output; extractors emit review candidates, not adoption plans or files.`);
147
+ continue;
148
+ }
149
+ if (PATH_KEYS.has(key) && typeof child === "string" && isUnsafeRelativePath(child)) {
150
+ errors.push(`${childPath} must be a safe project-relative path.`);
151
+ continue;
152
+ }
153
+ validateNoUnsafeRecords(child, childPath, errors);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * @param {unknown} result
159
+ * @param {ExtractorResultValidationOptions} [options]
160
+ * @returns {ExtractorResultValidation}
161
+ */
162
+ export function validateExtractorResult(result, options = {}) {
163
+ const errors = [];
164
+ if (!isPlainObject(result)) {
165
+ return { ok: false, errors: ["extract(context) must return an object"], smoke: null };
166
+ }
167
+ if (result.findings != null && !Array.isArray(result.findings)) {
168
+ errors.push("extract(context) findings must be an array when present");
169
+ }
170
+ if (result.diagnostics != null && !Array.isArray(result.diagnostics)) {
171
+ errors.push("extract(context) diagnostics must be an array when present");
172
+ }
173
+ if (!isPlainObject(result.candidates)) {
174
+ errors.push("extract(context) result must include a candidates object");
175
+ return { ok: false, errors, smoke: null };
176
+ }
177
+
178
+ const allowedBuckets = options.track ? TRACK_CANDIDATE_BUCKETS[options.track] : null;
179
+ const candidateKeys = Object.keys(result.candidates);
180
+ for (const [bucket, value] of Object.entries(result.candidates)) {
181
+ const bucketLabel = `extract(context) candidates.${bucket}`;
182
+ if (DISALLOWED_BUCKETS.has(bucket)) {
183
+ errors.push(`${bucketLabel} is not allowed; extractors must not return adoption plans, canonical files, patches, or topo writes.`);
184
+ continue;
185
+ }
186
+ if (options.strictCandidates && allowedBuckets && !allowedBuckets.has(bucket)) {
187
+ errors.push(`${bucketLabel} is not allowed for track '${options.track}'.`);
188
+ continue;
189
+ }
190
+ if (!Array.isArray(value)) {
191
+ errors.push(`${bucketLabel} must be an array`);
192
+ continue;
193
+ }
194
+ if (!options.strictCandidates) continue;
195
+ for (let index = 0; index < value.length; index += 1) {
196
+ const candidate = value[index];
197
+ const candidateLabel = `${bucketLabel}[${index}]`;
198
+ if (!isPlainObject(candidate)) {
199
+ errors.push(`${candidateLabel} must be an object.`);
200
+ continue;
201
+ }
202
+ errors.push(...validateCandidateIdentity(bucket, candidate, candidateLabel));
203
+ validateNoUnsafeRecords(candidate, candidateLabel, errors);
204
+ }
205
+ }
206
+
207
+ return {
208
+ ok: errors.length === 0,
209
+ errors,
210
+ smoke: errors.length === 0
211
+ ? {
212
+ findings: Array.isArray(result.findings) ? result.findings.length : 0,
213
+ candidateKeys: candidateKeys.length,
214
+ diagnostics: Array.isArray(result.diagnostics) ? result.diagnostics.length : 0
215
+ }
216
+ : null
217
+ };
218
+ }
219
+
220
+ export { TRACK_CANDIDATE_BUCKETS };
@@ -457,6 +457,11 @@ npm run check
457
457
  Replace the scaffold adapter in \`index.cjs\` with precise, read-only source evidence.
458
458
  Extractor packages must not mutate source files, write canonical \`topo/**\`, install
459
459
  packages, perform network access, or define adoption semantics.
460
+
461
+ Candidate output is validated by track. Return only review candidate buckets for
462
+ the declared track, give each candidate a stable identity, keep file evidence
463
+ project-relative, and never return files, patches, adoption plans, or write
464
+ instructions.
460
465
  `
461
466
  };
462
467
  for (const [relative, contents] of Object.entries(defaults.fixtureFiles)) {
@@ -3,6 +3,7 @@
3
3
  import { getEnrichersForTrack, getExtractorsForTrack } from "../registry.js";
4
4
  import { normalizeCandidatesForTrack } from "./candidates.js";
5
5
  import { packageExtractorsForContext } from "../../../extractor/packages.js";
6
+ import { validateExtractorResult } from "../../../extractor/output.js";
6
7
 
7
8
  /**
8
9
  * @param {any} context
@@ -140,25 +141,12 @@ function initialCandidatesForTrack(track) {
140
141
  */
141
142
  function assertExtractorResultShape(extractor, result) {
142
143
  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
- }
144
+ const validation = validateExtractorResult(result, {
145
+ track: extractor?.track,
146
+ strictCandidates: extractor?.source === "package"
147
+ });
148
+ if (!validation.ok) {
149
+ throw new Error(validation.errors.map((message) => `Extractor '${label}' ${message}.`).join("\n"));
162
150
  }
163
151
  }
164
152