@genn-inc/cluebase-cli 0.0.1
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/README.md +101 -0
- package/bin/cluebase-cli.mjs +11 -0
- package/package.json +17 -0
- package/src/cli-command.mjs +515 -0
- package/src/cli-invocation.mjs +17 -0
- package/src/code-evidence-analyzer.mjs +2041 -0
- package/src/contracts.mjs +36 -0
- package/src/generated-code-evidence-contract.mjs +22 -0
- package/src/generated-sdk-version-contract.mjs +5 -0
- package/src/generated-source-path-policy.mjs +20 -0
- package/src/lifecycle-guard.mjs +202 -0
- package/src/path-policy.mjs +81 -0
- package/src/setup-ai-contract.mjs +221 -0
- package/src/setup-check-constants.mjs +110 -0
- package/src/setup-check-scan-a.mjs +849 -0
- package/src/setup-check-scan-b.mjs +994 -0
- package/src/setup-check.mjs +575 -0
- package/src/setup-discover-check.mjs +755 -0
- package/src/setup-doctor-deadline.mjs +221 -0
- package/src/setup-doctor-env.mjs +331 -0
- package/src/setup-doctor-file-boundary.mjs +426 -0
- package/src/setup-doctor-probe.mjs +719 -0
- package/src/setup-doctor-quality-checks-a.mjs +593 -0
- package/src/setup-doctor-quality-checks-b.mjs +638 -0
- package/src/setup-doctor-quality-shared.mjs +382 -0
- package/src/setup-doctor-quality.mjs +209 -0
- package/src/setup-doctor-route-scan.mjs +160 -0
- package/src/setup-doctor-sdk-probe.mjs +340 -0
- package/src/setup-doctor.mjs +545 -0
- package/src/setup-documents.mjs +112 -0
- package/src/setup-help.mjs +130 -0
- package/src/setup-prepare.mjs +360 -0
- package/src/setup-repository-discovery.mjs +764 -0
- package/src/setup-step-builders-discover.mjs +701 -0
- package/src/setup-step-builders-events.mjs +229 -0
- package/src/setup-step-builders-implement.mjs +710 -0
- package/src/setup-step-commands.mjs +427 -0
- package/src/setup-tool.mjs +27 -0
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
// setup-discover-check validates `.cluebase/discoveries.json` against
|
|
2
|
+
// `.cluebase/setup-manifest.json` and the repository.
|
|
3
|
+
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { isAbsolute, resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
export const SETUP_DISCOVER_CHECK_EXIT_CODES = Object.freeze({
|
|
9
|
+
PASS: 0,
|
|
10
|
+
GENERIC_ERROR: 1,
|
|
11
|
+
SCHEMA_VIOLATION: 2,
|
|
12
|
+
FILE_NOT_FOUND: 3,
|
|
13
|
+
CARDINALITY_VIOLATION: 4,
|
|
14
|
+
DUPLICATE_SITE: 5,
|
|
15
|
+
SECRETS_LEAK: 6,
|
|
16
|
+
SECRETS_NOT_IGNORED: 7,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const SITE_ARRAY_FIELDS = Object.freeze([
|
|
20
|
+
"identify_sites",
|
|
21
|
+
"group_sites",
|
|
22
|
+
"reset_sites",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const SINGULAR_INIT_FIELDS = Object.freeze([
|
|
26
|
+
"cluebase_init_frontend",
|
|
27
|
+
"cluebase_init_backend",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const isPlainObject = (value) =>
|
|
31
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
32
|
+
|
|
33
|
+
const isPositiveInt = (value) =>
|
|
34
|
+
typeof value === "number" && Number.isInteger(value) && value >= 1;
|
|
35
|
+
|
|
36
|
+
const requireString = (value) => typeof value === "string" && value.length > 0;
|
|
37
|
+
|
|
38
|
+
const requireNonEmptyString = (value) =>
|
|
39
|
+
typeof value === "string" && value.trim().length > 0;
|
|
40
|
+
|
|
41
|
+
const ERROR_CODE_TO_EXIT = Object.freeze({
|
|
42
|
+
SCHEMA_VIOLATION: SETUP_DISCOVER_CHECK_EXIT_CODES.SCHEMA_VIOLATION,
|
|
43
|
+
FRAMEWORK_MISMATCH: SETUP_DISCOVER_CHECK_EXIT_CODES.SCHEMA_VIOLATION,
|
|
44
|
+
SERVICE_KEY_MISMATCH: SETUP_DISCOVER_CHECK_EXIT_CODES.SCHEMA_VIOLATION,
|
|
45
|
+
CARDINALITY_VIOLATION: SETUP_DISCOVER_CHECK_EXIT_CODES.CARDINALITY_VIOLATION,
|
|
46
|
+
FILE_NOT_FOUND: SETUP_DISCOVER_CHECK_EXIT_CODES.FILE_NOT_FOUND,
|
|
47
|
+
LINE_OUT_OF_RANGE: SETUP_DISCOVER_CHECK_EXIT_CODES.FILE_NOT_FOUND,
|
|
48
|
+
ABSOLUTE_PATH: SETUP_DISCOVER_CHECK_EXIT_CODES.FILE_NOT_FOUND,
|
|
49
|
+
FILE_READ_ERROR: SETUP_DISCOVER_CHECK_EXIT_CODES.FILE_NOT_FOUND,
|
|
50
|
+
DUPLICATE_SITE: SETUP_DISCOVER_CHECK_EXIT_CODES.DUPLICATE_SITE,
|
|
51
|
+
SECRETS_LEAK: SETUP_DISCOVER_CHECK_EXIT_CODES.SECRETS_LEAK,
|
|
52
|
+
SECRETS_NOT_IGNORED: SETUP_DISCOVER_CHECK_EXIT_CODES.SECRETS_NOT_IGNORED,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const validateSchema = (discoveries) => {
|
|
56
|
+
const errors = [];
|
|
57
|
+
if (!isPlainObject(discoveries)) {
|
|
58
|
+
errors.push({
|
|
59
|
+
code: "SCHEMA_VIOLATION",
|
|
60
|
+
message: "discoveries must be a JSON object",
|
|
61
|
+
});
|
|
62
|
+
return errors;
|
|
63
|
+
}
|
|
64
|
+
if (!requireString(discoveries.framework_frontend)) {
|
|
65
|
+
errors.push({
|
|
66
|
+
code: "SCHEMA_VIOLATION",
|
|
67
|
+
message: "framework_frontend must be a non-empty string",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (
|
|
71
|
+
discoveries.framework_backend !== null &&
|
|
72
|
+
!requireString(discoveries.framework_backend)
|
|
73
|
+
) {
|
|
74
|
+
errors.push({
|
|
75
|
+
code: "SCHEMA_VIOLATION",
|
|
76
|
+
message: "framework_backend must be a non-empty string or null",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (!requireString(discoveries.service_key)) {
|
|
80
|
+
errors.push({
|
|
81
|
+
code: "SCHEMA_VIOLATION",
|
|
82
|
+
message: "service_key must be a non-empty string",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
for (const field of SINGULAR_INIT_FIELDS) {
|
|
86
|
+
if (!(field in discoveries)) {
|
|
87
|
+
errors.push({
|
|
88
|
+
code: "SCHEMA_VIOLATION",
|
|
89
|
+
message: `${field} is required (object or null)`,
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const value = discoveries[field];
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
errors.push({
|
|
96
|
+
code: "CARDINALITY_VIOLATION",
|
|
97
|
+
message: `${field} must be a single object or null, not an array`,
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (value === null) continue;
|
|
102
|
+
if (!isPlainObject(value)) {
|
|
103
|
+
errors.push({
|
|
104
|
+
code: "SCHEMA_VIOLATION",
|
|
105
|
+
message: `${field} must be an object or null`,
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!requireString(value.file)) {
|
|
110
|
+
errors.push({
|
|
111
|
+
code: "SCHEMA_VIOLATION",
|
|
112
|
+
message: `${field}.file must be a non-empty string`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
if (!isPositiveInt(value.line)) {
|
|
116
|
+
errors.push({
|
|
117
|
+
code: "SCHEMA_VIOLATION",
|
|
118
|
+
message: `${field}.line must be a positive integer`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (!requireString(value.rationale)) {
|
|
122
|
+
errors.push({
|
|
123
|
+
code: "SCHEMA_VIOLATION",
|
|
124
|
+
message: `${field}.rationale must be a non-empty string`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const field of SITE_ARRAY_FIELDS) {
|
|
129
|
+
if (!(field in discoveries)) {
|
|
130
|
+
errors.push({
|
|
131
|
+
code: "SCHEMA_VIOLATION",
|
|
132
|
+
message: `${field} is required (array)`,
|
|
133
|
+
});
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const arr = discoveries[field];
|
|
137
|
+
if (!Array.isArray(arr)) {
|
|
138
|
+
errors.push({
|
|
139
|
+
code: "CARDINALITY_VIOLATION",
|
|
140
|
+
message: `${field} must be an array (even when empty)`,
|
|
141
|
+
});
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
arr.forEach((site, index) => {
|
|
145
|
+
if (!isPlainObject(site)) {
|
|
146
|
+
errors.push({
|
|
147
|
+
code: "SCHEMA_VIOLATION",
|
|
148
|
+
message: `${field}[${index}] must be an object`,
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!requireString(site.file)) {
|
|
153
|
+
errors.push({
|
|
154
|
+
code: "SCHEMA_VIOLATION",
|
|
155
|
+
message: `${field}[${index}].file must be a non-empty string`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (!isPositiveInt(site.line)) {
|
|
159
|
+
errors.push({
|
|
160
|
+
code: "SCHEMA_VIOLATION",
|
|
161
|
+
message: `${field}[${index}].line must be a positive integer`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (!requireString(site.rationale)) {
|
|
165
|
+
errors.push({
|
|
166
|
+
code: "SCHEMA_VIOLATION",
|
|
167
|
+
message: `${field}[${index}].rationale must be a non-empty string`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (!requireNonEmptyString(site.evidence_snippet)) {
|
|
171
|
+
errors.push({
|
|
172
|
+
code: "SCHEMA_VIOLATION",
|
|
173
|
+
message: `${field}[${index}].evidence_snippet must be a non-empty string`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
// `available_fields` / `field_acquisition_notes` 型 check は site 側で
|
|
177
|
+
// value が入っているときだけ確認する (= STEP 3 完了の有無は警告レベル
|
|
178
|
+
// で別途レポート、 ここでは型のみ防御)。 STEP 3 incomplete 自体は
|
|
179
|
+
// buildWarnings の `STEP_3_INCOMPLETE` で検出 (R4-P1-9)。
|
|
180
|
+
//
|
|
181
|
+
// available_fields の形式は STEP 3 prompt の指定通り object (= field name → path expression)。
|
|
182
|
+
// 例: { "id": "data.user.id", "name": "data.user.user_metadata?.name ?? data.user.email" }
|
|
183
|
+
// reset_sites は引数なしなので空 object `{}` を許容。
|
|
184
|
+
if (
|
|
185
|
+
"available_fields" in site &&
|
|
186
|
+
site.available_fields !== null &&
|
|
187
|
+
(typeof site.available_fields !== "object" ||
|
|
188
|
+
Array.isArray(site.available_fields))
|
|
189
|
+
) {
|
|
190
|
+
errors.push({
|
|
191
|
+
code: "SCHEMA_VIOLATION",
|
|
192
|
+
message: `${field}[${index}].available_fields must be a plain object (field name → path string) or null`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (
|
|
196
|
+
"field_acquisition_notes" in site &&
|
|
197
|
+
site.field_acquisition_notes !== null &&
|
|
198
|
+
typeof site.field_acquisition_notes !== "string"
|
|
199
|
+
) {
|
|
200
|
+
errors.push({
|
|
201
|
+
code: "SCHEMA_VIOLATION",
|
|
202
|
+
message: `${field}[${index}].field_acquisition_notes must be a string or null`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (
|
|
208
|
+
"organization_context" in discoveries &&
|
|
209
|
+
discoveries.organization_context !== null &&
|
|
210
|
+
!isPlainObject(discoveries.organization_context)
|
|
211
|
+
) {
|
|
212
|
+
errors.push({
|
|
213
|
+
code: "SCHEMA_VIOLATION",
|
|
214
|
+
message:
|
|
215
|
+
"organization_context must be a plain object or null when present",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (
|
|
219
|
+
"existing_cluebase_calls" in discoveries &&
|
|
220
|
+
!Array.isArray(discoveries.existing_cluebase_calls)
|
|
221
|
+
) {
|
|
222
|
+
errors.push({
|
|
223
|
+
code: "SCHEMA_VIOLATION",
|
|
224
|
+
message: "existing_cluebase_calls must be an array when present",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (
|
|
228
|
+
"unclear_points" in discoveries &&
|
|
229
|
+
!Array.isArray(discoveries.unclear_points)
|
|
230
|
+
) {
|
|
231
|
+
errors.push({
|
|
232
|
+
code: "SCHEMA_VIOLATION",
|
|
233
|
+
message: "unclear_points must be an array when present",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return errors;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const validateManifestConsistency = (discoveries, manifest) => {
|
|
240
|
+
if (!isPlainObject(discoveries) || !isPlainObject(manifest)) return [];
|
|
241
|
+
const errors = [];
|
|
242
|
+
const detected = isPlainObject(manifest.detected) ? manifest.detected : null;
|
|
243
|
+
const detectedFramework = detected?.framework;
|
|
244
|
+
if (
|
|
245
|
+
requireString(detectedFramework) &&
|
|
246
|
+
discoveries.framework_frontend !== detectedFramework &&
|
|
247
|
+
discoveries.framework_backend !== detectedFramework
|
|
248
|
+
) {
|
|
249
|
+
errors.push({
|
|
250
|
+
code: "FRAMEWORK_MISMATCH",
|
|
251
|
+
message: `manifest detected.framework="${detectedFramework}" but discoveries.framework_frontend="${discoveries.framework_frontend}" / framework_backend="${discoveries.framework_backend ?? "null"}"`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const manifestServiceKey = detected?.service_key;
|
|
255
|
+
if (
|
|
256
|
+
requireString(manifestServiceKey) &&
|
|
257
|
+
requireString(discoveries.service_key) &&
|
|
258
|
+
discoveries.service_key !== manifestServiceKey
|
|
259
|
+
) {
|
|
260
|
+
errors.push({
|
|
261
|
+
code: "SERVICE_KEY_MISMATCH",
|
|
262
|
+
message: `manifest service_key="${manifestServiceKey}" but discoveries.service_key="${discoveries.service_key}"`,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return errors;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const collectAllSites = (discoveries) => {
|
|
269
|
+
if (!isPlainObject(discoveries)) return [];
|
|
270
|
+
const sites = [];
|
|
271
|
+
for (const field of SINGULAR_INIT_FIELDS) {
|
|
272
|
+
const value = discoveries[field];
|
|
273
|
+
if (
|
|
274
|
+
isPlainObject(value) &&
|
|
275
|
+
requireString(value.file) &&
|
|
276
|
+
isPositiveInt(value.line)
|
|
277
|
+
) {
|
|
278
|
+
sites.push({
|
|
279
|
+
location: field,
|
|
280
|
+
file: value.file,
|
|
281
|
+
line: value.line,
|
|
282
|
+
// Allow implementation to create new singleton files. When true, file existence
|
|
283
|
+
// and line-count checks are skipped because the file is planned, not
|
|
284
|
+
// present in the repo yet.
|
|
285
|
+
creates_new_file: Boolean(value.creates_new_file),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for (const field of SITE_ARRAY_FIELDS) {
|
|
290
|
+
const arr = discoveries[field];
|
|
291
|
+
if (!Array.isArray(arr)) continue;
|
|
292
|
+
arr.forEach((site, index) => {
|
|
293
|
+
if (
|
|
294
|
+
isPlainObject(site) &&
|
|
295
|
+
requireString(site.file) &&
|
|
296
|
+
isPositiveInt(site.line)
|
|
297
|
+
) {
|
|
298
|
+
sites.push({
|
|
299
|
+
location: `${field}[${index}]`,
|
|
300
|
+
file: site.file,
|
|
301
|
+
line: site.line,
|
|
302
|
+
creates_new_file: false,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return sites;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const validateFileExistence = async ({ discoveries, repoRoot }) => {
|
|
311
|
+
const errors = [];
|
|
312
|
+
const sites = collectAllSites(discoveries);
|
|
313
|
+
for (const site of sites) {
|
|
314
|
+
if (isAbsolute(site.file)) {
|
|
315
|
+
errors.push({
|
|
316
|
+
code: "ABSOLUTE_PATH",
|
|
317
|
+
location: site.location,
|
|
318
|
+
file: site.file,
|
|
319
|
+
message: `${site.location}.file must be a path relative to the repo root, got absolute path "${site.file}"`,
|
|
320
|
+
});
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const absolute = resolve(repoRoot, site.file);
|
|
324
|
+
if (!existsSync(absolute)) {
|
|
325
|
+
if (site.creates_new_file) {
|
|
326
|
+
// Implementation will create this file. Skip the existence and line-range
|
|
327
|
+
// checks but still require a sensible non-absolute relative path.
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
errors.push({
|
|
331
|
+
code: "FILE_NOT_FOUND",
|
|
332
|
+
location: site.location,
|
|
333
|
+
file: site.file,
|
|
334
|
+
message: `${site.location} references "${site.file}" which does not exist in the repository`,
|
|
335
|
+
});
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (site.creates_new_file) {
|
|
339
|
+
// File already exists. The implementation step will edit it instead of creating it.
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
const text = await readFile(absolute, "utf8");
|
|
344
|
+
const lineCount = text.split(/\r?\n/).length;
|
|
345
|
+
if (site.line > lineCount) {
|
|
346
|
+
errors.push({
|
|
347
|
+
code: "LINE_OUT_OF_RANGE",
|
|
348
|
+
location: site.location,
|
|
349
|
+
file: site.file,
|
|
350
|
+
line: site.line,
|
|
351
|
+
message: `${site.location}.line=${site.line} exceeds the line count (${lineCount}) of ${site.file}`,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
} catch (error) {
|
|
355
|
+
errors.push({
|
|
356
|
+
code: "FILE_READ_ERROR",
|
|
357
|
+
location: site.location,
|
|
358
|
+
file: site.file,
|
|
359
|
+
message: `failed to read ${site.file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return errors;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const validateNoDuplicates = (discoveries) => {
|
|
367
|
+
const errors = [];
|
|
368
|
+
const seen = new Map();
|
|
369
|
+
const sitesForDuplicateCheck = [];
|
|
370
|
+
for (const field of SITE_ARRAY_FIELDS) {
|
|
371
|
+
const arr = isPlainObject(discoveries) ? discoveries[field] : null;
|
|
372
|
+
if (!Array.isArray(arr)) continue;
|
|
373
|
+
arr.forEach((site, index) => {
|
|
374
|
+
if (
|
|
375
|
+
isPlainObject(site) &&
|
|
376
|
+
requireString(site.file) &&
|
|
377
|
+
isPositiveInt(site.line)
|
|
378
|
+
) {
|
|
379
|
+
sitesForDuplicateCheck.push({
|
|
380
|
+
location: `${field}[${index}]`,
|
|
381
|
+
file: site.file,
|
|
382
|
+
line: site.line,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
for (const entry of sitesForDuplicateCheck) {
|
|
388
|
+
const key = `${entry.file}:${entry.line}`;
|
|
389
|
+
const previous = seen.get(key);
|
|
390
|
+
if (previous) {
|
|
391
|
+
errors.push({
|
|
392
|
+
code: "DUPLICATE_SITE",
|
|
393
|
+
sites: [previous, entry],
|
|
394
|
+
message: `duplicate site (file, line) "${key}" at ${previous.location} and ${entry.location}`,
|
|
395
|
+
});
|
|
396
|
+
} else {
|
|
397
|
+
seen.set(key, entry);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return errors;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const validateEnvFiles = (discoveries) => {
|
|
404
|
+
if (!isPlainObject(discoveries)) return [];
|
|
405
|
+
if (!("env_files" in discoveries)) return [];
|
|
406
|
+
const errors = [];
|
|
407
|
+
const envFiles = discoveries.env_files;
|
|
408
|
+
if (!isPlainObject(envFiles)) {
|
|
409
|
+
errors.push({
|
|
410
|
+
code: "SCHEMA_VIOLATION",
|
|
411
|
+
message: "env_files must be a plain object",
|
|
412
|
+
});
|
|
413
|
+
return errors;
|
|
414
|
+
}
|
|
415
|
+
for (const kind of ["frontend", "backend"]) {
|
|
416
|
+
if (!(kind in envFiles)) continue;
|
|
417
|
+
const entry = envFiles[kind];
|
|
418
|
+
if (entry === null) continue;
|
|
419
|
+
if (!isPlainObject(entry)) {
|
|
420
|
+
errors.push({
|
|
421
|
+
code: "SCHEMA_VIOLATION",
|
|
422
|
+
message: `env_files.${kind} must be an object or null`,
|
|
423
|
+
});
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (!requireString(entry.path)) {
|
|
427
|
+
errors.push({
|
|
428
|
+
code: "SCHEMA_VIOLATION",
|
|
429
|
+
message: `env_files.${kind}.path must be a non-empty string`,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
if (entry.format && entry.format !== "dotenv") {
|
|
433
|
+
errors.push({
|
|
434
|
+
code: "SCHEMA_VIOLATION",
|
|
435
|
+
message: `env_files.${kind}.format must be "dotenv" (got "${entry.format}")`,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return errors;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// Lines like "NAME=value" where NAME is uppercase ASCII / digits / underscores.
|
|
443
|
+
// Trailing comments / spaces / quotes inside value are allowed.
|
|
444
|
+
const ENV_LINE_PATTERN = /^[A-Z_][A-Z0-9_]*=/;
|
|
445
|
+
const SECRETS_LEAK_PATTERNS = [
|
|
446
|
+
/^CLUEBASE_API_KEY\s*=/,
|
|
447
|
+
/^(?:NEXT_PUBLIC|VITE|PUBLIC|NUXT_PUBLIC|REACT_APP)_CLUEBASE_API_KEY\s*=/,
|
|
448
|
+
];
|
|
449
|
+
|
|
450
|
+
const validateEnvLines = (discoveries) => {
|
|
451
|
+
if (!isPlainObject(discoveries)) return [];
|
|
452
|
+
if (!("env_lines" in discoveries)) return [];
|
|
453
|
+
const errors = [];
|
|
454
|
+
const envLines = discoveries.env_lines;
|
|
455
|
+
if (!isPlainObject(envLines)) {
|
|
456
|
+
errors.push({
|
|
457
|
+
code: "SCHEMA_VIOLATION",
|
|
458
|
+
message: "env_lines must be a plain object",
|
|
459
|
+
});
|
|
460
|
+
return errors;
|
|
461
|
+
}
|
|
462
|
+
for (const kind of ["frontend", "backend"]) {
|
|
463
|
+
if (!(kind in envLines)) continue;
|
|
464
|
+
const arr = envLines[kind];
|
|
465
|
+
if (!Array.isArray(arr)) {
|
|
466
|
+
errors.push({
|
|
467
|
+
code: "SCHEMA_VIOLATION",
|
|
468
|
+
message: `env_lines.${kind} must be an array`,
|
|
469
|
+
});
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
arr.forEach((line, idx) => {
|
|
473
|
+
if (typeof line !== "string") {
|
|
474
|
+
errors.push({
|
|
475
|
+
code: "SCHEMA_VIOLATION",
|
|
476
|
+
message: `env_lines.${kind}[${idx}] must be a string`,
|
|
477
|
+
});
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (!ENV_LINE_PATTERN.test(line)) {
|
|
481
|
+
errors.push({
|
|
482
|
+
code: "SCHEMA_VIOLATION",
|
|
483
|
+
message: `env_lines.${kind}[${idx}] must be a "NAME=value" pair (got "${line.slice(0, 40)}...")`,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
// Browser-facing env files must never carry server-only secrets.
|
|
488
|
+
if (kind === "frontend") {
|
|
489
|
+
arr.forEach((line, idx) => {
|
|
490
|
+
if (typeof line !== "string") return;
|
|
491
|
+
for (const pattern of SECRETS_LEAK_PATTERNS) {
|
|
492
|
+
if (pattern.test(line)) {
|
|
493
|
+
errors.push({
|
|
494
|
+
code: "SECRETS_LEAK",
|
|
495
|
+
message: `env_lines.frontend[${idx}] contains a server-only secret (matched ${pattern}); never expose it via a browser-public env file`,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return errors;
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const validateSecretsGitignore = async ({ repoRoot }) => {
|
|
506
|
+
const errors = [];
|
|
507
|
+
const secretsPath = resolve(repoRoot, ".cluebase/secrets.json");
|
|
508
|
+
if (!existsSync(secretsPath)) {
|
|
509
|
+
// secrets.json absent simply means STEP 5 will not have a CLUEBASE_API_KEY
|
|
510
|
+
// value to auto-write. We surface this in `buildWarnings`, not here.
|
|
511
|
+
return errors;
|
|
512
|
+
}
|
|
513
|
+
const gitignorePath = resolve(repoRoot, ".gitignore");
|
|
514
|
+
if (!existsSync(gitignorePath)) {
|
|
515
|
+
errors.push({
|
|
516
|
+
code: "SECRETS_NOT_IGNORED",
|
|
517
|
+
message:
|
|
518
|
+
".cluebase/secrets.json exists but the repo has no .gitignore. Add `.cluebase/secrets.json` to .gitignore before committing — otherwise the Cluebase API key will leak to the remote.",
|
|
519
|
+
});
|
|
520
|
+
return errors;
|
|
521
|
+
}
|
|
522
|
+
let gitignoreContent = "";
|
|
523
|
+
try {
|
|
524
|
+
gitignoreContent = await readFile(gitignorePath, "utf8");
|
|
525
|
+
} catch (error) {
|
|
526
|
+
errors.push({
|
|
527
|
+
code: "FILE_READ_ERROR",
|
|
528
|
+
message: `failed to read .gitignore: ${error instanceof Error ? error.message : String(error)}`,
|
|
529
|
+
});
|
|
530
|
+
return errors;
|
|
531
|
+
}
|
|
532
|
+
const covers = gitignoreContent
|
|
533
|
+
.split(/\r?\n/)
|
|
534
|
+
.map((entry) => entry.trim())
|
|
535
|
+
.some(
|
|
536
|
+
(entry) =>
|
|
537
|
+
entry === ".cluebase/secrets.json" ||
|
|
538
|
+
entry === "/.cluebase/secrets.json" ||
|
|
539
|
+
entry === ".cluebase" ||
|
|
540
|
+
entry === ".cluebase/" ||
|
|
541
|
+
entry === ".cluebase/*" ||
|
|
542
|
+
entry === "/.cluebase" ||
|
|
543
|
+
entry === "/.cluebase/" ||
|
|
544
|
+
entry === "/.cluebase/*",
|
|
545
|
+
);
|
|
546
|
+
if (!covers) {
|
|
547
|
+
errors.push({
|
|
548
|
+
code: "SECRETS_NOT_IGNORED",
|
|
549
|
+
message:
|
|
550
|
+
".cluebase/secrets.json exists but is NOT registered in .gitignore. Add `.cluebase/secrets.json` to .gitignore before committing — otherwise the Cluebase API key will leak.",
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return errors;
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const buildWarnings = (discoveries, { repoRoot } = {}) => {
|
|
557
|
+
if (!isPlainObject(discoveries)) return [];
|
|
558
|
+
const warnings = [];
|
|
559
|
+
if (
|
|
560
|
+
Array.isArray(discoveries.unclear_points) &&
|
|
561
|
+
discoveries.unclear_points.length > 0
|
|
562
|
+
) {
|
|
563
|
+
warnings.push({
|
|
564
|
+
code: "UNCLEAR_POINTS_REMAINING",
|
|
565
|
+
count: discoveries.unclear_points.length,
|
|
566
|
+
message: `Discovery reported ${discoveries.unclear_points.length} unclear point(s). Review and resolve before implementation.`,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
if (
|
|
570
|
+
Array.isArray(discoveries.existing_cluebase_calls) &&
|
|
571
|
+
discoveries.existing_cluebase_calls.length > 0
|
|
572
|
+
) {
|
|
573
|
+
warnings.push({
|
|
574
|
+
code: "EXISTING_CLUEBASE_CALLS",
|
|
575
|
+
count: discoveries.existing_cluebase_calls.length,
|
|
576
|
+
message: `${discoveries.existing_cluebase_calls.length} existing Cluebase call(s) detected. Implementation must not duplicate them.`,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
// STEP 3 は各 site に `available_fields` / `field_acquisition_notes` を、
|
|
580
|
+
// discoveries root に `organization_context` を追記する責務。STEP 5 は
|
|
581
|
+
// これらに基づいて cluebase.identify / cluebase.group の有効 trait を決める。
|
|
582
|
+
const allSites = [
|
|
583
|
+
...(Array.isArray(discoveries.frontend_sites)
|
|
584
|
+
? discoveries.frontend_sites
|
|
585
|
+
: []),
|
|
586
|
+
...(Array.isArray(discoveries.backend_sites)
|
|
587
|
+
? discoveries.backend_sites
|
|
588
|
+
: []),
|
|
589
|
+
];
|
|
590
|
+
const sitesMissingAvailableFields = allSites.filter(
|
|
591
|
+
(site) => isPlainObject(site) && !("available_fields" in site),
|
|
592
|
+
);
|
|
593
|
+
const sitesMissingFieldAcquisitionNotes = allSites.filter(
|
|
594
|
+
(site) => isPlainObject(site) && !("field_acquisition_notes" in site),
|
|
595
|
+
);
|
|
596
|
+
const missingOrganizationContext = !("organization_context" in discoveries);
|
|
597
|
+
const step3Incomplete =
|
|
598
|
+
sitesMissingAvailableFields.length > 0 ||
|
|
599
|
+
sitesMissingFieldAcquisitionNotes.length > 0 ||
|
|
600
|
+
missingOrganizationContext;
|
|
601
|
+
if (step3Incomplete) {
|
|
602
|
+
const missingFields = [];
|
|
603
|
+
if (sitesMissingAvailableFields.length > 0) {
|
|
604
|
+
missingFields.push(
|
|
605
|
+
`available_fields (sites=${sitesMissingAvailableFields.length})`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
if (sitesMissingFieldAcquisitionNotes.length > 0) {
|
|
609
|
+
missingFields.push(
|
|
610
|
+
`field_acquisition_notes (sites=${sitesMissingFieldAcquisitionNotes.length})`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
if (missingOrganizationContext) {
|
|
614
|
+
missingFields.push("organization_context");
|
|
615
|
+
}
|
|
616
|
+
warnings.push({
|
|
617
|
+
code: "STEP_3_INCOMPLETE",
|
|
618
|
+
missing: missingFields,
|
|
619
|
+
message: `STEP 3 context outputs are missing: ${missingFields.join(", ")}. /cluebase-implement will hard stop without them — re-run /cluebase-discover so it can resume from step3_context.`,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
// SECRETS_FILE_MISSING: STEP 5 が CLUEBASE_API_KEY を env file に auto-write
|
|
623
|
+
// するための値の source (= `.cluebase/secrets.json`) が無い場合。
|
|
624
|
+
if (repoRoot) {
|
|
625
|
+
const secretsPath = resolve(repoRoot, ".cluebase/secrets.json");
|
|
626
|
+
if (!existsSync(secretsPath)) {
|
|
627
|
+
warnings.push({
|
|
628
|
+
code: "SECRETS_FILE_MISSING",
|
|
629
|
+
message:
|
|
630
|
+
".cluebase/secrets.json was not generated. STEP 5 will not auto-write CLUEBASE_API_KEY into the backend env file. Re-run `npx -y @genn-inc/cluebase-cli setup ... --cluebase-api-key <key>` with the value from the setup screen, or set CLUEBASE_API_KEY manually in the backend env file.",
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return warnings;
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const pickExitCode = (errors) => {
|
|
638
|
+
if (errors.length === 0) return SETUP_DISCOVER_CHECK_EXIT_CODES.PASS;
|
|
639
|
+
let resolvedExit = SETUP_DISCOVER_CHECK_EXIT_CODES.GENERIC_ERROR;
|
|
640
|
+
for (const error of errors) {
|
|
641
|
+
const mapped =
|
|
642
|
+
ERROR_CODE_TO_EXIT[error.code] ??
|
|
643
|
+
SETUP_DISCOVER_CHECK_EXIT_CODES.GENERIC_ERROR;
|
|
644
|
+
if (mapped > resolvedExit) resolvedExit = mapped;
|
|
645
|
+
}
|
|
646
|
+
return resolvedExit;
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
// Exported for tests so the validation logic can be exercised without the
|
|
650
|
+
// CLI's filesystem and process integration.
|
|
651
|
+
export const validateDiscoveries = async ({
|
|
652
|
+
discoveries,
|
|
653
|
+
manifest = null,
|
|
654
|
+
repoRoot,
|
|
655
|
+
}) => {
|
|
656
|
+
const errors = [];
|
|
657
|
+
const schemaErrors = validateSchema(discoveries);
|
|
658
|
+
errors.push(...schemaErrors);
|
|
659
|
+
// env_files / env_lines は type check のみで、不在は warning レベル
|
|
660
|
+
// (= buildWarnings) で扱う。
|
|
661
|
+
// SECRETS_LEAK だけは P0 error (= browser env に CLUEBASE_API_KEY が混入したら setup 不可)。
|
|
662
|
+
errors.push(...validateEnvFiles(discoveries));
|
|
663
|
+
errors.push(...validateEnvLines(discoveries));
|
|
664
|
+
if (schemaErrors.length === 0) {
|
|
665
|
+
errors.push(...validateManifestConsistency(discoveries, manifest));
|
|
666
|
+
errors.push(...(await validateFileExistence({ discoveries, repoRoot })));
|
|
667
|
+
errors.push(...validateNoDuplicates(discoveries));
|
|
668
|
+
errors.push(...(await validateSecretsGitignore({ repoRoot })));
|
|
669
|
+
}
|
|
670
|
+
const warnings = buildWarnings(discoveries, { repoRoot });
|
|
671
|
+
const passed = errors.length === 0;
|
|
672
|
+
return {
|
|
673
|
+
passed,
|
|
674
|
+
errors,
|
|
675
|
+
warnings,
|
|
676
|
+
exit_code: pickExitCode(errors),
|
|
677
|
+
};
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const readJsonFile = async (path) => JSON.parse(await readFile(path, "utf8"));
|
|
681
|
+
|
|
682
|
+
// Public entrypoint used by bin/cluebase-cli.mjs.
|
|
683
|
+
export const runSetupDiscoverCheck = async ({
|
|
684
|
+
manifestPath,
|
|
685
|
+
discoveriesPath,
|
|
686
|
+
repoRoot,
|
|
687
|
+
}) => {
|
|
688
|
+
let manifest = null;
|
|
689
|
+
let manifestReadError = null;
|
|
690
|
+
if (requireString(manifestPath)) {
|
|
691
|
+
const resolvedManifestPath = isAbsolute(manifestPath)
|
|
692
|
+
? manifestPath
|
|
693
|
+
: resolve(repoRoot, manifestPath);
|
|
694
|
+
try {
|
|
695
|
+
manifest = await readJsonFile(resolvedManifestPath);
|
|
696
|
+
} catch (error) {
|
|
697
|
+
manifestReadError = error;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (!requireString(discoveriesPath)) {
|
|
701
|
+
return {
|
|
702
|
+
passed: false,
|
|
703
|
+
errors: [
|
|
704
|
+
{
|
|
705
|
+
code: "GENERIC_ERROR",
|
|
706
|
+
message: "--discoveries path is required",
|
|
707
|
+
},
|
|
708
|
+
],
|
|
709
|
+
warnings: [],
|
|
710
|
+
exit_code: SETUP_DISCOVER_CHECK_EXIT_CODES.GENERIC_ERROR,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
const resolvedDiscoveriesPath = isAbsolute(discoveriesPath)
|
|
714
|
+
? discoveriesPath
|
|
715
|
+
: resolve(repoRoot, discoveriesPath);
|
|
716
|
+
let discoveries;
|
|
717
|
+
try {
|
|
718
|
+
discoveries = await readJsonFile(resolvedDiscoveriesPath);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
return {
|
|
721
|
+
passed: false,
|
|
722
|
+
errors: [
|
|
723
|
+
{
|
|
724
|
+
code: "GENERIC_ERROR",
|
|
725
|
+
message: `failed to read discoveries JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
726
|
+
},
|
|
727
|
+
],
|
|
728
|
+
warnings: [],
|
|
729
|
+
exit_code: SETUP_DISCOVER_CHECK_EXIT_CODES.GENERIC_ERROR,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
const result = await validateDiscoveries({
|
|
733
|
+
discoveries,
|
|
734
|
+
manifest,
|
|
735
|
+
repoRoot,
|
|
736
|
+
});
|
|
737
|
+
if (manifestReadError) {
|
|
738
|
+
return {
|
|
739
|
+
...result,
|
|
740
|
+
errors: [
|
|
741
|
+
{
|
|
742
|
+
code: "GENERIC_ERROR",
|
|
743
|
+
message: `failed to read manifest JSON: ${manifestReadError instanceof Error ? manifestReadError.message : String(manifestReadError)}`,
|
|
744
|
+
},
|
|
745
|
+
...result.errors,
|
|
746
|
+
],
|
|
747
|
+
passed: false,
|
|
748
|
+
exit_code: Math.max(
|
|
749
|
+
result.exit_code,
|
|
750
|
+
SETUP_DISCOVER_CHECK_EXIT_CODES.GENERIC_ERROR,
|
|
751
|
+
),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
return result;
|
|
755
|
+
};
|