@ipation/specbridge 0.2.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/CHANGELOG.md +173 -0
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +3315 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +1802 -0
- package/dist/index.js +3059 -0
- package/dist/index.js.map +1 -0
- package/package.json +101 -0
- package/templates/decision.template.yaml +82 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3059 @@
|
|
|
1
|
+
// src/core/schemas/decision.schema.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var DecisionStatusSchema = z.enum(["draft", "active", "deprecated", "superseded"]);
|
|
4
|
+
var ConstraintTypeSchema = z.enum(["invariant", "convention", "guideline"]);
|
|
5
|
+
var SeveritySchema = z.enum(["critical", "high", "medium", "low"]);
|
|
6
|
+
var VerificationFrequencySchema = z.enum(["commit", "pr", "daily", "weekly"]);
|
|
7
|
+
var DecisionMetadataSchema = z.object({
|
|
8
|
+
id: z.string().min(1).regex(/^[a-z0-9-]+$/, "ID must be lowercase alphanumeric with hyphens"),
|
|
9
|
+
title: z.string().min(1).max(200),
|
|
10
|
+
status: DecisionStatusSchema,
|
|
11
|
+
owners: z.array(z.string().min(1)).min(1),
|
|
12
|
+
createdAt: z.string().datetime().optional(),
|
|
13
|
+
updatedAt: z.string().datetime().optional(),
|
|
14
|
+
supersededBy: z.string().optional(),
|
|
15
|
+
tags: z.array(z.string()).optional()
|
|
16
|
+
});
|
|
17
|
+
var DecisionContentSchema = z.object({
|
|
18
|
+
summary: z.string().min(1).max(500),
|
|
19
|
+
rationale: z.string().min(1),
|
|
20
|
+
context: z.string().optional(),
|
|
21
|
+
consequences: z.array(z.string()).optional()
|
|
22
|
+
});
|
|
23
|
+
var ConstraintExceptionSchema = z.object({
|
|
24
|
+
pattern: z.string().min(1),
|
|
25
|
+
reason: z.string().min(1),
|
|
26
|
+
approvedBy: z.string().optional(),
|
|
27
|
+
expiresAt: z.string().datetime().optional()
|
|
28
|
+
});
|
|
29
|
+
var ConstraintSchema = z.object({
|
|
30
|
+
id: z.string().min(1).regex(/^[a-z0-9-]+$/, "Constraint ID must be lowercase alphanumeric with hyphens"),
|
|
31
|
+
type: ConstraintTypeSchema,
|
|
32
|
+
rule: z.string().min(1),
|
|
33
|
+
severity: SeveritySchema,
|
|
34
|
+
scope: z.string().min(1),
|
|
35
|
+
verifier: z.string().optional(),
|
|
36
|
+
autofix: z.boolean().optional(),
|
|
37
|
+
exceptions: z.array(ConstraintExceptionSchema).optional()
|
|
38
|
+
});
|
|
39
|
+
var VerificationConfigSchema = z.object({
|
|
40
|
+
check: z.string().min(1),
|
|
41
|
+
target: z.string().min(1),
|
|
42
|
+
frequency: VerificationFrequencySchema,
|
|
43
|
+
timeout: z.number().positive().optional()
|
|
44
|
+
});
|
|
45
|
+
var LinksSchema = z.object({
|
|
46
|
+
related: z.array(z.string()).optional(),
|
|
47
|
+
supersedes: z.array(z.string()).optional(),
|
|
48
|
+
references: z.array(z.string().url()).optional()
|
|
49
|
+
});
|
|
50
|
+
var DecisionSchema = z.object({
|
|
51
|
+
kind: z.literal("Decision"),
|
|
52
|
+
metadata: DecisionMetadataSchema,
|
|
53
|
+
decision: DecisionContentSchema,
|
|
54
|
+
constraints: z.array(ConstraintSchema).min(1),
|
|
55
|
+
verification: z.object({
|
|
56
|
+
automated: z.array(VerificationConfigSchema).optional()
|
|
57
|
+
}).optional(),
|
|
58
|
+
links: LinksSchema.optional()
|
|
59
|
+
});
|
|
60
|
+
function validateDecision(data) {
|
|
61
|
+
const result = DecisionSchema.safeParse(data);
|
|
62
|
+
if (result.success) {
|
|
63
|
+
return { success: true, data: result.data };
|
|
64
|
+
}
|
|
65
|
+
return { success: false, errors: result.error };
|
|
66
|
+
}
|
|
67
|
+
function formatValidationErrors(errors) {
|
|
68
|
+
return errors.errors.map((err) => {
|
|
69
|
+
const path = err.path.join(".");
|
|
70
|
+
return `${path}: ${err.message}`;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/core/schemas/config.schema.ts
|
|
75
|
+
import { z as z2 } from "zod";
|
|
76
|
+
var SeveritySchema2 = z2.enum(["critical", "high", "medium", "low"]);
|
|
77
|
+
var LevelConfigSchema = z2.object({
|
|
78
|
+
timeout: z2.number().positive().optional(),
|
|
79
|
+
severity: z2.array(SeveritySchema2).optional()
|
|
80
|
+
});
|
|
81
|
+
var ProjectConfigSchema = z2.object({
|
|
82
|
+
name: z2.string().min(1),
|
|
83
|
+
sourceRoots: z2.array(z2.string().min(1)).min(1),
|
|
84
|
+
exclude: z2.array(z2.string()).optional()
|
|
85
|
+
});
|
|
86
|
+
var InferenceConfigSchema = z2.object({
|
|
87
|
+
minConfidence: z2.number().min(0).max(100).optional(),
|
|
88
|
+
analyzers: z2.array(z2.string()).optional()
|
|
89
|
+
});
|
|
90
|
+
var VerificationConfigSchema2 = z2.object({
|
|
91
|
+
levels: z2.object({
|
|
92
|
+
commit: LevelConfigSchema.optional(),
|
|
93
|
+
pr: LevelConfigSchema.optional(),
|
|
94
|
+
full: LevelConfigSchema.optional()
|
|
95
|
+
}).optional()
|
|
96
|
+
});
|
|
97
|
+
var AgentConfigSchema = z2.object({
|
|
98
|
+
format: z2.enum(["markdown", "json", "mcp"]).optional(),
|
|
99
|
+
includeRationale: z2.boolean().optional()
|
|
100
|
+
});
|
|
101
|
+
var SpecBridgeConfigSchema = z2.object({
|
|
102
|
+
version: z2.string().regex(/^\d+\.\d+$/, "Version must be in format X.Y"),
|
|
103
|
+
project: ProjectConfigSchema,
|
|
104
|
+
inference: InferenceConfigSchema.optional(),
|
|
105
|
+
verification: VerificationConfigSchema2.optional(),
|
|
106
|
+
agent: AgentConfigSchema.optional()
|
|
107
|
+
});
|
|
108
|
+
function validateConfig(data) {
|
|
109
|
+
const result = SpecBridgeConfigSchema.safeParse(data);
|
|
110
|
+
if (result.success) {
|
|
111
|
+
return { success: true, data: result.data };
|
|
112
|
+
}
|
|
113
|
+
return { success: false, errors: result.error };
|
|
114
|
+
}
|
|
115
|
+
var defaultConfig = {
|
|
116
|
+
version: "1.0",
|
|
117
|
+
project: {
|
|
118
|
+
name: "my-project",
|
|
119
|
+
sourceRoots: ["src/**/*.ts", "src/**/*.tsx"],
|
|
120
|
+
exclude: ["**/*.test.ts", "**/*.spec.ts", "**/node_modules/**", "**/dist/**"]
|
|
121
|
+
},
|
|
122
|
+
inference: {
|
|
123
|
+
minConfidence: 70,
|
|
124
|
+
analyzers: ["naming", "structure", "imports", "errors"]
|
|
125
|
+
},
|
|
126
|
+
verification: {
|
|
127
|
+
levels: {
|
|
128
|
+
commit: {
|
|
129
|
+
timeout: 5e3,
|
|
130
|
+
severity: ["critical"]
|
|
131
|
+
},
|
|
132
|
+
pr: {
|
|
133
|
+
timeout: 6e4,
|
|
134
|
+
severity: ["critical", "high"]
|
|
135
|
+
},
|
|
136
|
+
full: {
|
|
137
|
+
timeout: 3e5,
|
|
138
|
+
severity: ["critical", "high", "medium", "low"]
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
agent: {
|
|
143
|
+
format: "markdown",
|
|
144
|
+
includeRationale: true
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// src/core/errors/index.ts
|
|
149
|
+
var SpecBridgeError = class extends Error {
|
|
150
|
+
constructor(message, code, details) {
|
|
151
|
+
super(message);
|
|
152
|
+
this.code = code;
|
|
153
|
+
this.details = details;
|
|
154
|
+
this.name = "SpecBridgeError";
|
|
155
|
+
Error.captureStackTrace(this, this.constructor);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
var ConfigError = class extends SpecBridgeError {
|
|
159
|
+
constructor(message, details) {
|
|
160
|
+
super(message, "CONFIG_ERROR", details);
|
|
161
|
+
this.name = "ConfigError";
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
var DecisionValidationError = class extends SpecBridgeError {
|
|
165
|
+
constructor(message, decisionId, validationErrors) {
|
|
166
|
+
super(message, "DECISION_VALIDATION_ERROR", { decisionId, validationErrors });
|
|
167
|
+
this.decisionId = decisionId;
|
|
168
|
+
this.validationErrors = validationErrors;
|
|
169
|
+
this.name = "DecisionValidationError";
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
var DecisionNotFoundError = class extends SpecBridgeError {
|
|
173
|
+
constructor(decisionId) {
|
|
174
|
+
super(`Decision not found: ${decisionId}`, "DECISION_NOT_FOUND", { decisionId });
|
|
175
|
+
this.name = "DecisionNotFoundError";
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
var RegistryError = class extends SpecBridgeError {
|
|
179
|
+
constructor(message, details) {
|
|
180
|
+
super(message, "REGISTRY_ERROR", details);
|
|
181
|
+
this.name = "RegistryError";
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
var VerificationError = class extends SpecBridgeError {
|
|
185
|
+
constructor(message, details) {
|
|
186
|
+
super(message, "VERIFICATION_ERROR", details);
|
|
187
|
+
this.name = "VerificationError";
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
var InferenceError = class extends SpecBridgeError {
|
|
191
|
+
constructor(message, details) {
|
|
192
|
+
super(message, "INFERENCE_ERROR", details);
|
|
193
|
+
this.name = "InferenceError";
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
var FileSystemError = class extends SpecBridgeError {
|
|
197
|
+
constructor(message, path) {
|
|
198
|
+
super(message, "FILE_SYSTEM_ERROR", { path });
|
|
199
|
+
this.path = path;
|
|
200
|
+
this.name = "FileSystemError";
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
var AlreadyInitializedError = class extends SpecBridgeError {
|
|
204
|
+
constructor(path) {
|
|
205
|
+
super(`SpecBridge is already initialized at ${path}`, "ALREADY_INITIALIZED", { path });
|
|
206
|
+
this.name = "AlreadyInitializedError";
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
var NotInitializedError = class extends SpecBridgeError {
|
|
210
|
+
constructor() {
|
|
211
|
+
super(
|
|
212
|
+
'SpecBridge is not initialized. Run "specbridge init" first.',
|
|
213
|
+
"NOT_INITIALIZED"
|
|
214
|
+
);
|
|
215
|
+
this.name = "NotInitializedError";
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
var VerifierNotFoundError = class extends SpecBridgeError {
|
|
219
|
+
constructor(verifierId) {
|
|
220
|
+
super(`Verifier not found: ${verifierId}`, "VERIFIER_NOT_FOUND", { verifierId });
|
|
221
|
+
this.name = "VerifierNotFoundError";
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
var AnalyzerNotFoundError = class extends SpecBridgeError {
|
|
225
|
+
constructor(analyzerId) {
|
|
226
|
+
super(`Analyzer not found: ${analyzerId}`, "ANALYZER_NOT_FOUND", { analyzerId });
|
|
227
|
+
this.name = "AnalyzerNotFoundError";
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
var HookError = class extends SpecBridgeError {
|
|
231
|
+
constructor(message, details) {
|
|
232
|
+
super(message, "HOOK_ERROR", details);
|
|
233
|
+
this.name = "HookError";
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
function formatError(error) {
|
|
237
|
+
if (error instanceof SpecBridgeError) {
|
|
238
|
+
let message = `Error [${error.code}]: ${error.message}`;
|
|
239
|
+
if (error.details) {
|
|
240
|
+
const detailsStr = Object.entries(error.details).filter(([key]) => key !== "validationErrors").map(([key, value]) => ` ${key}: ${value}`).join("\n");
|
|
241
|
+
if (detailsStr) {
|
|
242
|
+
message += `
|
|
243
|
+
${detailsStr}`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (error instanceof DecisionValidationError && error.validationErrors.length > 0) {
|
|
247
|
+
message += "\nValidation errors:\n" + error.validationErrors.map((e) => ` - ${e}`).join("\n");
|
|
248
|
+
}
|
|
249
|
+
return message;
|
|
250
|
+
}
|
|
251
|
+
return `Error: ${error.message}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/utils/fs.ts
|
|
255
|
+
import { readFile, writeFile, mkdir, access, readdir, stat } from "fs/promises";
|
|
256
|
+
import { join, dirname } from "path";
|
|
257
|
+
import { constants } from "fs";
|
|
258
|
+
async function pathExists(path) {
|
|
259
|
+
try {
|
|
260
|
+
await access(path, constants.F_OK);
|
|
261
|
+
return true;
|
|
262
|
+
} catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async function isDirectory(path) {
|
|
267
|
+
try {
|
|
268
|
+
const stats = await stat(path);
|
|
269
|
+
return stats.isDirectory();
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async function ensureDir(path) {
|
|
275
|
+
await mkdir(path, { recursive: true });
|
|
276
|
+
}
|
|
277
|
+
async function readTextFile(path) {
|
|
278
|
+
return readFile(path, "utf-8");
|
|
279
|
+
}
|
|
280
|
+
async function writeTextFile(path, content) {
|
|
281
|
+
await ensureDir(dirname(path));
|
|
282
|
+
await writeFile(path, content, "utf-8");
|
|
283
|
+
}
|
|
284
|
+
async function readFilesInDir(dirPath, filter) {
|
|
285
|
+
try {
|
|
286
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
287
|
+
const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
288
|
+
if (filter) {
|
|
289
|
+
return files.filter(filter);
|
|
290
|
+
}
|
|
291
|
+
return files;
|
|
292
|
+
} catch {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function getSpecBridgeDir(basePath = process.cwd()) {
|
|
297
|
+
return join(basePath, ".specbridge");
|
|
298
|
+
}
|
|
299
|
+
function getDecisionsDir(basePath = process.cwd()) {
|
|
300
|
+
return join(getSpecBridgeDir(basePath), "decisions");
|
|
301
|
+
}
|
|
302
|
+
function getVerifiersDir(basePath = process.cwd()) {
|
|
303
|
+
return join(getSpecBridgeDir(basePath), "verifiers");
|
|
304
|
+
}
|
|
305
|
+
function getInferredDir(basePath = process.cwd()) {
|
|
306
|
+
return join(getSpecBridgeDir(basePath), "inferred");
|
|
307
|
+
}
|
|
308
|
+
function getReportsDir(basePath = process.cwd()) {
|
|
309
|
+
return join(getSpecBridgeDir(basePath), "reports");
|
|
310
|
+
}
|
|
311
|
+
function getConfigPath(basePath = process.cwd()) {
|
|
312
|
+
return join(getSpecBridgeDir(basePath), "config.yaml");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/utils/yaml.ts
|
|
316
|
+
import { parse, stringify, parseDocument } from "yaml";
|
|
317
|
+
function parseYaml(content) {
|
|
318
|
+
return parse(content);
|
|
319
|
+
}
|
|
320
|
+
function stringifyYaml(data, options) {
|
|
321
|
+
return stringify(data, {
|
|
322
|
+
indent: options?.indent ?? 2,
|
|
323
|
+
lineWidth: 100,
|
|
324
|
+
minContentWidth: 20
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
function parseYamlDocument(content) {
|
|
328
|
+
return parseDocument(content);
|
|
329
|
+
}
|
|
330
|
+
function updateYamlDocument(doc, path, value) {
|
|
331
|
+
let current = doc.contents;
|
|
332
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
333
|
+
const key = path[i];
|
|
334
|
+
if (key && current && typeof current === "object" && "get" in current) {
|
|
335
|
+
current = current.get(key);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const lastKey = path[path.length - 1];
|
|
339
|
+
if (lastKey && current && typeof current === "object" && "set" in current) {
|
|
340
|
+
current.set(lastKey, value);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/config/loader.ts
|
|
345
|
+
async function loadConfig(basePath = process.cwd()) {
|
|
346
|
+
const specbridgeDir = getSpecBridgeDir(basePath);
|
|
347
|
+
const configPath = getConfigPath(basePath);
|
|
348
|
+
if (!await pathExists(specbridgeDir)) {
|
|
349
|
+
throw new NotInitializedError();
|
|
350
|
+
}
|
|
351
|
+
if (!await pathExists(configPath)) {
|
|
352
|
+
return defaultConfig;
|
|
353
|
+
}
|
|
354
|
+
const content = await readTextFile(configPath);
|
|
355
|
+
const parsed = parseYaml(content);
|
|
356
|
+
const result = validateConfig(parsed);
|
|
357
|
+
if (!result.success) {
|
|
358
|
+
const errors = result.errors.errors.map((e) => `${e.path.join(".")}: ${e.message}`);
|
|
359
|
+
throw new ConfigError(`Invalid configuration in ${configPath}`, { errors });
|
|
360
|
+
}
|
|
361
|
+
return result.data;
|
|
362
|
+
}
|
|
363
|
+
function mergeWithDefaults(partial) {
|
|
364
|
+
return {
|
|
365
|
+
...defaultConfig,
|
|
366
|
+
...partial,
|
|
367
|
+
project: {
|
|
368
|
+
...defaultConfig.project,
|
|
369
|
+
...partial.project
|
|
370
|
+
},
|
|
371
|
+
inference: {
|
|
372
|
+
...defaultConfig.inference,
|
|
373
|
+
...partial.inference
|
|
374
|
+
},
|
|
375
|
+
verification: {
|
|
376
|
+
...defaultConfig.verification,
|
|
377
|
+
...partial.verification,
|
|
378
|
+
levels: {
|
|
379
|
+
...defaultConfig.verification?.levels,
|
|
380
|
+
...partial.verification?.levels
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
agent: {
|
|
384
|
+
...defaultConfig.agent,
|
|
385
|
+
...partial.agent
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// src/registry/loader.ts
|
|
391
|
+
import { join as join2 } from "path";
|
|
392
|
+
async function loadDecisionFile(filePath) {
|
|
393
|
+
if (!await pathExists(filePath)) {
|
|
394
|
+
throw new FileSystemError(`Decision file not found: ${filePath}`, filePath);
|
|
395
|
+
}
|
|
396
|
+
const content = await readTextFile(filePath);
|
|
397
|
+
const parsed = parseYaml(content);
|
|
398
|
+
const result = validateDecision(parsed);
|
|
399
|
+
if (!result.success) {
|
|
400
|
+
const errors = formatValidationErrors(result.errors);
|
|
401
|
+
throw new DecisionValidationError(
|
|
402
|
+
`Invalid decision file: ${filePath}`,
|
|
403
|
+
typeof parsed === "object" && parsed !== null && "metadata" in parsed ? parsed.metadata?.id || "unknown" : "unknown",
|
|
404
|
+
errors
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
return result.data;
|
|
408
|
+
}
|
|
409
|
+
async function loadDecisionsFromDir(dirPath) {
|
|
410
|
+
const decisions = [];
|
|
411
|
+
const errors = [];
|
|
412
|
+
if (!await pathExists(dirPath)) {
|
|
413
|
+
return { decisions, errors };
|
|
414
|
+
}
|
|
415
|
+
const files = await readFilesInDir(dirPath, (f) => f.endsWith(".decision.yaml"));
|
|
416
|
+
for (const file of files) {
|
|
417
|
+
const filePath = join2(dirPath, file);
|
|
418
|
+
try {
|
|
419
|
+
const decision = await loadDecisionFile(filePath);
|
|
420
|
+
decisions.push({ decision, filePath });
|
|
421
|
+
} catch (error) {
|
|
422
|
+
errors.push({
|
|
423
|
+
filePath,
|
|
424
|
+
error: error instanceof Error ? error.message : String(error)
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return { decisions, errors };
|
|
429
|
+
}
|
|
430
|
+
async function validateDecisionFile(filePath) {
|
|
431
|
+
try {
|
|
432
|
+
if (!await pathExists(filePath)) {
|
|
433
|
+
return { valid: false, errors: [`File not found: ${filePath}`] };
|
|
434
|
+
}
|
|
435
|
+
const content = await readTextFile(filePath);
|
|
436
|
+
const parsed = parseYaml(content);
|
|
437
|
+
const result = validateDecision(parsed);
|
|
438
|
+
if (!result.success) {
|
|
439
|
+
return { valid: false, errors: formatValidationErrors(result.errors) };
|
|
440
|
+
}
|
|
441
|
+
return { valid: true, errors: [] };
|
|
442
|
+
} catch (error) {
|
|
443
|
+
return {
|
|
444
|
+
valid: false,
|
|
445
|
+
errors: [error instanceof Error ? error.message : String(error)]
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// src/utils/glob.ts
|
|
451
|
+
import fg from "fast-glob";
|
|
452
|
+
import { minimatch } from "minimatch";
|
|
453
|
+
async function glob(patterns, options = {}) {
|
|
454
|
+
const {
|
|
455
|
+
cwd = process.cwd(),
|
|
456
|
+
ignore = [],
|
|
457
|
+
absolute = false,
|
|
458
|
+
onlyFiles = true
|
|
459
|
+
} = options;
|
|
460
|
+
return fg(patterns, {
|
|
461
|
+
cwd,
|
|
462
|
+
ignore,
|
|
463
|
+
absolute,
|
|
464
|
+
onlyFiles,
|
|
465
|
+
dot: false
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
function matchesPattern(filePath, pattern) {
|
|
469
|
+
return minimatch(filePath, pattern, { matchBase: true });
|
|
470
|
+
}
|
|
471
|
+
function matchesAnyPattern(filePath, patterns) {
|
|
472
|
+
return patterns.some((pattern) => matchesPattern(filePath, pattern));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/registry/registry.ts
|
|
476
|
+
var Registry = class {
|
|
477
|
+
decisions = /* @__PURE__ */ new Map();
|
|
478
|
+
basePath;
|
|
479
|
+
loaded = false;
|
|
480
|
+
constructor(options = {}) {
|
|
481
|
+
this.basePath = options.basePath || process.cwd();
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Load all decisions from the decisions directory
|
|
485
|
+
*/
|
|
486
|
+
async load() {
|
|
487
|
+
if (!await pathExists(getSpecBridgeDir(this.basePath))) {
|
|
488
|
+
throw new NotInitializedError();
|
|
489
|
+
}
|
|
490
|
+
const decisionsDir = getDecisionsDir(this.basePath);
|
|
491
|
+
const result = await loadDecisionsFromDir(decisionsDir);
|
|
492
|
+
this.decisions.clear();
|
|
493
|
+
for (const loaded of result.decisions) {
|
|
494
|
+
this.decisions.set(loaded.decision.metadata.id, loaded);
|
|
495
|
+
}
|
|
496
|
+
this.loaded = true;
|
|
497
|
+
return result;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Ensure registry is loaded
|
|
501
|
+
*/
|
|
502
|
+
ensureLoaded() {
|
|
503
|
+
if (!this.loaded) {
|
|
504
|
+
throw new Error("Registry not loaded. Call load() first.");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Get all decisions
|
|
509
|
+
*/
|
|
510
|
+
getAll(filter) {
|
|
511
|
+
this.ensureLoaded();
|
|
512
|
+
let decisions = Array.from(this.decisions.values()).map((d) => d.decision);
|
|
513
|
+
if (filter) {
|
|
514
|
+
decisions = this.applyFilter(decisions, filter);
|
|
515
|
+
}
|
|
516
|
+
return decisions;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Get active decisions only
|
|
520
|
+
*/
|
|
521
|
+
getActive() {
|
|
522
|
+
return this.getAll({ status: ["active"] });
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Get a decision by ID
|
|
526
|
+
*/
|
|
527
|
+
get(id) {
|
|
528
|
+
this.ensureLoaded();
|
|
529
|
+
const loaded = this.decisions.get(id);
|
|
530
|
+
if (!loaded) {
|
|
531
|
+
throw new DecisionNotFoundError(id);
|
|
532
|
+
}
|
|
533
|
+
return loaded.decision;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Get a decision with its file path
|
|
537
|
+
*/
|
|
538
|
+
getWithPath(id) {
|
|
539
|
+
this.ensureLoaded();
|
|
540
|
+
const loaded = this.decisions.get(id);
|
|
541
|
+
if (!loaded) {
|
|
542
|
+
throw new DecisionNotFoundError(id);
|
|
543
|
+
}
|
|
544
|
+
return loaded;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Check if a decision exists
|
|
548
|
+
*/
|
|
549
|
+
has(id) {
|
|
550
|
+
this.ensureLoaded();
|
|
551
|
+
return this.decisions.has(id);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Get all decision IDs
|
|
555
|
+
*/
|
|
556
|
+
getIds() {
|
|
557
|
+
this.ensureLoaded();
|
|
558
|
+
return Array.from(this.decisions.keys());
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Get constraints applicable to a specific file
|
|
562
|
+
*/
|
|
563
|
+
getConstraintsForFile(filePath, filter) {
|
|
564
|
+
this.ensureLoaded();
|
|
565
|
+
const applicable = [];
|
|
566
|
+
let decisions = this.getActive();
|
|
567
|
+
if (filter) {
|
|
568
|
+
decisions = this.applyFilter(decisions, filter);
|
|
569
|
+
}
|
|
570
|
+
for (const decision of decisions) {
|
|
571
|
+
for (const constraint of decision.constraints) {
|
|
572
|
+
if (matchesPattern(filePath, constraint.scope)) {
|
|
573
|
+
applicable.push({
|
|
574
|
+
decisionId: decision.metadata.id,
|
|
575
|
+
decisionTitle: decision.metadata.title,
|
|
576
|
+
constraintId: constraint.id,
|
|
577
|
+
type: constraint.type,
|
|
578
|
+
rule: constraint.rule,
|
|
579
|
+
severity: constraint.severity,
|
|
580
|
+
scope: constraint.scope
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return applicable;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Get decisions by tag
|
|
589
|
+
*/
|
|
590
|
+
getByTag(tag) {
|
|
591
|
+
return this.getAll().filter(
|
|
592
|
+
(d) => d.metadata.tags?.includes(tag)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Get decisions by owner
|
|
597
|
+
*/
|
|
598
|
+
getByOwner(owner) {
|
|
599
|
+
return this.getAll().filter(
|
|
600
|
+
(d) => d.metadata.owners.includes(owner)
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Apply filter to decisions
|
|
605
|
+
*/
|
|
606
|
+
applyFilter(decisions, filter) {
|
|
607
|
+
return decisions.filter((decision) => {
|
|
608
|
+
if (filter.status && !filter.status.includes(decision.metadata.status)) {
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
if (filter.tags) {
|
|
612
|
+
const hasTags = filter.tags.some(
|
|
613
|
+
(tag) => decision.metadata.tags?.includes(tag)
|
|
614
|
+
);
|
|
615
|
+
if (!hasTags) return false;
|
|
616
|
+
}
|
|
617
|
+
if (filter.constraintType) {
|
|
618
|
+
const hasType = decision.constraints.some(
|
|
619
|
+
(c) => filter.constraintType?.includes(c.type)
|
|
620
|
+
);
|
|
621
|
+
if (!hasType) return false;
|
|
622
|
+
}
|
|
623
|
+
if (filter.severity) {
|
|
624
|
+
const hasSeverity = decision.constraints.some(
|
|
625
|
+
(c) => filter.severity?.includes(c.severity)
|
|
626
|
+
);
|
|
627
|
+
if (!hasSeverity) return false;
|
|
628
|
+
}
|
|
629
|
+
return true;
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Get count of decisions by status
|
|
634
|
+
*/
|
|
635
|
+
getStatusCounts() {
|
|
636
|
+
this.ensureLoaded();
|
|
637
|
+
const counts = {
|
|
638
|
+
draft: 0,
|
|
639
|
+
active: 0,
|
|
640
|
+
deprecated: 0,
|
|
641
|
+
superseded: 0
|
|
642
|
+
};
|
|
643
|
+
for (const loaded of this.decisions.values()) {
|
|
644
|
+
counts[loaded.decision.metadata.status]++;
|
|
645
|
+
}
|
|
646
|
+
return counts;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Get total constraint count
|
|
650
|
+
*/
|
|
651
|
+
getConstraintCount() {
|
|
652
|
+
this.ensureLoaded();
|
|
653
|
+
let count = 0;
|
|
654
|
+
for (const loaded of this.decisions.values()) {
|
|
655
|
+
count += loaded.decision.constraints.length;
|
|
656
|
+
}
|
|
657
|
+
return count;
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
function createRegistry(options) {
|
|
661
|
+
return new Registry(options);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/inference/scanner.ts
|
|
665
|
+
import { Project, Node, SyntaxKind } from "ts-morph";
|
|
666
|
+
var CodeScanner = class {
|
|
667
|
+
project;
|
|
668
|
+
scannedFiles = /* @__PURE__ */ new Map();
|
|
669
|
+
constructor() {
|
|
670
|
+
this.project = new Project({
|
|
671
|
+
compilerOptions: {
|
|
672
|
+
allowJs: true,
|
|
673
|
+
checkJs: false,
|
|
674
|
+
noEmit: true,
|
|
675
|
+
skipLibCheck: true
|
|
676
|
+
},
|
|
677
|
+
skipAddingFilesFromTsConfig: true
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Scan files matching the given patterns
|
|
682
|
+
*/
|
|
683
|
+
async scan(options) {
|
|
684
|
+
const { sourceRoots, exclude = [], cwd = process.cwd() } = options;
|
|
685
|
+
const files = await glob(sourceRoots, {
|
|
686
|
+
cwd,
|
|
687
|
+
ignore: exclude,
|
|
688
|
+
absolute: true
|
|
689
|
+
});
|
|
690
|
+
for (const filePath of files) {
|
|
691
|
+
try {
|
|
692
|
+
const sourceFile = this.project.addSourceFileAtPath(filePath);
|
|
693
|
+
const lines = sourceFile.getEndLineNumber();
|
|
694
|
+
this.scannedFiles.set(filePath, {
|
|
695
|
+
path: filePath,
|
|
696
|
+
sourceFile,
|
|
697
|
+
lines
|
|
698
|
+
});
|
|
699
|
+
} catch {
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const scannedArray = Array.from(this.scannedFiles.values());
|
|
703
|
+
const totalLines = scannedArray.reduce((sum, f) => sum + f.lines, 0);
|
|
704
|
+
return {
|
|
705
|
+
files: scannedArray,
|
|
706
|
+
totalFiles: scannedArray.length,
|
|
707
|
+
totalLines
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Get all scanned files
|
|
712
|
+
*/
|
|
713
|
+
getFiles() {
|
|
714
|
+
return Array.from(this.scannedFiles.values());
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Get a specific file
|
|
718
|
+
*/
|
|
719
|
+
getFile(path) {
|
|
720
|
+
return this.scannedFiles.get(path);
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Get project instance for advanced analysis
|
|
724
|
+
*/
|
|
725
|
+
getProject() {
|
|
726
|
+
return this.project;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Find all classes in scanned files
|
|
730
|
+
*/
|
|
731
|
+
findClasses() {
|
|
732
|
+
const classes = [];
|
|
733
|
+
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
734
|
+
for (const classDecl of sourceFile.getClasses()) {
|
|
735
|
+
const name = classDecl.getName();
|
|
736
|
+
if (name) {
|
|
737
|
+
classes.push({
|
|
738
|
+
file: path,
|
|
739
|
+
name,
|
|
740
|
+
line: classDecl.getStartLineNumber()
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return classes;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Find all functions in scanned files
|
|
749
|
+
*/
|
|
750
|
+
findFunctions() {
|
|
751
|
+
const functions = [];
|
|
752
|
+
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
753
|
+
for (const funcDecl of sourceFile.getFunctions()) {
|
|
754
|
+
const name = funcDecl.getName();
|
|
755
|
+
if (name) {
|
|
756
|
+
functions.push({
|
|
757
|
+
file: path,
|
|
758
|
+
name,
|
|
759
|
+
line: funcDecl.getStartLineNumber(),
|
|
760
|
+
isExported: funcDecl.isExported()
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
for (const varDecl of sourceFile.getVariableDeclarations()) {
|
|
765
|
+
const init = varDecl.getInitializer();
|
|
766
|
+
if (init && Node.isArrowFunction(init)) {
|
|
767
|
+
functions.push({
|
|
768
|
+
file: path,
|
|
769
|
+
name: varDecl.getName(),
|
|
770
|
+
line: varDecl.getStartLineNumber(),
|
|
771
|
+
isExported: varDecl.isExported()
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return functions;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Find all imports in scanned files
|
|
780
|
+
*/
|
|
781
|
+
findImports() {
|
|
782
|
+
const imports = [];
|
|
783
|
+
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
784
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
785
|
+
const module = importDecl.getModuleSpecifierValue();
|
|
786
|
+
const namedImports = importDecl.getNamedImports().map((n) => n.getName());
|
|
787
|
+
imports.push({
|
|
788
|
+
file: path,
|
|
789
|
+
module,
|
|
790
|
+
named: namedImports,
|
|
791
|
+
line: importDecl.getStartLineNumber()
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return imports;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Find all interfaces in scanned files
|
|
799
|
+
*/
|
|
800
|
+
findInterfaces() {
|
|
801
|
+
const interfaces = [];
|
|
802
|
+
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
803
|
+
for (const interfaceDecl of sourceFile.getInterfaces()) {
|
|
804
|
+
interfaces.push({
|
|
805
|
+
file: path,
|
|
806
|
+
name: interfaceDecl.getName(),
|
|
807
|
+
line: interfaceDecl.getStartLineNumber()
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
return interfaces;
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Find all type aliases in scanned files
|
|
815
|
+
*/
|
|
816
|
+
findTypeAliases() {
|
|
817
|
+
const types = [];
|
|
818
|
+
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
819
|
+
for (const typeAlias of sourceFile.getTypeAliases()) {
|
|
820
|
+
types.push({
|
|
821
|
+
file: path,
|
|
822
|
+
name: typeAlias.getName(),
|
|
823
|
+
line: typeAlias.getStartLineNumber()
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return types;
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Find try-catch blocks in scanned files
|
|
831
|
+
*/
|
|
832
|
+
findTryCatchBlocks() {
|
|
833
|
+
const blocks = [];
|
|
834
|
+
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
835
|
+
sourceFile.forEachDescendant((node) => {
|
|
836
|
+
if (Node.isTryStatement(node)) {
|
|
837
|
+
const catchClause = node.getCatchClause();
|
|
838
|
+
const hasThrow = catchClause ? catchClause.getDescendantsOfKind(SyntaxKind.ThrowStatement).length > 0 : false;
|
|
839
|
+
blocks.push({
|
|
840
|
+
file: path,
|
|
841
|
+
line: node.getStartLineNumber(),
|
|
842
|
+
hasThrow
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
return blocks;
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
function createScannerFromConfig(_config) {
|
|
851
|
+
return new CodeScanner();
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// src/inference/analyzers/base.ts
|
|
855
|
+
function createPattern(analyzer, params) {
|
|
856
|
+
return {
|
|
857
|
+
analyzer,
|
|
858
|
+
...params
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
function calculateConfidence(occurrences, total, minOccurrences = 3) {
|
|
862
|
+
if (occurrences < minOccurrences) {
|
|
863
|
+
return 0;
|
|
864
|
+
}
|
|
865
|
+
const ratio = occurrences / total;
|
|
866
|
+
return Math.min(100, Math.round(50 + ratio * 50));
|
|
867
|
+
}
|
|
868
|
+
function extractSnippet(content, line, contextLines = 1) {
|
|
869
|
+
const lines = content.split("\n");
|
|
870
|
+
const start = Math.max(0, line - 1 - contextLines);
|
|
871
|
+
const end = Math.min(lines.length, line + contextLines);
|
|
872
|
+
return lines.slice(start, end).join("\n");
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/inference/analyzers/naming.ts
|
|
876
|
+
var CLASS_PATTERNS = [
|
|
877
|
+
{ convention: "PascalCase", regex: /^[A-Z][a-zA-Z0-9]*$/, description: "Classes use PascalCase" }
|
|
878
|
+
];
|
|
879
|
+
var FUNCTION_PATTERNS = [
|
|
880
|
+
{ convention: "camelCase", regex: /^[a-z][a-zA-Z0-9]*$/, description: "Functions use camelCase" },
|
|
881
|
+
{ convention: "snake_case", regex: /^[a-z][a-z0-9_]*$/, description: "Functions use snake_case" }
|
|
882
|
+
];
|
|
883
|
+
var INTERFACE_PATTERNS = [
|
|
884
|
+
{ convention: "PascalCase", regex: /^[A-Z][a-zA-Z0-9]*$/, description: "Interfaces use PascalCase" },
|
|
885
|
+
{ convention: "IPrefixed", regex: /^I[A-Z][a-zA-Z0-9]*$/, description: "Interfaces are prefixed with I" }
|
|
886
|
+
];
|
|
887
|
+
var TYPE_PATTERNS = [
|
|
888
|
+
{ convention: "PascalCase", regex: /^[A-Z][a-zA-Z0-9]*$/, description: "Types use PascalCase" },
|
|
889
|
+
{ convention: "TSuffixed", regex: /^[A-Z][a-zA-Z0-9]*Type$/, description: "Types are suffixed with Type" }
|
|
890
|
+
];
|
|
891
|
+
var NamingAnalyzer = class {
|
|
892
|
+
id = "naming";
|
|
893
|
+
name = "Naming Convention Analyzer";
|
|
894
|
+
description = "Detects naming conventions for classes, functions, interfaces, and types";
|
|
895
|
+
async analyze(scanner) {
|
|
896
|
+
const patterns = [];
|
|
897
|
+
const classPattern = this.analyzeClassNaming(scanner);
|
|
898
|
+
if (classPattern) patterns.push(classPattern);
|
|
899
|
+
const functionPattern = this.analyzeFunctionNaming(scanner);
|
|
900
|
+
if (functionPattern) patterns.push(functionPattern);
|
|
901
|
+
const interfacePattern = this.analyzeInterfaceNaming(scanner);
|
|
902
|
+
if (interfacePattern) patterns.push(interfacePattern);
|
|
903
|
+
const typePattern = this.analyzeTypeNaming(scanner);
|
|
904
|
+
if (typePattern) patterns.push(typePattern);
|
|
905
|
+
return patterns;
|
|
906
|
+
}
|
|
907
|
+
analyzeClassNaming(scanner) {
|
|
908
|
+
const classes = scanner.findClasses();
|
|
909
|
+
if (classes.length < 3) return null;
|
|
910
|
+
const matches = this.findBestMatch(classes.map((c) => c.name), CLASS_PATTERNS);
|
|
911
|
+
if (!matches) return null;
|
|
912
|
+
return createPattern(this.id, {
|
|
913
|
+
id: "naming-classes",
|
|
914
|
+
name: "Class Naming Convention",
|
|
915
|
+
description: `Classes follow ${matches.convention} naming convention`,
|
|
916
|
+
confidence: matches.confidence,
|
|
917
|
+
occurrences: matches.matchCount,
|
|
918
|
+
examples: classes.slice(0, 3).map((c) => ({
|
|
919
|
+
file: c.file,
|
|
920
|
+
line: c.line,
|
|
921
|
+
snippet: `class ${c.name}`
|
|
922
|
+
})),
|
|
923
|
+
suggestedConstraint: {
|
|
924
|
+
type: "convention",
|
|
925
|
+
rule: `Classes should use ${matches.convention} naming convention`,
|
|
926
|
+
severity: "medium",
|
|
927
|
+
scope: "src/**/*.ts"
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
analyzeFunctionNaming(scanner) {
|
|
932
|
+
const functions = scanner.findFunctions();
|
|
933
|
+
if (functions.length < 3) return null;
|
|
934
|
+
const matches = this.findBestMatch(functions.map((f) => f.name), FUNCTION_PATTERNS);
|
|
935
|
+
if (!matches) return null;
|
|
936
|
+
return createPattern(this.id, {
|
|
937
|
+
id: "naming-functions",
|
|
938
|
+
name: "Function Naming Convention",
|
|
939
|
+
description: `Functions follow ${matches.convention} naming convention`,
|
|
940
|
+
confidence: matches.confidence,
|
|
941
|
+
occurrences: matches.matchCount,
|
|
942
|
+
examples: functions.slice(0, 3).map((f) => ({
|
|
943
|
+
file: f.file,
|
|
944
|
+
line: f.line,
|
|
945
|
+
snippet: `function ${f.name}`
|
|
946
|
+
})),
|
|
947
|
+
suggestedConstraint: {
|
|
948
|
+
type: "convention",
|
|
949
|
+
rule: `Functions should use ${matches.convention} naming convention`,
|
|
950
|
+
severity: "low",
|
|
951
|
+
scope: "src/**/*.ts"
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
analyzeInterfaceNaming(scanner) {
|
|
956
|
+
const interfaces = scanner.findInterfaces();
|
|
957
|
+
if (interfaces.length < 3) return null;
|
|
958
|
+
const matches = this.findBestMatch(interfaces.map((i) => i.name), INTERFACE_PATTERNS);
|
|
959
|
+
if (!matches) return null;
|
|
960
|
+
return createPattern(this.id, {
|
|
961
|
+
id: "naming-interfaces",
|
|
962
|
+
name: "Interface Naming Convention",
|
|
963
|
+
description: `Interfaces follow ${matches.convention} naming convention`,
|
|
964
|
+
confidence: matches.confidence,
|
|
965
|
+
occurrences: matches.matchCount,
|
|
966
|
+
examples: interfaces.slice(0, 3).map((i) => ({
|
|
967
|
+
file: i.file,
|
|
968
|
+
line: i.line,
|
|
969
|
+
snippet: `interface ${i.name}`
|
|
970
|
+
})),
|
|
971
|
+
suggestedConstraint: {
|
|
972
|
+
type: "convention",
|
|
973
|
+
rule: `Interfaces should use ${matches.convention} naming convention`,
|
|
974
|
+
severity: "low",
|
|
975
|
+
scope: "src/**/*.ts"
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
analyzeTypeNaming(scanner) {
|
|
980
|
+
const types = scanner.findTypeAliases();
|
|
981
|
+
if (types.length < 3) return null;
|
|
982
|
+
const matches = this.findBestMatch(types.map((t) => t.name), TYPE_PATTERNS);
|
|
983
|
+
if (!matches) return null;
|
|
984
|
+
return createPattern(this.id, {
|
|
985
|
+
id: "naming-types",
|
|
986
|
+
name: "Type Alias Naming Convention",
|
|
987
|
+
description: `Type aliases follow ${matches.convention} naming convention`,
|
|
988
|
+
confidence: matches.confidence,
|
|
989
|
+
occurrences: matches.matchCount,
|
|
990
|
+
examples: types.slice(0, 3).map((t) => ({
|
|
991
|
+
file: t.file,
|
|
992
|
+
line: t.line,
|
|
993
|
+
snippet: `type ${t.name}`
|
|
994
|
+
})),
|
|
995
|
+
suggestedConstraint: {
|
|
996
|
+
type: "guideline",
|
|
997
|
+
rule: `Type aliases should use ${matches.convention} naming convention`,
|
|
998
|
+
severity: "low",
|
|
999
|
+
scope: "src/**/*.ts"
|
|
1000
|
+
}
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
findBestMatch(names, patterns) {
|
|
1004
|
+
let bestMatch = null;
|
|
1005
|
+
for (const pattern of patterns) {
|
|
1006
|
+
const matchCount = names.filter((name) => pattern.regex.test(name)).length;
|
|
1007
|
+
if (!bestMatch || matchCount > bestMatch.matchCount) {
|
|
1008
|
+
bestMatch = { convention: pattern.convention, matchCount };
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (!bestMatch || bestMatch.matchCount < 3) return null;
|
|
1012
|
+
const confidence = calculateConfidence(bestMatch.matchCount, names.length);
|
|
1013
|
+
if (confidence < 50) return null;
|
|
1014
|
+
return {
|
|
1015
|
+
convention: bestMatch.convention,
|
|
1016
|
+
confidence,
|
|
1017
|
+
matchCount: bestMatch.matchCount
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
// src/inference/analyzers/imports.ts
|
|
1023
|
+
var ImportsAnalyzer = class {
|
|
1024
|
+
id = "imports";
|
|
1025
|
+
name = "Import Pattern Analyzer";
|
|
1026
|
+
description = "Detects import organization patterns and module usage conventions";
|
|
1027
|
+
async analyze(scanner) {
|
|
1028
|
+
const patterns = [];
|
|
1029
|
+
const barrelPattern = this.analyzeBarrelImports(scanner);
|
|
1030
|
+
if (barrelPattern) patterns.push(barrelPattern);
|
|
1031
|
+
const relativePattern = this.analyzeRelativeImports(scanner);
|
|
1032
|
+
if (relativePattern) patterns.push(relativePattern);
|
|
1033
|
+
const modulePatterns = this.analyzeCommonModules(scanner);
|
|
1034
|
+
patterns.push(...modulePatterns);
|
|
1035
|
+
return patterns;
|
|
1036
|
+
}
|
|
1037
|
+
analyzeBarrelImports(scanner) {
|
|
1038
|
+
const imports = scanner.findImports();
|
|
1039
|
+
const barrelImports = imports.filter((i) => {
|
|
1040
|
+
const modulePath = i.module;
|
|
1041
|
+
return modulePath.startsWith(".") && !modulePath.includes(".js") && !modulePath.includes(".ts");
|
|
1042
|
+
});
|
|
1043
|
+
const indexImports = barrelImports.filter((i) => {
|
|
1044
|
+
return i.module.endsWith("/index") || !i.module.includes("/");
|
|
1045
|
+
});
|
|
1046
|
+
if (indexImports.length < 3) return null;
|
|
1047
|
+
const confidence = calculateConfidence(indexImports.length, barrelImports.length);
|
|
1048
|
+
if (confidence < 50) return null;
|
|
1049
|
+
return createPattern(this.id, {
|
|
1050
|
+
id: "imports-barrel",
|
|
1051
|
+
name: "Barrel Import Pattern",
|
|
1052
|
+
description: "Modules are imported through barrel (index) files",
|
|
1053
|
+
confidence,
|
|
1054
|
+
occurrences: indexImports.length,
|
|
1055
|
+
examples: indexImports.slice(0, 3).map((i) => ({
|
|
1056
|
+
file: i.file,
|
|
1057
|
+
line: i.line,
|
|
1058
|
+
snippet: `import { ${i.named.slice(0, 3).join(", ")} } from '${i.module}'`
|
|
1059
|
+
})),
|
|
1060
|
+
suggestedConstraint: {
|
|
1061
|
+
type: "convention",
|
|
1062
|
+
rule: "Import from barrel (index) files rather than individual modules",
|
|
1063
|
+
severity: "low",
|
|
1064
|
+
scope: "src/**/*.ts"
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
analyzeRelativeImports(scanner) {
|
|
1069
|
+
const imports = scanner.findImports();
|
|
1070
|
+
const relativeImports = imports.filter((i) => i.module.startsWith("."));
|
|
1071
|
+
const absoluteImports = imports.filter((i) => !i.module.startsWith(".") && !i.module.startsWith("@"));
|
|
1072
|
+
const aliasImports = imports.filter((i) => i.module.startsWith("@/") || i.module.startsWith("~"));
|
|
1073
|
+
const total = relativeImports.length + absoluteImports.length + aliasImports.length;
|
|
1074
|
+
if (total < 10) return null;
|
|
1075
|
+
if (aliasImports.length > relativeImports.length && aliasImports.length >= 5) {
|
|
1076
|
+
const confidence = calculateConfidence(aliasImports.length, total);
|
|
1077
|
+
if (confidence < 50) return null;
|
|
1078
|
+
return createPattern(this.id, {
|
|
1079
|
+
id: "imports-alias",
|
|
1080
|
+
name: "Path Alias Import Pattern",
|
|
1081
|
+
description: "Imports use path aliases (@ or ~) instead of relative paths",
|
|
1082
|
+
confidence,
|
|
1083
|
+
occurrences: aliasImports.length,
|
|
1084
|
+
examples: aliasImports.slice(0, 3).map((i) => ({
|
|
1085
|
+
file: i.file,
|
|
1086
|
+
line: i.line,
|
|
1087
|
+
snippet: `import { ${i.named.slice(0, 2).join(", ")} } from '${i.module}'`
|
|
1088
|
+
})),
|
|
1089
|
+
suggestedConstraint: {
|
|
1090
|
+
type: "convention",
|
|
1091
|
+
rule: "Use path aliases instead of relative imports",
|
|
1092
|
+
severity: "low",
|
|
1093
|
+
scope: "src/**/*.ts"
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
if (relativeImports.length > aliasImports.length * 2 && relativeImports.length >= 5) {
|
|
1098
|
+
const confidence = calculateConfidence(relativeImports.length, total);
|
|
1099
|
+
if (confidence < 50) return null;
|
|
1100
|
+
return createPattern(this.id, {
|
|
1101
|
+
id: "imports-relative",
|
|
1102
|
+
name: "Relative Import Pattern",
|
|
1103
|
+
description: "Imports use relative paths",
|
|
1104
|
+
confidence,
|
|
1105
|
+
occurrences: relativeImports.length,
|
|
1106
|
+
examples: relativeImports.slice(0, 3).map((i) => ({
|
|
1107
|
+
file: i.file,
|
|
1108
|
+
line: i.line,
|
|
1109
|
+
snippet: `import { ${i.named.slice(0, 2).join(", ")} } from '${i.module}'`
|
|
1110
|
+
})),
|
|
1111
|
+
suggestedConstraint: {
|
|
1112
|
+
type: "guideline",
|
|
1113
|
+
rule: "Use relative imports for local modules",
|
|
1114
|
+
severity: "low",
|
|
1115
|
+
scope: "src/**/*.ts"
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
return null;
|
|
1120
|
+
}
|
|
1121
|
+
analyzeCommonModules(scanner) {
|
|
1122
|
+
const patterns = [];
|
|
1123
|
+
const imports = scanner.findImports();
|
|
1124
|
+
const moduleCounts = /* @__PURE__ */ new Map();
|
|
1125
|
+
for (const imp of imports) {
|
|
1126
|
+
if (imp.module.startsWith(".")) continue;
|
|
1127
|
+
const parts = imp.module.split("/");
|
|
1128
|
+
const packageName = imp.module.startsWith("@") && parts.length > 1 ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
1129
|
+
if (packageName) {
|
|
1130
|
+
const existing = moduleCounts.get(packageName) || { count: 0, examples: [] };
|
|
1131
|
+
existing.count++;
|
|
1132
|
+
existing.examples.push(imp);
|
|
1133
|
+
moduleCounts.set(packageName, existing);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
for (const [packageName, data] of moduleCounts) {
|
|
1137
|
+
if (data.count >= 5) {
|
|
1138
|
+
const confidence = Math.min(100, 50 + data.count * 2);
|
|
1139
|
+
patterns.push(createPattern(this.id, {
|
|
1140
|
+
id: `imports-module-${packageName.replace(/[/@]/g, "-")}`,
|
|
1141
|
+
name: `${packageName} Usage`,
|
|
1142
|
+
description: `${packageName} is used across ${data.count} files`,
|
|
1143
|
+
confidence,
|
|
1144
|
+
occurrences: data.count,
|
|
1145
|
+
examples: data.examples.slice(0, 3).map((i) => ({
|
|
1146
|
+
file: i.file,
|
|
1147
|
+
line: i.line,
|
|
1148
|
+
snippet: `import { ${i.named.slice(0, 2).join(", ") || "..."} } from '${i.module}'`
|
|
1149
|
+
}))
|
|
1150
|
+
}));
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return patterns;
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
// src/inference/analyzers/structure.ts
|
|
1158
|
+
import { basename, dirname as dirname2 } from "path";
|
|
1159
|
+
var StructureAnalyzer = class {
|
|
1160
|
+
id = "structure";
|
|
1161
|
+
name = "Code Structure Analyzer";
|
|
1162
|
+
description = "Detects file organization and directory structure patterns";
|
|
1163
|
+
async analyze(scanner) {
|
|
1164
|
+
const patterns = [];
|
|
1165
|
+
const files = scanner.getFiles();
|
|
1166
|
+
const dirPatterns = this.analyzeDirectoryConventions(files);
|
|
1167
|
+
patterns.push(...dirPatterns);
|
|
1168
|
+
const filePatterns = this.analyzeFileNaming(files);
|
|
1169
|
+
patterns.push(...filePatterns);
|
|
1170
|
+
const colocationPattern = this.analyzeColocation(files);
|
|
1171
|
+
if (colocationPattern) patterns.push(colocationPattern);
|
|
1172
|
+
return patterns;
|
|
1173
|
+
}
|
|
1174
|
+
analyzeDirectoryConventions(files) {
|
|
1175
|
+
const patterns = [];
|
|
1176
|
+
const dirCounts = /* @__PURE__ */ new Map();
|
|
1177
|
+
for (const file of files) {
|
|
1178
|
+
const dir = basename(dirname2(file.path));
|
|
1179
|
+
dirCounts.set(dir, (dirCounts.get(dir) || 0) + 1);
|
|
1180
|
+
}
|
|
1181
|
+
const commonDirs = [
|
|
1182
|
+
{ name: "components", description: "UI components are organized in a components directory" },
|
|
1183
|
+
{ name: "hooks", description: "Custom hooks are organized in a hooks directory" },
|
|
1184
|
+
{ name: "utils", description: "Utility functions are organized in a utils directory" },
|
|
1185
|
+
{ name: "services", description: "Service modules are organized in a services directory" },
|
|
1186
|
+
{ name: "types", description: "Type definitions are organized in a types directory" },
|
|
1187
|
+
{ name: "api", description: "API modules are organized in an api directory" },
|
|
1188
|
+
{ name: "lib", description: "Library code is organized in a lib directory" },
|
|
1189
|
+
{ name: "core", description: "Core modules are organized in a core directory" }
|
|
1190
|
+
];
|
|
1191
|
+
for (const { name, description } of commonDirs) {
|
|
1192
|
+
const count = dirCounts.get(name);
|
|
1193
|
+
if (count && count >= 3) {
|
|
1194
|
+
const exampleFiles = files.filter((f) => basename(dirname2(f.path)) === name).slice(0, 3);
|
|
1195
|
+
patterns.push(createPattern(this.id, {
|
|
1196
|
+
id: `structure-dir-${name}`,
|
|
1197
|
+
name: `${name}/ Directory Convention`,
|
|
1198
|
+
description,
|
|
1199
|
+
confidence: Math.min(100, 60 + count * 5),
|
|
1200
|
+
occurrences: count,
|
|
1201
|
+
examples: exampleFiles.map((f) => ({
|
|
1202
|
+
file: f.path,
|
|
1203
|
+
line: 1,
|
|
1204
|
+
snippet: basename(f.path)
|
|
1205
|
+
})),
|
|
1206
|
+
suggestedConstraint: {
|
|
1207
|
+
type: "convention",
|
|
1208
|
+
rule: `${name.charAt(0).toUpperCase() + name.slice(1)} should be placed in the ${name}/ directory`,
|
|
1209
|
+
severity: "low",
|
|
1210
|
+
scope: `src/**/${name}/**/*.ts`
|
|
1211
|
+
}
|
|
1212
|
+
}));
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
return patterns;
|
|
1216
|
+
}
|
|
1217
|
+
analyzeFileNaming(files) {
|
|
1218
|
+
const patterns = [];
|
|
1219
|
+
const suffixPatterns = [
|
|
1220
|
+
{ suffix: ".test.ts", pattern: /\.test\.ts$/, description: "Test files use .test.ts suffix" },
|
|
1221
|
+
{ suffix: ".spec.ts", pattern: /\.spec\.ts$/, description: "Test files use .spec.ts suffix" },
|
|
1222
|
+
{ suffix: ".types.ts", pattern: /\.types\.ts$/, description: "Type definition files use .types.ts suffix" },
|
|
1223
|
+
{ suffix: ".utils.ts", pattern: /\.utils\.ts$/, description: "Utility files use .utils.ts suffix" },
|
|
1224
|
+
{ suffix: ".service.ts", pattern: /\.service\.ts$/, description: "Service files use .service.ts suffix" },
|
|
1225
|
+
{ suffix: ".controller.ts", pattern: /\.controller\.ts$/, description: "Controller files use .controller.ts suffix" },
|
|
1226
|
+
{ suffix: ".model.ts", pattern: /\.model\.ts$/, description: "Model files use .model.ts suffix" },
|
|
1227
|
+
{ suffix: ".schema.ts", pattern: /\.schema\.ts$/, description: "Schema files use .schema.ts suffix" }
|
|
1228
|
+
];
|
|
1229
|
+
for (const { suffix, pattern, description } of suffixPatterns) {
|
|
1230
|
+
const matchingFiles = files.filter((f) => pattern.test(f.path));
|
|
1231
|
+
if (matchingFiles.length >= 3) {
|
|
1232
|
+
const confidence = Math.min(100, 60 + matchingFiles.length * 3);
|
|
1233
|
+
patterns.push(createPattern(this.id, {
|
|
1234
|
+
id: `structure-suffix-${suffix.replace(/\./g, "-")}`,
|
|
1235
|
+
name: `${suffix} File Naming`,
|
|
1236
|
+
description,
|
|
1237
|
+
confidence,
|
|
1238
|
+
occurrences: matchingFiles.length,
|
|
1239
|
+
examples: matchingFiles.slice(0, 3).map((f) => ({
|
|
1240
|
+
file: f.path,
|
|
1241
|
+
line: 1,
|
|
1242
|
+
snippet: basename(f.path)
|
|
1243
|
+
}))
|
|
1244
|
+
}));
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return patterns;
|
|
1248
|
+
}
|
|
1249
|
+
analyzeColocation(files) {
|
|
1250
|
+
const testFiles = files.filter((f) => /\.(test|spec)\.tsx?$/.test(f.path));
|
|
1251
|
+
const sourceFiles = files.filter((f) => !/\.(test|spec)\.tsx?$/.test(f.path));
|
|
1252
|
+
if (testFiles.length < 3) return null;
|
|
1253
|
+
let colocatedCount = 0;
|
|
1254
|
+
const colocatedExamples = [];
|
|
1255
|
+
for (const testFile of testFiles) {
|
|
1256
|
+
const testDir = dirname2(testFile.path);
|
|
1257
|
+
const testName = basename(testFile.path).replace(/\.(test|spec)\.tsx?$/, "");
|
|
1258
|
+
const hasColocatedSource = sourceFiles.some(
|
|
1259
|
+
(s) => dirname2(s.path) === testDir && basename(s.path).startsWith(testName)
|
|
1260
|
+
);
|
|
1261
|
+
if (hasColocatedSource) {
|
|
1262
|
+
colocatedCount++;
|
|
1263
|
+
if (colocatedExamples.length < 3) {
|
|
1264
|
+
colocatedExamples.push({
|
|
1265
|
+
file: testFile.path,
|
|
1266
|
+
line: 1,
|
|
1267
|
+
snippet: basename(testFile.path)
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
const confidence = calculateConfidence(colocatedCount, testFiles.length);
|
|
1273
|
+
if (confidence < 60) return null;
|
|
1274
|
+
return createPattern(this.id, {
|
|
1275
|
+
id: "structure-colocation",
|
|
1276
|
+
name: "Test Colocation Pattern",
|
|
1277
|
+
description: "Test files are colocated with their source files",
|
|
1278
|
+
confidence,
|
|
1279
|
+
occurrences: colocatedCount,
|
|
1280
|
+
examples: colocatedExamples,
|
|
1281
|
+
suggestedConstraint: {
|
|
1282
|
+
type: "guideline",
|
|
1283
|
+
rule: "Test files should be colocated with their source files",
|
|
1284
|
+
severity: "low",
|
|
1285
|
+
scope: "src/**/*.test.ts"
|
|
1286
|
+
}
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1291
|
+
// src/inference/analyzers/errors.ts
|
|
1292
|
+
import { Node as Node2 } from "ts-morph";
|
|
1293
|
+
var ErrorsAnalyzer = class {
|
|
1294
|
+
id = "errors";
|
|
1295
|
+
name = "Error Handling Analyzer";
|
|
1296
|
+
description = "Detects error handling patterns and custom error class usage";
|
|
1297
|
+
async analyze(scanner) {
|
|
1298
|
+
const patterns = [];
|
|
1299
|
+
const errorClassPattern = this.analyzeCustomErrorClasses(scanner);
|
|
1300
|
+
if (errorClassPattern) patterns.push(errorClassPattern);
|
|
1301
|
+
const tryCatchPattern = this.analyzeTryCatchPatterns(scanner);
|
|
1302
|
+
if (tryCatchPattern) patterns.push(tryCatchPattern);
|
|
1303
|
+
const throwPattern = this.analyzeThrowPatterns(scanner);
|
|
1304
|
+
if (throwPattern) patterns.push(throwPattern);
|
|
1305
|
+
return patterns;
|
|
1306
|
+
}
|
|
1307
|
+
analyzeCustomErrorClasses(scanner) {
|
|
1308
|
+
const classes = scanner.findClasses();
|
|
1309
|
+
const errorClasses = classes.filter(
|
|
1310
|
+
(c) => c.name.endsWith("Error") || c.name.endsWith("Exception")
|
|
1311
|
+
);
|
|
1312
|
+
if (errorClasses.length < 2) return null;
|
|
1313
|
+
const files = scanner.getFiles();
|
|
1314
|
+
let extendsError = 0;
|
|
1315
|
+
let extendsCustomBase = 0;
|
|
1316
|
+
let customBaseName = null;
|
|
1317
|
+
for (const errorClass of errorClasses) {
|
|
1318
|
+
const file = files.find((f) => f.path === errorClass.file);
|
|
1319
|
+
if (!file) continue;
|
|
1320
|
+
const classDecl = file.sourceFile.getClass(errorClass.name);
|
|
1321
|
+
if (!classDecl) continue;
|
|
1322
|
+
const extendClause = classDecl.getExtends();
|
|
1323
|
+
if (extendClause) {
|
|
1324
|
+
const baseName = extendClause.getText();
|
|
1325
|
+
if (baseName === "Error") {
|
|
1326
|
+
extendsError++;
|
|
1327
|
+
} else if (baseName.endsWith("Error")) {
|
|
1328
|
+
extendsCustomBase++;
|
|
1329
|
+
customBaseName = customBaseName || baseName;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
if (extendsCustomBase >= 2 && customBaseName) {
|
|
1334
|
+
const confidence = calculateConfidence(extendsCustomBase, errorClasses.length);
|
|
1335
|
+
return createPattern(this.id, {
|
|
1336
|
+
id: "errors-custom-base",
|
|
1337
|
+
name: "Custom Error Base Class",
|
|
1338
|
+
description: `Custom errors extend a common base class (${customBaseName})`,
|
|
1339
|
+
confidence,
|
|
1340
|
+
occurrences: extendsCustomBase,
|
|
1341
|
+
examples: errorClasses.slice(0, 3).map((c) => ({
|
|
1342
|
+
file: c.file,
|
|
1343
|
+
line: c.line,
|
|
1344
|
+
snippet: `class ${c.name} extends ${customBaseName}`
|
|
1345
|
+
})),
|
|
1346
|
+
suggestedConstraint: {
|
|
1347
|
+
type: "convention",
|
|
1348
|
+
rule: `Custom error classes should extend ${customBaseName}`,
|
|
1349
|
+
severity: "medium",
|
|
1350
|
+
scope: "src/**/*.ts",
|
|
1351
|
+
verifier: "error-hierarchy"
|
|
1352
|
+
}
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
if (errorClasses.length >= 3) {
|
|
1356
|
+
const confidence = Math.min(100, 50 + errorClasses.length * 5);
|
|
1357
|
+
return createPattern(this.id, {
|
|
1358
|
+
id: "errors-custom-classes",
|
|
1359
|
+
name: "Custom Error Classes",
|
|
1360
|
+
description: "Custom error classes are used for domain-specific errors",
|
|
1361
|
+
confidence,
|
|
1362
|
+
occurrences: errorClasses.length,
|
|
1363
|
+
examples: errorClasses.slice(0, 3).map((c) => ({
|
|
1364
|
+
file: c.file,
|
|
1365
|
+
line: c.line,
|
|
1366
|
+
snippet: `class ${c.name}`
|
|
1367
|
+
})),
|
|
1368
|
+
suggestedConstraint: {
|
|
1369
|
+
type: "guideline",
|
|
1370
|
+
rule: "Use custom error classes for domain-specific errors",
|
|
1371
|
+
severity: "low",
|
|
1372
|
+
scope: "src/**/*.ts"
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
return null;
|
|
1377
|
+
}
|
|
1378
|
+
analyzeTryCatchPatterns(scanner) {
|
|
1379
|
+
const tryCatchBlocks = scanner.findTryCatchBlocks();
|
|
1380
|
+
if (tryCatchBlocks.length < 3) return null;
|
|
1381
|
+
const rethrowCount = tryCatchBlocks.filter((b) => b.hasThrow).length;
|
|
1382
|
+
const swallowCount = tryCatchBlocks.length - rethrowCount;
|
|
1383
|
+
if (rethrowCount >= 3 && rethrowCount > swallowCount) {
|
|
1384
|
+
const confidence = calculateConfidence(rethrowCount, tryCatchBlocks.length);
|
|
1385
|
+
return createPattern(this.id, {
|
|
1386
|
+
id: "errors-rethrow",
|
|
1387
|
+
name: "Error Rethrow Pattern",
|
|
1388
|
+
description: "Caught errors are typically rethrown after handling",
|
|
1389
|
+
confidence,
|
|
1390
|
+
occurrences: rethrowCount,
|
|
1391
|
+
examples: tryCatchBlocks.filter((b) => b.hasThrow).slice(0, 3).map((b) => ({
|
|
1392
|
+
file: b.file,
|
|
1393
|
+
line: b.line,
|
|
1394
|
+
snippet: "try { ... } catch (e) { ... throw ... }"
|
|
1395
|
+
})),
|
|
1396
|
+
suggestedConstraint: {
|
|
1397
|
+
type: "guideline",
|
|
1398
|
+
rule: "Caught errors should be rethrown or wrapped after handling",
|
|
1399
|
+
severity: "low",
|
|
1400
|
+
scope: "src/**/*.ts"
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
return null;
|
|
1405
|
+
}
|
|
1406
|
+
analyzeThrowPatterns(scanner) {
|
|
1407
|
+
const files = scanner.getFiles();
|
|
1408
|
+
let throwNewError = 0;
|
|
1409
|
+
let throwCustom = 0;
|
|
1410
|
+
const examples = [];
|
|
1411
|
+
for (const { path, sourceFile } of files) {
|
|
1412
|
+
sourceFile.forEachDescendant((node) => {
|
|
1413
|
+
if (Node2.isThrowStatement(node)) {
|
|
1414
|
+
const expression = node.getExpression();
|
|
1415
|
+
if (expression) {
|
|
1416
|
+
const text = expression.getText();
|
|
1417
|
+
if (text.startsWith("new Error(")) {
|
|
1418
|
+
throwNewError++;
|
|
1419
|
+
} else if (text.startsWith("new ") && text.includes("Error")) {
|
|
1420
|
+
throwCustom++;
|
|
1421
|
+
if (examples.length < 3) {
|
|
1422
|
+
examples.push({
|
|
1423
|
+
file: path,
|
|
1424
|
+
line: node.getStartLineNumber(),
|
|
1425
|
+
snippet: `throw ${text.slice(0, 50)}${text.length > 50 ? "..." : ""}`
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
if (throwCustom >= 3 && throwCustom > throwNewError) {
|
|
1434
|
+
const confidence = calculateConfidence(throwCustom, throwCustom + throwNewError);
|
|
1435
|
+
return createPattern(this.id, {
|
|
1436
|
+
id: "errors-throw-custom",
|
|
1437
|
+
name: "Custom Error Throwing",
|
|
1438
|
+
description: "Custom error classes are thrown instead of generic Error",
|
|
1439
|
+
confidence,
|
|
1440
|
+
occurrences: throwCustom,
|
|
1441
|
+
examples,
|
|
1442
|
+
suggestedConstraint: {
|
|
1443
|
+
type: "convention",
|
|
1444
|
+
rule: "Throw custom error classes instead of generic Error",
|
|
1445
|
+
severity: "medium",
|
|
1446
|
+
scope: "src/**/*.ts",
|
|
1447
|
+
verifier: "custom-errors-only"
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
return null;
|
|
1452
|
+
}
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1455
|
+
// src/inference/analyzers/index.ts
|
|
1456
|
+
var builtinAnalyzers = {
|
|
1457
|
+
naming: () => new NamingAnalyzer(),
|
|
1458
|
+
imports: () => new ImportsAnalyzer(),
|
|
1459
|
+
structure: () => new StructureAnalyzer(),
|
|
1460
|
+
errors: () => new ErrorsAnalyzer()
|
|
1461
|
+
};
|
|
1462
|
+
function getAnalyzer(id) {
|
|
1463
|
+
const factory = builtinAnalyzers[id];
|
|
1464
|
+
return factory ? factory() : null;
|
|
1465
|
+
}
|
|
1466
|
+
function getAnalyzerIds() {
|
|
1467
|
+
return Object.keys(builtinAnalyzers);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// src/inference/engine.ts
|
|
1471
|
+
var InferenceEngine = class {
|
|
1472
|
+
scanner;
|
|
1473
|
+
analyzers = [];
|
|
1474
|
+
constructor() {
|
|
1475
|
+
this.scanner = new CodeScanner();
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Configure analyzers to use
|
|
1479
|
+
*/
|
|
1480
|
+
configureAnalyzers(analyzerIds) {
|
|
1481
|
+
this.analyzers = [];
|
|
1482
|
+
for (const id of analyzerIds) {
|
|
1483
|
+
const analyzer = getAnalyzer(id);
|
|
1484
|
+
if (!analyzer) {
|
|
1485
|
+
throw new AnalyzerNotFoundError(id);
|
|
1486
|
+
}
|
|
1487
|
+
this.analyzers.push(analyzer);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Run inference on the codebase
|
|
1492
|
+
*/
|
|
1493
|
+
async infer(options) {
|
|
1494
|
+
const startTime = Date.now();
|
|
1495
|
+
const {
|
|
1496
|
+
analyzers: analyzerIds = getAnalyzerIds(),
|
|
1497
|
+
minConfidence = 50,
|
|
1498
|
+
sourceRoots = ["src/**/*.ts", "src/**/*.tsx"],
|
|
1499
|
+
exclude = ["**/*.test.ts", "**/*.spec.ts", "**/node_modules/**", "**/dist/**"],
|
|
1500
|
+
cwd = process.cwd()
|
|
1501
|
+
} = options;
|
|
1502
|
+
this.configureAnalyzers(analyzerIds);
|
|
1503
|
+
const scanResult = await this.scanner.scan({
|
|
1504
|
+
sourceRoots,
|
|
1505
|
+
exclude,
|
|
1506
|
+
cwd
|
|
1507
|
+
});
|
|
1508
|
+
if (scanResult.totalFiles === 0) {
|
|
1509
|
+
return {
|
|
1510
|
+
patterns: [],
|
|
1511
|
+
analyzersRun: analyzerIds,
|
|
1512
|
+
filesScanned: 0,
|
|
1513
|
+
duration: Date.now() - startTime
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
const allPatterns = [];
|
|
1517
|
+
for (const analyzer of this.analyzers) {
|
|
1518
|
+
try {
|
|
1519
|
+
const patterns = await analyzer.analyze(this.scanner);
|
|
1520
|
+
allPatterns.push(...patterns);
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
console.warn(`Analyzer ${analyzer.id} failed:`, error);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
const filteredPatterns = allPatterns.filter((p) => p.confidence >= minConfidence);
|
|
1526
|
+
filteredPatterns.sort((a, b) => b.confidence - a.confidence);
|
|
1527
|
+
return {
|
|
1528
|
+
patterns: filteredPatterns,
|
|
1529
|
+
analyzersRun: analyzerIds,
|
|
1530
|
+
filesScanned: scanResult.totalFiles,
|
|
1531
|
+
duration: Date.now() - startTime
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Get scanner for direct access
|
|
1536
|
+
*/
|
|
1537
|
+
getScanner() {
|
|
1538
|
+
return this.scanner;
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
function createInferenceEngine() {
|
|
1542
|
+
return new InferenceEngine();
|
|
1543
|
+
}
|
|
1544
|
+
async function runInference(config, options) {
|
|
1545
|
+
const engine = createInferenceEngine();
|
|
1546
|
+
return engine.infer({
|
|
1547
|
+
analyzers: config.inference?.analyzers,
|
|
1548
|
+
minConfidence: config.inference?.minConfidence,
|
|
1549
|
+
sourceRoots: config.project.sourceRoots,
|
|
1550
|
+
exclude: config.project.exclude,
|
|
1551
|
+
...options
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// src/verification/engine.ts
|
|
1556
|
+
import { Project as Project2 } from "ts-morph";
|
|
1557
|
+
|
|
1558
|
+
// src/verification/verifiers/base.ts
|
|
1559
|
+
function createViolation(params) {
|
|
1560
|
+
return params;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// src/verification/verifiers/naming.ts
|
|
1564
|
+
var NAMING_PATTERNS = {
|
|
1565
|
+
PascalCase: {
|
|
1566
|
+
regex: /^[A-Z][a-zA-Z0-9]*$/,
|
|
1567
|
+
description: "PascalCase (e.g., MyClass)"
|
|
1568
|
+
},
|
|
1569
|
+
camelCase: {
|
|
1570
|
+
regex: /^[a-z][a-zA-Z0-9]*$/,
|
|
1571
|
+
description: "camelCase (e.g., myFunction)"
|
|
1572
|
+
},
|
|
1573
|
+
UPPER_SNAKE_CASE: {
|
|
1574
|
+
regex: /^[A-Z][A-Z0-9_]*$/,
|
|
1575
|
+
description: "UPPER_SNAKE_CASE (e.g., MAX_VALUE)"
|
|
1576
|
+
},
|
|
1577
|
+
snake_case: {
|
|
1578
|
+
regex: /^[a-z][a-z0-9_]*$/,
|
|
1579
|
+
description: "snake_case (e.g., my_variable)"
|
|
1580
|
+
},
|
|
1581
|
+
"kebab-case": {
|
|
1582
|
+
regex: /^[a-z][a-z0-9-]*$/,
|
|
1583
|
+
description: "kebab-case (e.g., my-component)"
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
var NamingVerifier = class {
|
|
1587
|
+
id = "naming";
|
|
1588
|
+
name = "Naming Convention Verifier";
|
|
1589
|
+
description = "Verifies naming conventions for classes, functions, and variables";
|
|
1590
|
+
async verify(ctx) {
|
|
1591
|
+
const violations = [];
|
|
1592
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
1593
|
+
const rule = constraint.rule.toLowerCase();
|
|
1594
|
+
let convention = null;
|
|
1595
|
+
let targetType = null;
|
|
1596
|
+
for (const [name] of Object.entries(NAMING_PATTERNS)) {
|
|
1597
|
+
if (rule.includes(name.toLowerCase())) {
|
|
1598
|
+
convention = name;
|
|
1599
|
+
break;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
if (rule.includes("class")) targetType = "class";
|
|
1603
|
+
else if (rule.includes("function")) targetType = "function";
|
|
1604
|
+
else if (rule.includes("interface")) targetType = "interface";
|
|
1605
|
+
else if (rule.includes("type")) targetType = "type";
|
|
1606
|
+
if (!convention || !targetType) {
|
|
1607
|
+
return violations;
|
|
1608
|
+
}
|
|
1609
|
+
const pattern = NAMING_PATTERNS[convention];
|
|
1610
|
+
if (!pattern) return violations;
|
|
1611
|
+
if (targetType === "class") {
|
|
1612
|
+
for (const classDecl of sourceFile.getClasses()) {
|
|
1613
|
+
const name = classDecl.getName();
|
|
1614
|
+
if (name && !pattern.regex.test(name)) {
|
|
1615
|
+
violations.push(createViolation({
|
|
1616
|
+
decisionId,
|
|
1617
|
+
constraintId: constraint.id,
|
|
1618
|
+
type: constraint.type,
|
|
1619
|
+
severity: constraint.severity,
|
|
1620
|
+
message: `Class "${name}" does not follow ${pattern.description} naming convention`,
|
|
1621
|
+
file: filePath,
|
|
1622
|
+
line: classDecl.getStartLineNumber(),
|
|
1623
|
+
column: classDecl.getStart() - classDecl.getStartLinePos(),
|
|
1624
|
+
suggestion: `Rename to follow ${pattern.description}`
|
|
1625
|
+
}));
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
if (targetType === "function") {
|
|
1630
|
+
for (const funcDecl of sourceFile.getFunctions()) {
|
|
1631
|
+
const name = funcDecl.getName();
|
|
1632
|
+
if (name && !pattern.regex.test(name)) {
|
|
1633
|
+
violations.push(createViolation({
|
|
1634
|
+
decisionId,
|
|
1635
|
+
constraintId: constraint.id,
|
|
1636
|
+
type: constraint.type,
|
|
1637
|
+
severity: constraint.severity,
|
|
1638
|
+
message: `Function "${name}" does not follow ${pattern.description} naming convention`,
|
|
1639
|
+
file: filePath,
|
|
1640
|
+
line: funcDecl.getStartLineNumber(),
|
|
1641
|
+
suggestion: `Rename to follow ${pattern.description}`
|
|
1642
|
+
}));
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
if (targetType === "interface") {
|
|
1647
|
+
for (const interfaceDecl of sourceFile.getInterfaces()) {
|
|
1648
|
+
const name = interfaceDecl.getName();
|
|
1649
|
+
if (!pattern.regex.test(name)) {
|
|
1650
|
+
violations.push(createViolation({
|
|
1651
|
+
decisionId,
|
|
1652
|
+
constraintId: constraint.id,
|
|
1653
|
+
type: constraint.type,
|
|
1654
|
+
severity: constraint.severity,
|
|
1655
|
+
message: `Interface "${name}" does not follow ${pattern.description} naming convention`,
|
|
1656
|
+
file: filePath,
|
|
1657
|
+
line: interfaceDecl.getStartLineNumber(),
|
|
1658
|
+
suggestion: `Rename to follow ${pattern.description}`
|
|
1659
|
+
}));
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
if (targetType === "type") {
|
|
1664
|
+
for (const typeAlias of sourceFile.getTypeAliases()) {
|
|
1665
|
+
const name = typeAlias.getName();
|
|
1666
|
+
if (!pattern.regex.test(name)) {
|
|
1667
|
+
violations.push(createViolation({
|
|
1668
|
+
decisionId,
|
|
1669
|
+
constraintId: constraint.id,
|
|
1670
|
+
type: constraint.type,
|
|
1671
|
+
severity: constraint.severity,
|
|
1672
|
+
message: `Type "${name}" does not follow ${pattern.description} naming convention`,
|
|
1673
|
+
file: filePath,
|
|
1674
|
+
line: typeAlias.getStartLineNumber(),
|
|
1675
|
+
suggestion: `Rename to follow ${pattern.description}`
|
|
1676
|
+
}));
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
return violations;
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
|
|
1684
|
+
// src/verification/verifiers/imports.ts
|
|
1685
|
+
var ImportsVerifier = class {
|
|
1686
|
+
id = "imports";
|
|
1687
|
+
name = "Import Pattern Verifier";
|
|
1688
|
+
description = "Verifies import patterns like barrel imports, path aliases, etc.";
|
|
1689
|
+
async verify(ctx) {
|
|
1690
|
+
const violations = [];
|
|
1691
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
1692
|
+
const rule = constraint.rule.toLowerCase();
|
|
1693
|
+
if (rule.includes("barrel") || rule.includes("index")) {
|
|
1694
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
1695
|
+
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
1696
|
+
if (!moduleSpec.startsWith(".")) continue;
|
|
1697
|
+
if (moduleSpec.match(/\.(ts|js|tsx|jsx)$/) || moduleSpec.match(/\/[^/]+$/)) {
|
|
1698
|
+
if (!moduleSpec.endsWith("/index") && !moduleSpec.endsWith("index")) {
|
|
1699
|
+
violations.push(createViolation({
|
|
1700
|
+
decisionId,
|
|
1701
|
+
constraintId: constraint.id,
|
|
1702
|
+
type: constraint.type,
|
|
1703
|
+
severity: constraint.severity,
|
|
1704
|
+
message: `Import from "${moduleSpec}" should use barrel (index) import`,
|
|
1705
|
+
file: filePath,
|
|
1706
|
+
line: importDecl.getStartLineNumber(),
|
|
1707
|
+
suggestion: "Import from the parent directory index file instead"
|
|
1708
|
+
}));
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
if (rule.includes("alias") || rule.includes("@/") || rule.includes("path alias")) {
|
|
1714
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
1715
|
+
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
1716
|
+
if (moduleSpec.match(/^\.\.\/\.\.\/\.\.\//)) {
|
|
1717
|
+
violations.push(createViolation({
|
|
1718
|
+
decisionId,
|
|
1719
|
+
constraintId: constraint.id,
|
|
1720
|
+
type: constraint.type,
|
|
1721
|
+
severity: constraint.severity,
|
|
1722
|
+
message: `Deep relative import "${moduleSpec}" should use path alias`,
|
|
1723
|
+
file: filePath,
|
|
1724
|
+
line: importDecl.getStartLineNumber(),
|
|
1725
|
+
suggestion: "Use path alias (e.g., @/module) for deep imports"
|
|
1726
|
+
}));
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
if (rule.includes("circular") || rule.includes("cycle")) {
|
|
1731
|
+
const currentFilename = filePath.replace(/\.[jt]sx?$/, "");
|
|
1732
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
1733
|
+
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
1734
|
+
if (moduleSpec.includes(currentFilename.split("/").pop() || "")) {
|
|
1735
|
+
violations.push(createViolation({
|
|
1736
|
+
decisionId,
|
|
1737
|
+
constraintId: constraint.id,
|
|
1738
|
+
type: constraint.type,
|
|
1739
|
+
severity: constraint.severity,
|
|
1740
|
+
message: `Possible circular import detected: "${moduleSpec}"`,
|
|
1741
|
+
file: filePath,
|
|
1742
|
+
line: importDecl.getStartLineNumber(),
|
|
1743
|
+
suggestion: "Review import structure for circular dependencies"
|
|
1744
|
+
}));
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
if (rule.includes("wildcard") || rule.includes("* as") || rule.includes("no namespace")) {
|
|
1749
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
1750
|
+
const namespaceImport = importDecl.getNamespaceImport();
|
|
1751
|
+
if (namespaceImport) {
|
|
1752
|
+
violations.push(createViolation({
|
|
1753
|
+
decisionId,
|
|
1754
|
+
constraintId: constraint.id,
|
|
1755
|
+
type: constraint.type,
|
|
1756
|
+
severity: constraint.severity,
|
|
1757
|
+
message: `Namespace import "* as ${namespaceImport.getText()}" should use named imports`,
|
|
1758
|
+
file: filePath,
|
|
1759
|
+
line: importDecl.getStartLineNumber(),
|
|
1760
|
+
suggestion: "Use specific named imports instead of namespace import"
|
|
1761
|
+
}));
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
return violations;
|
|
1766
|
+
}
|
|
1767
|
+
};
|
|
1768
|
+
|
|
1769
|
+
// src/verification/verifiers/errors.ts
|
|
1770
|
+
import { Node as Node3 } from "ts-morph";
|
|
1771
|
+
var ErrorsVerifier = class {
|
|
1772
|
+
id = "errors";
|
|
1773
|
+
name = "Error Handling Verifier";
|
|
1774
|
+
description = "Verifies error handling patterns";
|
|
1775
|
+
async verify(ctx) {
|
|
1776
|
+
const violations = [];
|
|
1777
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
1778
|
+
const rule = constraint.rule.toLowerCase();
|
|
1779
|
+
if (rule.includes("extend") || rule.includes("base") || rule.includes("hierarchy")) {
|
|
1780
|
+
const baseClassMatch = rule.match(/extend\s+(\w+)/i) || rule.match(/(\w+Error)\s+class/i);
|
|
1781
|
+
const requiredBase = baseClassMatch ? baseClassMatch[1] : null;
|
|
1782
|
+
for (const classDecl of sourceFile.getClasses()) {
|
|
1783
|
+
const className = classDecl.getName();
|
|
1784
|
+
if (!className?.endsWith("Error") && !className?.endsWith("Exception")) continue;
|
|
1785
|
+
const extendsClause = classDecl.getExtends();
|
|
1786
|
+
if (!extendsClause) {
|
|
1787
|
+
violations.push(createViolation({
|
|
1788
|
+
decisionId,
|
|
1789
|
+
constraintId: constraint.id,
|
|
1790
|
+
type: constraint.type,
|
|
1791
|
+
severity: constraint.severity,
|
|
1792
|
+
message: `Error class "${className}" does not extend any base class`,
|
|
1793
|
+
file: filePath,
|
|
1794
|
+
line: classDecl.getStartLineNumber(),
|
|
1795
|
+
suggestion: requiredBase ? `Extend ${requiredBase}` : "Extend a base error class for consistent error handling"
|
|
1796
|
+
}));
|
|
1797
|
+
} else if (requiredBase) {
|
|
1798
|
+
const baseName = extendsClause.getText();
|
|
1799
|
+
if (baseName !== requiredBase && baseName !== "Error") {
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
if (rule.includes("custom error") || rule.includes("throw custom")) {
|
|
1805
|
+
sourceFile.forEachDescendant((node) => {
|
|
1806
|
+
if (Node3.isThrowStatement(node)) {
|
|
1807
|
+
const expression = node.getExpression();
|
|
1808
|
+
if (expression) {
|
|
1809
|
+
const text = expression.getText();
|
|
1810
|
+
if (text.startsWith("new Error(")) {
|
|
1811
|
+
violations.push(createViolation({
|
|
1812
|
+
decisionId,
|
|
1813
|
+
constraintId: constraint.id,
|
|
1814
|
+
type: constraint.type,
|
|
1815
|
+
severity: constraint.severity,
|
|
1816
|
+
message: "Throwing generic Error instead of custom error class",
|
|
1817
|
+
file: filePath,
|
|
1818
|
+
line: node.getStartLineNumber(),
|
|
1819
|
+
suggestion: "Use a custom error class for better error handling"
|
|
1820
|
+
}));
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
if (rule.includes("empty catch") || rule.includes("swallow") || rule.includes("handle")) {
|
|
1827
|
+
sourceFile.forEachDescendant((node) => {
|
|
1828
|
+
if (Node3.isTryStatement(node)) {
|
|
1829
|
+
const catchClause = node.getCatchClause();
|
|
1830
|
+
if (catchClause) {
|
|
1831
|
+
const block = catchClause.getBlock();
|
|
1832
|
+
const statements = block.getStatements();
|
|
1833
|
+
if (statements.length === 0) {
|
|
1834
|
+
violations.push(createViolation({
|
|
1835
|
+
decisionId,
|
|
1836
|
+
constraintId: constraint.id,
|
|
1837
|
+
type: constraint.type,
|
|
1838
|
+
severity: constraint.severity,
|
|
1839
|
+
message: "Empty catch block swallows error without handling",
|
|
1840
|
+
file: filePath,
|
|
1841
|
+
line: catchClause.getStartLineNumber(),
|
|
1842
|
+
suggestion: "Add error handling, logging, or rethrow the error"
|
|
1843
|
+
}));
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
if (rule.includes("logging") || rule.includes("logger") || rule.includes("no console")) {
|
|
1850
|
+
sourceFile.forEachDescendant((node) => {
|
|
1851
|
+
if (Node3.isCallExpression(node)) {
|
|
1852
|
+
const expression = node.getExpression();
|
|
1853
|
+
const text = expression.getText();
|
|
1854
|
+
if (text === "console.error" || text === "console.log") {
|
|
1855
|
+
violations.push(createViolation({
|
|
1856
|
+
decisionId,
|
|
1857
|
+
constraintId: constraint.id,
|
|
1858
|
+
type: constraint.type,
|
|
1859
|
+
severity: constraint.severity,
|
|
1860
|
+
message: `Using ${text} instead of proper logging`,
|
|
1861
|
+
file: filePath,
|
|
1862
|
+
line: node.getStartLineNumber(),
|
|
1863
|
+
suggestion: "Use a proper logging library"
|
|
1864
|
+
}));
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
return violations;
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1873
|
+
// src/verification/verifiers/regex.ts
|
|
1874
|
+
var RegexVerifier = class {
|
|
1875
|
+
id = "regex";
|
|
1876
|
+
name = "Regex Pattern Verifier";
|
|
1877
|
+
description = "Verifies code against regex patterns specified in constraints";
|
|
1878
|
+
async verify(ctx) {
|
|
1879
|
+
const violations = [];
|
|
1880
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
1881
|
+
const rule = constraint.rule;
|
|
1882
|
+
const mustNotMatch = rule.match(/must\s+not\s+(?:contain|match|use)\s+\/(.+?)\//i);
|
|
1883
|
+
const shouldMatch = rule.match(/(?:should|must)\s+(?:contain|match|use)\s+\/(.+?)\//i);
|
|
1884
|
+
const forbiddenPattern = rule.match(/forbidden:\s*\/(.+?)\//i);
|
|
1885
|
+
const requiredPattern = rule.match(/required:\s*\/(.+?)\//i);
|
|
1886
|
+
const fileText = sourceFile.getFullText();
|
|
1887
|
+
const patternToForbid = mustNotMatch?.[1] || forbiddenPattern?.[1];
|
|
1888
|
+
if (patternToForbid) {
|
|
1889
|
+
try {
|
|
1890
|
+
const regex = new RegExp(patternToForbid, "g");
|
|
1891
|
+
let match;
|
|
1892
|
+
while ((match = regex.exec(fileText)) !== null) {
|
|
1893
|
+
const beforeMatch = fileText.substring(0, match.index);
|
|
1894
|
+
const lineNumber = beforeMatch.split("\n").length;
|
|
1895
|
+
violations.push(createViolation({
|
|
1896
|
+
decisionId,
|
|
1897
|
+
constraintId: constraint.id,
|
|
1898
|
+
type: constraint.type,
|
|
1899
|
+
severity: constraint.severity,
|
|
1900
|
+
message: `Found forbidden pattern: "${match[0]}"`,
|
|
1901
|
+
file: filePath,
|
|
1902
|
+
line: lineNumber,
|
|
1903
|
+
suggestion: `Remove or replace the pattern matching /${patternToForbid}/`
|
|
1904
|
+
}));
|
|
1905
|
+
}
|
|
1906
|
+
} catch {
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
const patternToRequire = shouldMatch?.[1] || requiredPattern?.[1];
|
|
1910
|
+
if (patternToRequire && !mustNotMatch) {
|
|
1911
|
+
try {
|
|
1912
|
+
const regex = new RegExp(patternToRequire);
|
|
1913
|
+
if (!regex.test(fileText)) {
|
|
1914
|
+
violations.push(createViolation({
|
|
1915
|
+
decisionId,
|
|
1916
|
+
constraintId: constraint.id,
|
|
1917
|
+
type: constraint.type,
|
|
1918
|
+
severity: constraint.severity,
|
|
1919
|
+
message: `File does not contain required pattern: /${patternToRequire}/`,
|
|
1920
|
+
file: filePath,
|
|
1921
|
+
suggestion: `Add code matching /${patternToRequire}/`
|
|
1922
|
+
}));
|
|
1923
|
+
}
|
|
1924
|
+
} catch {
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
return violations;
|
|
1928
|
+
}
|
|
1929
|
+
};
|
|
1930
|
+
|
|
1931
|
+
// src/verification/verifiers/index.ts
|
|
1932
|
+
var builtinVerifiers = {
|
|
1933
|
+
naming: () => new NamingVerifier(),
|
|
1934
|
+
imports: () => new ImportsVerifier(),
|
|
1935
|
+
errors: () => new ErrorsVerifier(),
|
|
1936
|
+
regex: () => new RegexVerifier()
|
|
1937
|
+
};
|
|
1938
|
+
function getVerifier(id) {
|
|
1939
|
+
const factory = builtinVerifiers[id];
|
|
1940
|
+
return factory ? factory() : null;
|
|
1941
|
+
}
|
|
1942
|
+
function getVerifierIds() {
|
|
1943
|
+
return Object.keys(builtinVerifiers);
|
|
1944
|
+
}
|
|
1945
|
+
function selectVerifierForConstraint(rule, specifiedVerifier) {
|
|
1946
|
+
if (specifiedVerifier) {
|
|
1947
|
+
return getVerifier(specifiedVerifier);
|
|
1948
|
+
}
|
|
1949
|
+
const lowerRule = rule.toLowerCase();
|
|
1950
|
+
if (lowerRule.includes("naming") || lowerRule.includes("case")) {
|
|
1951
|
+
return getVerifier("naming");
|
|
1952
|
+
}
|
|
1953
|
+
if (lowerRule.includes("import") || lowerRule.includes("barrel") || lowerRule.includes("alias")) {
|
|
1954
|
+
return getVerifier("imports");
|
|
1955
|
+
}
|
|
1956
|
+
if (lowerRule.includes("error") || lowerRule.includes("throw") || lowerRule.includes("catch")) {
|
|
1957
|
+
return getVerifier("errors");
|
|
1958
|
+
}
|
|
1959
|
+
if (lowerRule.includes("/") || lowerRule.includes("pattern") || lowerRule.includes("regex")) {
|
|
1960
|
+
return getVerifier("regex");
|
|
1961
|
+
}
|
|
1962
|
+
return getVerifier("regex");
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// src/verification/engine.ts
|
|
1966
|
+
var VerificationEngine = class {
|
|
1967
|
+
registry;
|
|
1968
|
+
project;
|
|
1969
|
+
constructor(registry) {
|
|
1970
|
+
this.registry = registry || createRegistry();
|
|
1971
|
+
this.project = new Project2({
|
|
1972
|
+
compilerOptions: {
|
|
1973
|
+
allowJs: true,
|
|
1974
|
+
checkJs: false,
|
|
1975
|
+
noEmit: true,
|
|
1976
|
+
skipLibCheck: true
|
|
1977
|
+
},
|
|
1978
|
+
skipAddingFilesFromTsConfig: true
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Run verification
|
|
1983
|
+
*/
|
|
1984
|
+
async verify(config, options = {}) {
|
|
1985
|
+
const startTime = Date.now();
|
|
1986
|
+
const {
|
|
1987
|
+
level = "full",
|
|
1988
|
+
files: specificFiles,
|
|
1989
|
+
decisions: decisionIds,
|
|
1990
|
+
cwd = process.cwd()
|
|
1991
|
+
} = options;
|
|
1992
|
+
const levelConfig = config.verification?.levels?.[level];
|
|
1993
|
+
const severityFilter = options.severity || levelConfig?.severity;
|
|
1994
|
+
const timeout = options.timeout || levelConfig?.timeout || 6e4;
|
|
1995
|
+
await this.registry.load();
|
|
1996
|
+
let decisions = this.registry.getActive();
|
|
1997
|
+
if (decisionIds && decisionIds.length > 0) {
|
|
1998
|
+
decisions = decisions.filter((d) => decisionIds.includes(d.metadata.id));
|
|
1999
|
+
}
|
|
2000
|
+
const filesToVerify = specificFiles ? specificFiles : await glob(config.project.sourceRoots, {
|
|
2001
|
+
cwd,
|
|
2002
|
+
ignore: config.project.exclude,
|
|
2003
|
+
absolute: true
|
|
2004
|
+
});
|
|
2005
|
+
if (filesToVerify.length === 0) {
|
|
2006
|
+
return {
|
|
2007
|
+
success: true,
|
|
2008
|
+
violations: [],
|
|
2009
|
+
checked: 0,
|
|
2010
|
+
passed: 0,
|
|
2011
|
+
failed: 0,
|
|
2012
|
+
skipped: 0,
|
|
2013
|
+
duration: Date.now() - startTime
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
const allViolations = [];
|
|
2017
|
+
let checked = 0;
|
|
2018
|
+
let passed = 0;
|
|
2019
|
+
let failed = 0;
|
|
2020
|
+
let skipped = 0;
|
|
2021
|
+
const timeoutPromise = new Promise(
|
|
2022
|
+
(resolve) => setTimeout(() => resolve("timeout"), timeout)
|
|
2023
|
+
);
|
|
2024
|
+
const verificationPromise = this.verifyFiles(
|
|
2025
|
+
filesToVerify,
|
|
2026
|
+
decisions,
|
|
2027
|
+
severityFilter,
|
|
2028
|
+
(violations) => {
|
|
2029
|
+
allViolations.push(...violations);
|
|
2030
|
+
checked++;
|
|
2031
|
+
if (violations.length > 0) {
|
|
2032
|
+
failed++;
|
|
2033
|
+
} else {
|
|
2034
|
+
passed++;
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
);
|
|
2038
|
+
const result = await Promise.race([verificationPromise, timeoutPromise]);
|
|
2039
|
+
if (result === "timeout") {
|
|
2040
|
+
return {
|
|
2041
|
+
success: false,
|
|
2042
|
+
violations: allViolations,
|
|
2043
|
+
checked,
|
|
2044
|
+
passed,
|
|
2045
|
+
failed,
|
|
2046
|
+
skipped: filesToVerify.length - checked,
|
|
2047
|
+
duration: timeout
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
const hasBlockingViolations = allViolations.some((v) => {
|
|
2051
|
+
if (level === "commit") {
|
|
2052
|
+
return v.type === "invariant" || v.severity === "critical";
|
|
2053
|
+
}
|
|
2054
|
+
if (level === "pr") {
|
|
2055
|
+
return v.type === "invariant" || v.severity === "critical" || v.severity === "high";
|
|
2056
|
+
}
|
|
2057
|
+
return v.type === "invariant";
|
|
2058
|
+
});
|
|
2059
|
+
return {
|
|
2060
|
+
success: !hasBlockingViolations,
|
|
2061
|
+
violations: allViolations,
|
|
2062
|
+
checked,
|
|
2063
|
+
passed,
|
|
2064
|
+
failed,
|
|
2065
|
+
skipped,
|
|
2066
|
+
duration: Date.now() - startTime
|
|
2067
|
+
};
|
|
2068
|
+
}
|
|
2069
|
+
/**
|
|
2070
|
+
* Verify a single file
|
|
2071
|
+
*/
|
|
2072
|
+
async verifyFile(filePath, decisions, severityFilter) {
|
|
2073
|
+
const violations = [];
|
|
2074
|
+
let sourceFile = this.project.getSourceFile(filePath);
|
|
2075
|
+
if (!sourceFile) {
|
|
2076
|
+
try {
|
|
2077
|
+
sourceFile = this.project.addSourceFileAtPath(filePath);
|
|
2078
|
+
} catch {
|
|
2079
|
+
return violations;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
for (const decision of decisions) {
|
|
2083
|
+
for (const constraint of decision.constraints) {
|
|
2084
|
+
if (!matchesPattern(filePath, constraint.scope)) {
|
|
2085
|
+
continue;
|
|
2086
|
+
}
|
|
2087
|
+
if (severityFilter && !severityFilter.includes(constraint.severity)) {
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
if (this.isExcepted(filePath, constraint)) {
|
|
2091
|
+
continue;
|
|
2092
|
+
}
|
|
2093
|
+
const verifier = selectVerifierForConstraint(constraint.rule, constraint.verifier);
|
|
2094
|
+
if (!verifier) {
|
|
2095
|
+
continue;
|
|
2096
|
+
}
|
|
2097
|
+
const ctx = {
|
|
2098
|
+
filePath,
|
|
2099
|
+
sourceFile,
|
|
2100
|
+
constraint,
|
|
2101
|
+
decisionId: decision.metadata.id
|
|
2102
|
+
};
|
|
2103
|
+
try {
|
|
2104
|
+
const constraintViolations = await verifier.verify(ctx);
|
|
2105
|
+
violations.push(...constraintViolations);
|
|
2106
|
+
} catch {
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
return violations;
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* Verify multiple files
|
|
2114
|
+
*/
|
|
2115
|
+
async verifyFiles(files, decisions, severityFilter, onFileVerified) {
|
|
2116
|
+
for (const file of files) {
|
|
2117
|
+
const violations = await this.verifyFile(file, decisions, severityFilter);
|
|
2118
|
+
onFileVerified(violations);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* Check if file is excepted from constraint
|
|
2123
|
+
*/
|
|
2124
|
+
isExcepted(filePath, constraint) {
|
|
2125
|
+
if (!constraint.exceptions) return false;
|
|
2126
|
+
return constraint.exceptions.some((exception) => {
|
|
2127
|
+
if (exception.expiresAt) {
|
|
2128
|
+
const expiryDate = new Date(exception.expiresAt);
|
|
2129
|
+
if (expiryDate < /* @__PURE__ */ new Date()) {
|
|
2130
|
+
return false;
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
return matchesPattern(filePath, exception.pattern);
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
2137
|
+
* Get registry
|
|
2138
|
+
*/
|
|
2139
|
+
getRegistry() {
|
|
2140
|
+
return this.registry;
|
|
2141
|
+
}
|
|
2142
|
+
};
|
|
2143
|
+
function createVerificationEngine(registry) {
|
|
2144
|
+
return new VerificationEngine(registry);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// src/propagation/graph.ts
|
|
2148
|
+
async function buildDependencyGraph(decisions, files) {
|
|
2149
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
2150
|
+
const decisionToFiles = /* @__PURE__ */ new Map();
|
|
2151
|
+
const fileToDecisions = /* @__PURE__ */ new Map();
|
|
2152
|
+
for (const decision of decisions) {
|
|
2153
|
+
const decisionId = `decision:${decision.metadata.id}`;
|
|
2154
|
+
nodes.set(decisionId, {
|
|
2155
|
+
type: "decision",
|
|
2156
|
+
id: decision.metadata.id,
|
|
2157
|
+
edges: decision.constraints.map((c) => `constraint:${decision.metadata.id}/${c.id}`)
|
|
2158
|
+
});
|
|
2159
|
+
for (const constraint of decision.constraints) {
|
|
2160
|
+
const constraintId = `constraint:${decision.metadata.id}/${constraint.id}`;
|
|
2161
|
+
const matchingFiles = [];
|
|
2162
|
+
for (const file of files) {
|
|
2163
|
+
if (matchesPattern(file, constraint.scope)) {
|
|
2164
|
+
matchingFiles.push(`file:${file}`);
|
|
2165
|
+
const fileDecisions = fileToDecisions.get(file) || /* @__PURE__ */ new Set();
|
|
2166
|
+
fileDecisions.add(decision.metadata.id);
|
|
2167
|
+
fileToDecisions.set(file, fileDecisions);
|
|
2168
|
+
const decFiles = decisionToFiles.get(decision.metadata.id) || /* @__PURE__ */ new Set();
|
|
2169
|
+
decFiles.add(file);
|
|
2170
|
+
decisionToFiles.set(decision.metadata.id, decFiles);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
nodes.set(constraintId, {
|
|
2174
|
+
type: "constraint",
|
|
2175
|
+
id: `${decision.metadata.id}/${constraint.id}`,
|
|
2176
|
+
edges: matchingFiles
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
for (const file of files) {
|
|
2181
|
+
const fileId = `file:${file}`;
|
|
2182
|
+
if (!nodes.has(fileId)) {
|
|
2183
|
+
nodes.set(fileId, {
|
|
2184
|
+
type: "file",
|
|
2185
|
+
id: file,
|
|
2186
|
+
edges: []
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
return {
|
|
2191
|
+
nodes,
|
|
2192
|
+
decisionToFiles,
|
|
2193
|
+
fileToDecisions
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
function getAffectedFiles(graph, decisionId) {
|
|
2197
|
+
const files = graph.decisionToFiles.get(decisionId);
|
|
2198
|
+
return files ? Array.from(files) : [];
|
|
2199
|
+
}
|
|
2200
|
+
function getAffectingDecisions(graph, filePath) {
|
|
2201
|
+
const decisions = graph.fileToDecisions.get(filePath);
|
|
2202
|
+
return decisions ? Array.from(decisions) : [];
|
|
2203
|
+
}
|
|
2204
|
+
function getTransitiveDependencies(graph, nodeId, visited = /* @__PURE__ */ new Set()) {
|
|
2205
|
+
if (visited.has(nodeId)) {
|
|
2206
|
+
return [];
|
|
2207
|
+
}
|
|
2208
|
+
visited.add(nodeId);
|
|
2209
|
+
const node = graph.nodes.get(nodeId);
|
|
2210
|
+
if (!node) {
|
|
2211
|
+
return [];
|
|
2212
|
+
}
|
|
2213
|
+
const deps = [nodeId];
|
|
2214
|
+
for (const edge of node.edges) {
|
|
2215
|
+
deps.push(...getTransitiveDependencies(graph, edge, visited));
|
|
2216
|
+
}
|
|
2217
|
+
return deps;
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// src/propagation/engine.ts
|
|
2221
|
+
var PropagationEngine = class {
|
|
2222
|
+
registry;
|
|
2223
|
+
graph = null;
|
|
2224
|
+
constructor(registry) {
|
|
2225
|
+
this.registry = registry || createRegistry();
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Initialize the engine with current state
|
|
2229
|
+
*/
|
|
2230
|
+
async initialize(config, options = {}) {
|
|
2231
|
+
const { cwd = process.cwd() } = options;
|
|
2232
|
+
await this.registry.load();
|
|
2233
|
+
const files = await glob(config.project.sourceRoots, {
|
|
2234
|
+
cwd,
|
|
2235
|
+
ignore: config.project.exclude,
|
|
2236
|
+
absolute: true
|
|
2237
|
+
});
|
|
2238
|
+
const decisions = this.registry.getActive();
|
|
2239
|
+
this.graph = await buildDependencyGraph(decisions, files);
|
|
2240
|
+
}
|
|
2241
|
+
/**
|
|
2242
|
+
* Analyze impact of changing a decision
|
|
2243
|
+
*/
|
|
2244
|
+
async analyzeImpact(decisionId, change, config, options = {}) {
|
|
2245
|
+
const { cwd = process.cwd() } = options;
|
|
2246
|
+
if (!this.graph) {
|
|
2247
|
+
await this.initialize(config, options);
|
|
2248
|
+
}
|
|
2249
|
+
const affectedFilePaths = getAffectedFiles(this.graph, decisionId);
|
|
2250
|
+
const verificationEngine = createVerificationEngine(this.registry);
|
|
2251
|
+
const result = await verificationEngine.verify(config, {
|
|
2252
|
+
files: affectedFilePaths,
|
|
2253
|
+
decisions: [decisionId],
|
|
2254
|
+
cwd
|
|
2255
|
+
});
|
|
2256
|
+
const fileViolations = /* @__PURE__ */ new Map();
|
|
2257
|
+
for (const violation of result.violations) {
|
|
2258
|
+
const existing = fileViolations.get(violation.file) || { total: 0, autoFixable: 0 };
|
|
2259
|
+
existing.total++;
|
|
2260
|
+
if (violation.autofix) {
|
|
2261
|
+
existing.autoFixable++;
|
|
2262
|
+
}
|
|
2263
|
+
fileViolations.set(violation.file, existing);
|
|
2264
|
+
}
|
|
2265
|
+
const affectedFiles = affectedFilePaths.map((path) => ({
|
|
2266
|
+
path,
|
|
2267
|
+
violations: fileViolations.get(path)?.total || 0,
|
|
2268
|
+
autoFixable: fileViolations.get(path)?.autoFixable || 0
|
|
2269
|
+
}));
|
|
2270
|
+
affectedFiles.sort((a, b) => b.violations - a.violations);
|
|
2271
|
+
const totalViolations = result.violations.length;
|
|
2272
|
+
const totalAutoFixable = result.violations.filter((v) => v.autofix).length;
|
|
2273
|
+
const manualFixes = totalViolations - totalAutoFixable;
|
|
2274
|
+
let estimatedEffort;
|
|
2275
|
+
if (manualFixes === 0) {
|
|
2276
|
+
estimatedEffort = "low";
|
|
2277
|
+
} else if (manualFixes <= 10) {
|
|
2278
|
+
estimatedEffort = "medium";
|
|
2279
|
+
} else {
|
|
2280
|
+
estimatedEffort = "high";
|
|
2281
|
+
}
|
|
2282
|
+
const migrationSteps = this.generateMigrationSteps(
|
|
2283
|
+
affectedFiles,
|
|
2284
|
+
totalAutoFixable > 0
|
|
2285
|
+
);
|
|
2286
|
+
return {
|
|
2287
|
+
decision: decisionId,
|
|
2288
|
+
change,
|
|
2289
|
+
affectedFiles,
|
|
2290
|
+
estimatedEffort,
|
|
2291
|
+
migrationSteps
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Generate migration steps
|
|
2296
|
+
*/
|
|
2297
|
+
generateMigrationSteps(affectedFiles, hasAutoFixes) {
|
|
2298
|
+
const steps = [];
|
|
2299
|
+
let order = 1;
|
|
2300
|
+
if (hasAutoFixes) {
|
|
2301
|
+
steps.push({
|
|
2302
|
+
order: order++,
|
|
2303
|
+
description: "Run auto-fix for mechanical violations",
|
|
2304
|
+
files: affectedFiles.filter((f) => f.autoFixable > 0).map((f) => f.path),
|
|
2305
|
+
automated: true
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
const filesWithManualFixes = affectedFiles.filter(
|
|
2309
|
+
(f) => f.violations > f.autoFixable
|
|
2310
|
+
);
|
|
2311
|
+
if (filesWithManualFixes.length > 0) {
|
|
2312
|
+
const highPriority = filesWithManualFixes.filter((f) => f.violations > 5);
|
|
2313
|
+
const mediumPriority = filesWithManualFixes.filter(
|
|
2314
|
+
(f) => f.violations <= 5 && f.violations > 1
|
|
2315
|
+
);
|
|
2316
|
+
const lowPriority = filesWithManualFixes.filter((f) => f.violations === 1);
|
|
2317
|
+
if (highPriority.length > 0) {
|
|
2318
|
+
steps.push({
|
|
2319
|
+
order: order++,
|
|
2320
|
+
description: "Fix high-violation files first",
|
|
2321
|
+
files: highPriority.map((f) => f.path),
|
|
2322
|
+
automated: false
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
if (mediumPriority.length > 0) {
|
|
2326
|
+
steps.push({
|
|
2327
|
+
order: order++,
|
|
2328
|
+
description: "Fix medium-violation files",
|
|
2329
|
+
files: mediumPriority.map((f) => f.path),
|
|
2330
|
+
automated: false
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
if (lowPriority.length > 0) {
|
|
2334
|
+
steps.push({
|
|
2335
|
+
order: order++,
|
|
2336
|
+
description: "Fix remaining files",
|
|
2337
|
+
files: lowPriority.map((f) => f.path),
|
|
2338
|
+
automated: false
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
steps.push({
|
|
2343
|
+
order: order++,
|
|
2344
|
+
description: "Run verification to confirm all violations resolved",
|
|
2345
|
+
files: [],
|
|
2346
|
+
automated: true
|
|
2347
|
+
});
|
|
2348
|
+
return steps;
|
|
2349
|
+
}
|
|
2350
|
+
/**
|
|
2351
|
+
* Get dependency graph
|
|
2352
|
+
*/
|
|
2353
|
+
getGraph() {
|
|
2354
|
+
return this.graph;
|
|
2355
|
+
}
|
|
2356
|
+
};
|
|
2357
|
+
function createPropagationEngine(registry) {
|
|
2358
|
+
return new PropagationEngine(registry);
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// src/reporting/reporter.ts
|
|
2362
|
+
async function generateReport(config, options = {}) {
|
|
2363
|
+
const { includeAll = false, cwd = process.cwd() } = options;
|
|
2364
|
+
const registry = createRegistry({ basePath: cwd });
|
|
2365
|
+
await registry.load();
|
|
2366
|
+
const engine = createVerificationEngine(registry);
|
|
2367
|
+
const result = await engine.verify(config, { level: "full", cwd });
|
|
2368
|
+
const decisions = includeAll ? registry.getAll() : registry.getActive();
|
|
2369
|
+
const byDecision = [];
|
|
2370
|
+
for (const decision of decisions) {
|
|
2371
|
+
const decisionViolations = result.violations.filter(
|
|
2372
|
+
(v) => v.decisionId === decision.metadata.id
|
|
2373
|
+
);
|
|
2374
|
+
const constraintCount = decision.constraints.length;
|
|
2375
|
+
const violationCount = decisionViolations.length;
|
|
2376
|
+
const compliance = violationCount === 0 ? 100 : Math.max(0, 100 - Math.min(violationCount * 10, 100));
|
|
2377
|
+
byDecision.push({
|
|
2378
|
+
decisionId: decision.metadata.id,
|
|
2379
|
+
title: decision.metadata.title,
|
|
2380
|
+
status: decision.metadata.status,
|
|
2381
|
+
constraints: constraintCount,
|
|
2382
|
+
violations: violationCount,
|
|
2383
|
+
compliance
|
|
2384
|
+
});
|
|
2385
|
+
}
|
|
2386
|
+
byDecision.sort((a, b) => a.compliance - b.compliance);
|
|
2387
|
+
const totalDecisions = decisions.length;
|
|
2388
|
+
const activeDecisions = decisions.filter((d) => d.metadata.status === "active").length;
|
|
2389
|
+
const totalConstraints = decisions.reduce((sum, d) => sum + d.constraints.length, 0);
|
|
2390
|
+
const violationsBySeverity = {
|
|
2391
|
+
critical: result.violations.filter((v) => v.severity === "critical").length,
|
|
2392
|
+
high: result.violations.filter((v) => v.severity === "high").length,
|
|
2393
|
+
medium: result.violations.filter((v) => v.severity === "medium").length,
|
|
2394
|
+
low: result.violations.filter((v) => v.severity === "low").length
|
|
2395
|
+
};
|
|
2396
|
+
const overallCompliance = byDecision.length > 0 ? Math.round(byDecision.reduce((sum, d) => sum + d.compliance, 0) / byDecision.length) : 100;
|
|
2397
|
+
return {
|
|
2398
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2399
|
+
project: config.project.name,
|
|
2400
|
+
summary: {
|
|
2401
|
+
totalDecisions,
|
|
2402
|
+
activeDecisions,
|
|
2403
|
+
totalConstraints,
|
|
2404
|
+
violations: violationsBySeverity,
|
|
2405
|
+
compliance: overallCompliance
|
|
2406
|
+
},
|
|
2407
|
+
byDecision
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
function checkDegradation(current, previous) {
|
|
2411
|
+
if (!previous) {
|
|
2412
|
+
return { degraded: false, details: [] };
|
|
2413
|
+
}
|
|
2414
|
+
const details = [];
|
|
2415
|
+
let degraded = false;
|
|
2416
|
+
if (current.summary.compliance < previous.summary.compliance) {
|
|
2417
|
+
degraded = true;
|
|
2418
|
+
details.push(
|
|
2419
|
+
`Overall compliance dropped from ${previous.summary.compliance}% to ${current.summary.compliance}%`
|
|
2420
|
+
);
|
|
2421
|
+
}
|
|
2422
|
+
const newCritical = current.summary.violations.critical - previous.summary.violations.critical;
|
|
2423
|
+
const newHigh = current.summary.violations.high - previous.summary.violations.high;
|
|
2424
|
+
if (newCritical > 0) {
|
|
2425
|
+
degraded = true;
|
|
2426
|
+
details.push(`${newCritical} new critical violation(s)`);
|
|
2427
|
+
}
|
|
2428
|
+
if (newHigh > 0) {
|
|
2429
|
+
degraded = true;
|
|
2430
|
+
details.push(`${newHigh} new high severity violation(s)`);
|
|
2431
|
+
}
|
|
2432
|
+
return { degraded, details };
|
|
2433
|
+
}
|
|
2434
|
+
var Reporter = class {
|
|
2435
|
+
/**
|
|
2436
|
+
* Generate formatted report from verification result
|
|
2437
|
+
*/
|
|
2438
|
+
generate(result, options = {}) {
|
|
2439
|
+
const { format = "table", groupBy } = options;
|
|
2440
|
+
switch (format) {
|
|
2441
|
+
case "json":
|
|
2442
|
+
return JSON.stringify(result, null, 2);
|
|
2443
|
+
case "markdown":
|
|
2444
|
+
return this.formatAsMarkdown(result);
|
|
2445
|
+
case "table":
|
|
2446
|
+
default:
|
|
2447
|
+
return groupBy ? this.formatAsTableGrouped(result, groupBy) : this.formatAsTable(result);
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
/**
|
|
2451
|
+
* Generate compliance overview from multiple results
|
|
2452
|
+
*/
|
|
2453
|
+
generateComplianceReport(results) {
|
|
2454
|
+
const lines = [];
|
|
2455
|
+
lines.push("# Compliance Report\n");
|
|
2456
|
+
if (results.length === 0) {
|
|
2457
|
+
lines.push("No results to report.\n");
|
|
2458
|
+
return lines.join("\n");
|
|
2459
|
+
}
|
|
2460
|
+
const totalViolations = results.reduce(
|
|
2461
|
+
(sum, r) => sum + (r.summary?.totalViolations || r.violations?.length || 0),
|
|
2462
|
+
0
|
|
2463
|
+
);
|
|
2464
|
+
const avgViolations = totalViolations / results.length;
|
|
2465
|
+
lines.push(`## Overall Statistics
|
|
2466
|
+
`);
|
|
2467
|
+
lines.push(`- Total Results: ${results.length}`);
|
|
2468
|
+
lines.push(`- Total Violations: ${totalViolations}`);
|
|
2469
|
+
lines.push(`- Average Violations per Result: ${avgViolations.toFixed(1)}`);
|
|
2470
|
+
const complianceRate = results.length > 0 ? results.filter((r) => (r.violations?.length || 0) === 0).length / results.length * 100 : 100;
|
|
2471
|
+
lines.push(`- Compliance Rate: ${complianceRate.toFixed(1)}%
|
|
2472
|
+
`);
|
|
2473
|
+
return lines.join("\n");
|
|
2474
|
+
}
|
|
2475
|
+
formatAsTable(result) {
|
|
2476
|
+
const lines = [];
|
|
2477
|
+
lines.push("Verification Report");
|
|
2478
|
+
lines.push("=".repeat(50));
|
|
2479
|
+
lines.push("");
|
|
2480
|
+
if (result.summary) {
|
|
2481
|
+
lines.push("Summary:");
|
|
2482
|
+
lines.push(` Decisions Checked: ${result.summary.decisionsChecked || 0}`);
|
|
2483
|
+
lines.push(` Files Checked: ${result.summary.filesChecked || 0}`);
|
|
2484
|
+
lines.push(` Total Violations: ${result.summary.totalViolations || result.violations?.length || 0}`);
|
|
2485
|
+
lines.push(` Critical: ${result.summary.critical || 0}`);
|
|
2486
|
+
lines.push(` High: ${result.summary.high || 0}`);
|
|
2487
|
+
lines.push(` Medium: ${result.summary.medium || 0}`);
|
|
2488
|
+
lines.push(` Low: ${result.summary.low || 0}`);
|
|
2489
|
+
lines.push(` Duration: ${result.summary.duration || 0}ms`);
|
|
2490
|
+
lines.push("");
|
|
2491
|
+
}
|
|
2492
|
+
const totalViolations = result.summary?.totalViolations ?? result.violations?.length ?? 0;
|
|
2493
|
+
if (totalViolations > 0 && result.violations && result.violations.length > 0) {
|
|
2494
|
+
lines.push("Violations:");
|
|
2495
|
+
lines.push("-".repeat(50));
|
|
2496
|
+
result.violations.forEach((v) => {
|
|
2497
|
+
const severity = v.severity.toLowerCase();
|
|
2498
|
+
lines.push(` [${v.severity.toUpperCase()}] ${v.decisionId} - ${v.constraintId} (${severity})`);
|
|
2499
|
+
lines.push(` ${v.message}`);
|
|
2500
|
+
lines.push(` Location: ${v.location?.file || v.file}:${v.location?.line || 0}:${v.location?.column || 0}`);
|
|
2501
|
+
lines.push("");
|
|
2502
|
+
});
|
|
2503
|
+
} else {
|
|
2504
|
+
lines.push("No violations found.");
|
|
2505
|
+
lines.push("");
|
|
2506
|
+
}
|
|
2507
|
+
return lines.join("\n");
|
|
2508
|
+
}
|
|
2509
|
+
formatAsTableGrouped(result, groupBy) {
|
|
2510
|
+
const lines = [];
|
|
2511
|
+
lines.push("Verification Report");
|
|
2512
|
+
lines.push("=".repeat(50));
|
|
2513
|
+
lines.push("");
|
|
2514
|
+
if (result.summary) {
|
|
2515
|
+
lines.push("Summary:");
|
|
2516
|
+
lines.push(` Total Violations: ${result.summary.totalViolations || result.violations?.length || 0}`);
|
|
2517
|
+
lines.push("");
|
|
2518
|
+
}
|
|
2519
|
+
if (result.violations && result.violations.length > 0) {
|
|
2520
|
+
if (groupBy === "severity") {
|
|
2521
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
2522
|
+
result.violations.forEach((v) => {
|
|
2523
|
+
const key = v.severity;
|
|
2524
|
+
if (!grouped.has(key)) grouped.set(key, []);
|
|
2525
|
+
grouped.get(key).push(v);
|
|
2526
|
+
});
|
|
2527
|
+
for (const [severity, violations] of grouped.entries()) {
|
|
2528
|
+
lines.push(`Severity: ${severity}`);
|
|
2529
|
+
lines.push("-".repeat(30));
|
|
2530
|
+
violations.forEach((v) => {
|
|
2531
|
+
lines.push(` ${v.decisionId} - ${v.message}`);
|
|
2532
|
+
});
|
|
2533
|
+
lines.push("");
|
|
2534
|
+
}
|
|
2535
|
+
} else if (groupBy === "file") {
|
|
2536
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
2537
|
+
result.violations.forEach((v) => {
|
|
2538
|
+
const key = v.location?.file || v.file || "unknown";
|
|
2539
|
+
if (!grouped.has(key)) grouped.set(key, []);
|
|
2540
|
+
grouped.get(key).push(v);
|
|
2541
|
+
});
|
|
2542
|
+
for (const [file, violations] of grouped.entries()) {
|
|
2543
|
+
lines.push(`File: ${file}`);
|
|
2544
|
+
lines.push("-".repeat(30));
|
|
2545
|
+
violations.forEach((v) => {
|
|
2546
|
+
lines.push(` [${v.severity}] ${v.message}`);
|
|
2547
|
+
});
|
|
2548
|
+
lines.push("");
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
} else {
|
|
2552
|
+
lines.push("No violations found.");
|
|
2553
|
+
lines.push("");
|
|
2554
|
+
}
|
|
2555
|
+
return lines.join("\n");
|
|
2556
|
+
}
|
|
2557
|
+
formatAsMarkdown(result) {
|
|
2558
|
+
const lines = [];
|
|
2559
|
+
lines.push("## Verification Report\n");
|
|
2560
|
+
if (result.summary) {
|
|
2561
|
+
lines.push("### Summary\n");
|
|
2562
|
+
lines.push(`- **Decisions Checked:** ${result.summary.decisionsChecked || 0}`);
|
|
2563
|
+
lines.push(`- **Files Checked:** ${result.summary.filesChecked || 0}`);
|
|
2564
|
+
lines.push(`- **Total Violations:** ${result.summary.totalViolations || result.violations?.length || 0}`);
|
|
2565
|
+
lines.push(`- **Critical:** ${result.summary.critical || 0}`);
|
|
2566
|
+
lines.push(`- **High:** ${result.summary.high || 0}`);
|
|
2567
|
+
lines.push(`- **Medium:** ${result.summary.medium || 0}`);
|
|
2568
|
+
lines.push(`- **Low:** ${result.summary.low || 0}
|
|
2569
|
+
`);
|
|
2570
|
+
}
|
|
2571
|
+
if (result.violations && result.violations.length > 0) {
|
|
2572
|
+
lines.push("### Violations\n");
|
|
2573
|
+
result.violations.forEach((v) => {
|
|
2574
|
+
lines.push(`#### [${v.severity.toUpperCase()}] ${v.decisionId}`);
|
|
2575
|
+
lines.push(`**Message:** ${v.message}`);
|
|
2576
|
+
lines.push(`**Location:** \`${v.location?.file || v.file}:${v.location?.line || 0}\`
|
|
2577
|
+
`);
|
|
2578
|
+
});
|
|
2579
|
+
} else {
|
|
2580
|
+
lines.push("No violations found.\n");
|
|
2581
|
+
}
|
|
2582
|
+
return lines.join("\n");
|
|
2583
|
+
}
|
|
2584
|
+
};
|
|
2585
|
+
|
|
2586
|
+
// src/reporting/formats/console.ts
|
|
2587
|
+
import chalk from "chalk";
|
|
2588
|
+
import { table } from "table";
|
|
2589
|
+
function formatConsoleReport(report) {
|
|
2590
|
+
const lines = [];
|
|
2591
|
+
lines.push("");
|
|
2592
|
+
lines.push(chalk.bold.blue("SpecBridge Compliance Report"));
|
|
2593
|
+
lines.push(chalk.dim(`Generated: ${new Date(report.timestamp).toLocaleString()}`));
|
|
2594
|
+
lines.push(chalk.dim(`Project: ${report.project}`));
|
|
2595
|
+
lines.push("");
|
|
2596
|
+
const complianceColor = getComplianceColor(report.summary.compliance);
|
|
2597
|
+
lines.push(chalk.bold("Overall Compliance"));
|
|
2598
|
+
lines.push(` ${complianceColor(formatComplianceBar(report.summary.compliance))} ${complianceColor(`${report.summary.compliance}%`)}`);
|
|
2599
|
+
lines.push("");
|
|
2600
|
+
lines.push(chalk.bold("Summary"));
|
|
2601
|
+
lines.push(` Decisions: ${report.summary.activeDecisions} active / ${report.summary.totalDecisions} total`);
|
|
2602
|
+
lines.push(` Constraints: ${report.summary.totalConstraints}`);
|
|
2603
|
+
lines.push("");
|
|
2604
|
+
lines.push(chalk.bold("Violations"));
|
|
2605
|
+
const { violations } = report.summary;
|
|
2606
|
+
const violationParts = [];
|
|
2607
|
+
if (violations.critical > 0) {
|
|
2608
|
+
violationParts.push(chalk.red(`${violations.critical} critical`));
|
|
2609
|
+
}
|
|
2610
|
+
if (violations.high > 0) {
|
|
2611
|
+
violationParts.push(chalk.yellow(`${violations.high} high`));
|
|
2612
|
+
}
|
|
2613
|
+
if (violations.medium > 0) {
|
|
2614
|
+
violationParts.push(chalk.cyan(`${violations.medium} medium`));
|
|
2615
|
+
}
|
|
2616
|
+
if (violations.low > 0) {
|
|
2617
|
+
violationParts.push(chalk.dim(`${violations.low} low`));
|
|
2618
|
+
}
|
|
2619
|
+
if (violationParts.length > 0) {
|
|
2620
|
+
lines.push(` ${violationParts.join(" | ")}`);
|
|
2621
|
+
} else {
|
|
2622
|
+
lines.push(chalk.green(" No violations"));
|
|
2623
|
+
}
|
|
2624
|
+
lines.push("");
|
|
2625
|
+
if (report.byDecision.length > 0) {
|
|
2626
|
+
lines.push(chalk.bold("By Decision"));
|
|
2627
|
+
lines.push("");
|
|
2628
|
+
const tableData = [
|
|
2629
|
+
[
|
|
2630
|
+
chalk.bold("Decision"),
|
|
2631
|
+
chalk.bold("Status"),
|
|
2632
|
+
chalk.bold("Constraints"),
|
|
2633
|
+
chalk.bold("Violations"),
|
|
2634
|
+
chalk.bold("Compliance")
|
|
2635
|
+
]
|
|
2636
|
+
];
|
|
2637
|
+
for (const dec of report.byDecision) {
|
|
2638
|
+
const compColor = getComplianceColor(dec.compliance);
|
|
2639
|
+
const statusColor = getStatusColor(dec.status);
|
|
2640
|
+
tableData.push([
|
|
2641
|
+
truncate(dec.title, 40),
|
|
2642
|
+
statusColor(dec.status),
|
|
2643
|
+
String(dec.constraints),
|
|
2644
|
+
dec.violations > 0 ? chalk.red(String(dec.violations)) : chalk.green("0"),
|
|
2645
|
+
compColor(`${dec.compliance}%`)
|
|
2646
|
+
]);
|
|
2647
|
+
}
|
|
2648
|
+
const tableOutput = table(tableData, {
|
|
2649
|
+
border: {
|
|
2650
|
+
topBody: "",
|
|
2651
|
+
topJoin: "",
|
|
2652
|
+
topLeft: "",
|
|
2653
|
+
topRight: "",
|
|
2654
|
+
bottomBody: "",
|
|
2655
|
+
bottomJoin: "",
|
|
2656
|
+
bottomLeft: "",
|
|
2657
|
+
bottomRight: "",
|
|
2658
|
+
bodyLeft: " ",
|
|
2659
|
+
bodyRight: "",
|
|
2660
|
+
bodyJoin: " ",
|
|
2661
|
+
joinBody: "",
|
|
2662
|
+
joinLeft: "",
|
|
2663
|
+
joinRight: "",
|
|
2664
|
+
joinJoin: ""
|
|
2665
|
+
},
|
|
2666
|
+
drawHorizontalLine: (index) => index === 1
|
|
2667
|
+
});
|
|
2668
|
+
lines.push(tableOutput);
|
|
2669
|
+
}
|
|
2670
|
+
return lines.join("\n");
|
|
2671
|
+
}
|
|
2672
|
+
function formatComplianceBar(compliance) {
|
|
2673
|
+
const filled = Math.round(compliance / 10);
|
|
2674
|
+
const empty = 10 - filled;
|
|
2675
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(empty);
|
|
2676
|
+
}
|
|
2677
|
+
function getComplianceColor(compliance) {
|
|
2678
|
+
if (compliance >= 90) return chalk.green;
|
|
2679
|
+
if (compliance >= 70) return chalk.yellow;
|
|
2680
|
+
if (compliance >= 50) return chalk.hex("#FFA500");
|
|
2681
|
+
return chalk.red;
|
|
2682
|
+
}
|
|
2683
|
+
function getStatusColor(status) {
|
|
2684
|
+
switch (status) {
|
|
2685
|
+
case "active":
|
|
2686
|
+
return chalk.green;
|
|
2687
|
+
case "draft":
|
|
2688
|
+
return chalk.yellow;
|
|
2689
|
+
case "deprecated":
|
|
2690
|
+
return chalk.gray;
|
|
2691
|
+
case "superseded":
|
|
2692
|
+
return chalk.blue;
|
|
2693
|
+
default:
|
|
2694
|
+
return chalk.white;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
function truncate(str, length) {
|
|
2698
|
+
if (str.length <= length) return str;
|
|
2699
|
+
return str.slice(0, length - 3) + "...";
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
// src/reporting/formats/markdown.ts
|
|
2703
|
+
function formatMarkdownReport(report) {
|
|
2704
|
+
const lines = [];
|
|
2705
|
+
lines.push("# SpecBridge Compliance Report");
|
|
2706
|
+
lines.push("");
|
|
2707
|
+
lines.push(`**Project:** ${report.project}`);
|
|
2708
|
+
lines.push(`**Generated:** ${new Date(report.timestamp).toLocaleString()}`);
|
|
2709
|
+
lines.push("");
|
|
2710
|
+
lines.push("## Overall Compliance");
|
|
2711
|
+
lines.push("");
|
|
2712
|
+
lines.push(`**Score:** ${report.summary.compliance}%`);
|
|
2713
|
+
lines.push("");
|
|
2714
|
+
lines.push(formatProgressBar(report.summary.compliance));
|
|
2715
|
+
lines.push("");
|
|
2716
|
+
lines.push("## Summary");
|
|
2717
|
+
lines.push("");
|
|
2718
|
+
lines.push(`- **Active Decisions:** ${report.summary.activeDecisions} / ${report.summary.totalDecisions}`);
|
|
2719
|
+
lines.push(`- **Total Constraints:** ${report.summary.totalConstraints}`);
|
|
2720
|
+
lines.push("");
|
|
2721
|
+
lines.push("### Violations");
|
|
2722
|
+
lines.push("");
|
|
2723
|
+
const { violations } = report.summary;
|
|
2724
|
+
const totalViolations = violations.critical + violations.high + violations.medium + violations.low;
|
|
2725
|
+
if (totalViolations === 0) {
|
|
2726
|
+
lines.push("No violations found.");
|
|
2727
|
+
} else {
|
|
2728
|
+
lines.push(`| Severity | Count |`);
|
|
2729
|
+
lines.push(`|----------|-------|`);
|
|
2730
|
+
lines.push(`| Critical | ${violations.critical} |`);
|
|
2731
|
+
lines.push(`| High | ${violations.high} |`);
|
|
2732
|
+
lines.push(`| Medium | ${violations.medium} |`);
|
|
2733
|
+
lines.push(`| Low | ${violations.low} |`);
|
|
2734
|
+
lines.push(`| **Total** | **${totalViolations}** |`);
|
|
2735
|
+
}
|
|
2736
|
+
lines.push("");
|
|
2737
|
+
if (report.byDecision.length > 0) {
|
|
2738
|
+
lines.push("## By Decision");
|
|
2739
|
+
lines.push("");
|
|
2740
|
+
lines.push("| Decision | Status | Constraints | Violations | Compliance |");
|
|
2741
|
+
lines.push("|----------|--------|-------------|------------|------------|");
|
|
2742
|
+
for (const dec of report.byDecision) {
|
|
2743
|
+
const complianceEmoji = dec.compliance >= 90 ? "" : dec.compliance >= 70 ? "" : "";
|
|
2744
|
+
lines.push(
|
|
2745
|
+
`| ${dec.title} | ${dec.status} | ${dec.constraints} | ${dec.violations} | ${complianceEmoji} ${dec.compliance}% |`
|
|
2746
|
+
);
|
|
2747
|
+
}
|
|
2748
|
+
lines.push("");
|
|
2749
|
+
}
|
|
2750
|
+
lines.push("---");
|
|
2751
|
+
lines.push("");
|
|
2752
|
+
lines.push("*Generated by [SpecBridge](https://github.com/specbridge/specbridge)*");
|
|
2753
|
+
return lines.join("\n");
|
|
2754
|
+
}
|
|
2755
|
+
function formatProgressBar(percentage) {
|
|
2756
|
+
const width = 20;
|
|
2757
|
+
const filled = Math.round(percentage / 100 * width);
|
|
2758
|
+
const empty = width - filled;
|
|
2759
|
+
const filledChar = "";
|
|
2760
|
+
const emptyChar = "";
|
|
2761
|
+
return `\`${filledChar.repeat(filled)}${emptyChar.repeat(empty)}\` ${percentage}%`;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
// src/agent/context.generator.ts
|
|
2765
|
+
async function generateContext(filePath, config, options = {}) {
|
|
2766
|
+
const { includeRationale = config.agent?.includeRationale ?? true, cwd = process.cwd() } = options;
|
|
2767
|
+
const registry = createRegistry({ basePath: cwd });
|
|
2768
|
+
await registry.load();
|
|
2769
|
+
const decisions = registry.getActive();
|
|
2770
|
+
const applicableDecisions = [];
|
|
2771
|
+
for (const decision of decisions) {
|
|
2772
|
+
const applicableConstraints = [];
|
|
2773
|
+
for (const constraint of decision.constraints) {
|
|
2774
|
+
if (matchesPattern(filePath, constraint.scope)) {
|
|
2775
|
+
applicableConstraints.push({
|
|
2776
|
+
id: constraint.id,
|
|
2777
|
+
type: constraint.type,
|
|
2778
|
+
rule: constraint.rule,
|
|
2779
|
+
severity: constraint.severity
|
|
2780
|
+
});
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
if (applicableConstraints.length > 0) {
|
|
2784
|
+
applicableDecisions.push({
|
|
2785
|
+
id: decision.metadata.id,
|
|
2786
|
+
title: decision.metadata.title,
|
|
2787
|
+
summary: includeRationale ? decision.decision.summary : "",
|
|
2788
|
+
constraints: applicableConstraints
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
return {
|
|
2793
|
+
file: filePath,
|
|
2794
|
+
applicableDecisions,
|
|
2795
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
function formatContextAsMarkdown(context) {
|
|
2799
|
+
const lines = [];
|
|
2800
|
+
lines.push("# Architectural Constraints");
|
|
2801
|
+
lines.push("");
|
|
2802
|
+
lines.push(`File: \`${context.file}\``);
|
|
2803
|
+
lines.push("");
|
|
2804
|
+
if (context.applicableDecisions.length === 0) {
|
|
2805
|
+
lines.push("No specific architectural constraints apply to this file.");
|
|
2806
|
+
return lines.join("\n");
|
|
2807
|
+
}
|
|
2808
|
+
lines.push("The following architectural decisions apply to this file:");
|
|
2809
|
+
lines.push("");
|
|
2810
|
+
for (const decision of context.applicableDecisions) {
|
|
2811
|
+
lines.push(`## ${decision.title}`);
|
|
2812
|
+
lines.push("");
|
|
2813
|
+
if (decision.summary) {
|
|
2814
|
+
lines.push(decision.summary);
|
|
2815
|
+
lines.push("");
|
|
2816
|
+
}
|
|
2817
|
+
lines.push("### Constraints");
|
|
2818
|
+
lines.push("");
|
|
2819
|
+
for (const constraint of decision.constraints) {
|
|
2820
|
+
const typeEmoji = constraint.type === "invariant" ? "" : constraint.type === "convention" ? "" : "";
|
|
2821
|
+
const severityBadge = `[${constraint.severity.toUpperCase()}]`;
|
|
2822
|
+
lines.push(`- ${typeEmoji} **${severityBadge}** ${constraint.rule}`);
|
|
2823
|
+
}
|
|
2824
|
+
lines.push("");
|
|
2825
|
+
}
|
|
2826
|
+
lines.push("---");
|
|
2827
|
+
lines.push("");
|
|
2828
|
+
lines.push("Please ensure your code complies with these constraints.");
|
|
2829
|
+
return lines.join("\n");
|
|
2830
|
+
}
|
|
2831
|
+
function formatContextAsJson(context) {
|
|
2832
|
+
return JSON.stringify(context, null, 2);
|
|
2833
|
+
}
|
|
2834
|
+
function formatContextAsMcp(context) {
|
|
2835
|
+
return {
|
|
2836
|
+
type: "architectural_context",
|
|
2837
|
+
version: "1.0",
|
|
2838
|
+
file: context.file,
|
|
2839
|
+
timestamp: context.generatedAt,
|
|
2840
|
+
decisions: context.applicableDecisions.map((d) => ({
|
|
2841
|
+
id: d.id,
|
|
2842
|
+
title: d.title,
|
|
2843
|
+
summary: d.summary,
|
|
2844
|
+
constraints: d.constraints.map((c) => ({
|
|
2845
|
+
id: c.id,
|
|
2846
|
+
type: c.type,
|
|
2847
|
+
severity: c.severity,
|
|
2848
|
+
rule: c.rule
|
|
2849
|
+
}))
|
|
2850
|
+
}))
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
async function generateFormattedContext(filePath, config, options = {}) {
|
|
2854
|
+
const context = await generateContext(filePath, config, options);
|
|
2855
|
+
const format = options.format || config.agent?.format || "markdown";
|
|
2856
|
+
switch (format) {
|
|
2857
|
+
case "json":
|
|
2858
|
+
return formatContextAsJson(context);
|
|
2859
|
+
case "mcp":
|
|
2860
|
+
return JSON.stringify(formatContextAsMcp(context), null, 2);
|
|
2861
|
+
case "markdown":
|
|
2862
|
+
default:
|
|
2863
|
+
return formatContextAsMarkdown(context);
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
var AgentContextGenerator = class {
|
|
2867
|
+
/**
|
|
2868
|
+
* Generate context from decisions
|
|
2869
|
+
*/
|
|
2870
|
+
generateContext(options) {
|
|
2871
|
+
const { decisions, filePattern, format = "markdown", concise = false, minSeverity } = options;
|
|
2872
|
+
const activeDecisions = decisions.filter((d) => d.metadata.status !== "deprecated");
|
|
2873
|
+
let filteredDecisions = activeDecisions;
|
|
2874
|
+
if (filePattern) {
|
|
2875
|
+
filteredDecisions = activeDecisions.filter(
|
|
2876
|
+
(d) => d.constraints.some((c) => matchesPattern(filePattern, c.scope))
|
|
2877
|
+
);
|
|
2878
|
+
}
|
|
2879
|
+
if (minSeverity) {
|
|
2880
|
+
const severityOrder = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
2881
|
+
const minLevel = severityOrder[minSeverity] || 0;
|
|
2882
|
+
filteredDecisions = filteredDecisions.map((d) => ({
|
|
2883
|
+
...d,
|
|
2884
|
+
constraints: d.constraints.filter((c) => {
|
|
2885
|
+
const level = severityOrder[c.severity] || 0;
|
|
2886
|
+
return level >= minLevel;
|
|
2887
|
+
})
|
|
2888
|
+
})).filter((d) => d.constraints.length > 0);
|
|
2889
|
+
}
|
|
2890
|
+
if (filteredDecisions.length === 0) {
|
|
2891
|
+
return "No architectural decisions apply.";
|
|
2892
|
+
}
|
|
2893
|
+
if (format === "json") {
|
|
2894
|
+
return JSON.stringify({ decisions: filteredDecisions }, null, 2);
|
|
2895
|
+
}
|
|
2896
|
+
const lines = [];
|
|
2897
|
+
if (format === "markdown") {
|
|
2898
|
+
lines.push("# Architectural Decisions\n");
|
|
2899
|
+
for (const decision of filteredDecisions) {
|
|
2900
|
+
lines.push(`## ${decision.metadata.title}`);
|
|
2901
|
+
if (!concise && decision.decision.summary) {
|
|
2902
|
+
lines.push(`
|
|
2903
|
+
${decision.decision.summary}
|
|
2904
|
+
`);
|
|
2905
|
+
}
|
|
2906
|
+
lines.push("### Constraints\n");
|
|
2907
|
+
for (const constraint of decision.constraints) {
|
|
2908
|
+
lines.push(`- **[${constraint.severity.toUpperCase()}]** ${constraint.rule}`);
|
|
2909
|
+
}
|
|
2910
|
+
lines.push("");
|
|
2911
|
+
}
|
|
2912
|
+
} else {
|
|
2913
|
+
for (const decision of filteredDecisions) {
|
|
2914
|
+
lines.push(`${decision.metadata.title}`);
|
|
2915
|
+
if (!concise && decision.decision.summary) {
|
|
2916
|
+
lines.push(`${decision.decision.summary}`);
|
|
2917
|
+
}
|
|
2918
|
+
for (const constraint of decision.constraints) {
|
|
2919
|
+
lines.push(` - ${constraint.rule}`);
|
|
2920
|
+
}
|
|
2921
|
+
lines.push("");
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
return lines.join("\n");
|
|
2925
|
+
}
|
|
2926
|
+
/**
|
|
2927
|
+
* Generate prompt suffix for AI agents
|
|
2928
|
+
*/
|
|
2929
|
+
generatePromptSuffix(options) {
|
|
2930
|
+
const { decisions } = options;
|
|
2931
|
+
if (decisions.length === 0) {
|
|
2932
|
+
return "No architectural decisions to follow.";
|
|
2933
|
+
}
|
|
2934
|
+
const lines = [];
|
|
2935
|
+
lines.push("Please follow these architectural decisions and constraints:");
|
|
2936
|
+
lines.push("");
|
|
2937
|
+
for (const decision of decisions) {
|
|
2938
|
+
lines.push(`- ${decision.metadata.title}`);
|
|
2939
|
+
for (const constraint of decision.constraints) {
|
|
2940
|
+
lines.push(` - ${constraint.rule}`);
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
lines.push("");
|
|
2944
|
+
lines.push("Ensure your code complies with all constraints listed above.");
|
|
2945
|
+
return lines.join("\n");
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* Extract relevant decisions for a specific file
|
|
2949
|
+
*/
|
|
2950
|
+
extractRelevantDecisions(options) {
|
|
2951
|
+
const { decisions, filePath } = options;
|
|
2952
|
+
return decisions.filter(
|
|
2953
|
+
(d) => d.constraints.some((c) => matchesPattern(filePath, c.scope))
|
|
2954
|
+
);
|
|
2955
|
+
}
|
|
2956
|
+
};
|
|
2957
|
+
export {
|
|
2958
|
+
AgentContextGenerator,
|
|
2959
|
+
AlreadyInitializedError,
|
|
2960
|
+
AnalyzerNotFoundError,
|
|
2961
|
+
CodeScanner,
|
|
2962
|
+
ConfigError,
|
|
2963
|
+
ConstraintExceptionSchema,
|
|
2964
|
+
ConstraintSchema,
|
|
2965
|
+
ConstraintTypeSchema,
|
|
2966
|
+
DecisionContentSchema,
|
|
2967
|
+
DecisionMetadataSchema,
|
|
2968
|
+
DecisionNotFoundError,
|
|
2969
|
+
DecisionSchema,
|
|
2970
|
+
DecisionStatusSchema,
|
|
2971
|
+
DecisionValidationError,
|
|
2972
|
+
ErrorsAnalyzer,
|
|
2973
|
+
ErrorsVerifier,
|
|
2974
|
+
FileSystemError,
|
|
2975
|
+
HookError,
|
|
2976
|
+
ImportsAnalyzer,
|
|
2977
|
+
ImportsVerifier,
|
|
2978
|
+
InferenceEngine,
|
|
2979
|
+
InferenceError,
|
|
2980
|
+
LinksSchema,
|
|
2981
|
+
NamingAnalyzer,
|
|
2982
|
+
NamingVerifier,
|
|
2983
|
+
NotInitializedError,
|
|
2984
|
+
PropagationEngine,
|
|
2985
|
+
RegexVerifier,
|
|
2986
|
+
Registry,
|
|
2987
|
+
RegistryError,
|
|
2988
|
+
Reporter,
|
|
2989
|
+
SeveritySchema,
|
|
2990
|
+
SpecBridgeConfigSchema,
|
|
2991
|
+
SpecBridgeError,
|
|
2992
|
+
StructureAnalyzer,
|
|
2993
|
+
VerificationConfigSchema,
|
|
2994
|
+
VerificationEngine,
|
|
2995
|
+
VerificationError,
|
|
2996
|
+
VerificationFrequencySchema,
|
|
2997
|
+
VerifierNotFoundError,
|
|
2998
|
+
buildDependencyGraph,
|
|
2999
|
+
builtinAnalyzers,
|
|
3000
|
+
builtinVerifiers,
|
|
3001
|
+
calculateConfidence,
|
|
3002
|
+
checkDegradation,
|
|
3003
|
+
createInferenceEngine,
|
|
3004
|
+
createPattern,
|
|
3005
|
+
createPropagationEngine,
|
|
3006
|
+
createRegistry,
|
|
3007
|
+
createScannerFromConfig,
|
|
3008
|
+
createVerificationEngine,
|
|
3009
|
+
createViolation,
|
|
3010
|
+
defaultConfig,
|
|
3011
|
+
ensureDir,
|
|
3012
|
+
extractSnippet,
|
|
3013
|
+
formatConsoleReport,
|
|
3014
|
+
formatContextAsJson,
|
|
3015
|
+
formatContextAsMarkdown,
|
|
3016
|
+
formatContextAsMcp,
|
|
3017
|
+
formatError,
|
|
3018
|
+
formatMarkdownReport,
|
|
3019
|
+
formatValidationErrors,
|
|
3020
|
+
generateContext,
|
|
3021
|
+
generateFormattedContext,
|
|
3022
|
+
generateReport,
|
|
3023
|
+
getAffectedFiles,
|
|
3024
|
+
getAffectingDecisions,
|
|
3025
|
+
getAnalyzer,
|
|
3026
|
+
getAnalyzerIds,
|
|
3027
|
+
getConfigPath,
|
|
3028
|
+
getDecisionsDir,
|
|
3029
|
+
getInferredDir,
|
|
3030
|
+
getReportsDir,
|
|
3031
|
+
getSpecBridgeDir,
|
|
3032
|
+
getTransitiveDependencies,
|
|
3033
|
+
getVerifier,
|
|
3034
|
+
getVerifierIds,
|
|
3035
|
+
getVerifiersDir,
|
|
3036
|
+
glob,
|
|
3037
|
+
isDirectory,
|
|
3038
|
+
loadConfig,
|
|
3039
|
+
loadDecisionFile,
|
|
3040
|
+
loadDecisionsFromDir,
|
|
3041
|
+
matchesAnyPattern,
|
|
3042
|
+
matchesPattern,
|
|
3043
|
+
mergeWithDefaults,
|
|
3044
|
+
minimatch,
|
|
3045
|
+
parseYaml,
|
|
3046
|
+
parseYamlDocument,
|
|
3047
|
+
pathExists,
|
|
3048
|
+
readFilesInDir,
|
|
3049
|
+
readTextFile,
|
|
3050
|
+
runInference,
|
|
3051
|
+
selectVerifierForConstraint,
|
|
3052
|
+
stringifyYaml,
|
|
3053
|
+
updateYamlDocument,
|
|
3054
|
+
validateConfig,
|
|
3055
|
+
validateDecision,
|
|
3056
|
+
validateDecisionFile,
|
|
3057
|
+
writeTextFile
|
|
3058
|
+
};
|
|
3059
|
+
//# sourceMappingURL=index.js.map
|