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