@ipation/specbridge 1.0.5 → 1.1.0
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 +182 -1
- package/dist/cli.js +1441 -96
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +108 -11
- package/dist/index.js +1088 -84
- package/dist/index.js.map +1 -1
- package/package.json +6 -2
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli/index.ts
|
|
4
|
-
import { Command as
|
|
5
|
-
import
|
|
6
|
-
import { readFileSync } from "fs";
|
|
7
|
-
import { fileURLToPath } from "url";
|
|
8
|
-
import { dirname as
|
|
4
|
+
import { Command as Command16 } from "commander";
|
|
5
|
+
import chalk14 from "chalk";
|
|
6
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
7
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
8
|
+
import { dirname as dirname4, join as join10 } from "path";
|
|
9
9
|
|
|
10
10
|
// src/core/errors/index.ts
|
|
11
11
|
var SpecBridgeError = class extends Error {
|
|
12
|
-
constructor(message, code, details) {
|
|
12
|
+
constructor(message, code, details, suggestion) {
|
|
13
13
|
super(message);
|
|
14
14
|
this.code = code;
|
|
15
15
|
this.details = details;
|
|
16
|
+
this.suggestion = suggestion;
|
|
16
17
|
this.name = "SpecBridgeError";
|
|
17
18
|
Error.captureStackTrace(this, this.constructor);
|
|
18
19
|
}
|
|
@@ -38,15 +39,15 @@ var DecisionNotFoundError = class extends SpecBridgeError {
|
|
|
38
39
|
}
|
|
39
40
|
};
|
|
40
41
|
var FileSystemError = class extends SpecBridgeError {
|
|
41
|
-
constructor(message,
|
|
42
|
-
super(message, "FILE_SYSTEM_ERROR", { path });
|
|
43
|
-
this.path =
|
|
42
|
+
constructor(message, path5) {
|
|
43
|
+
super(message, "FILE_SYSTEM_ERROR", { path: path5 });
|
|
44
|
+
this.path = path5;
|
|
44
45
|
this.name = "FileSystemError";
|
|
45
46
|
}
|
|
46
47
|
};
|
|
47
48
|
var AlreadyInitializedError = class extends SpecBridgeError {
|
|
48
|
-
constructor(
|
|
49
|
-
super(`SpecBridge is already initialized at ${
|
|
49
|
+
constructor(path5) {
|
|
50
|
+
super(`SpecBridge is already initialized at ${path5}`, "ALREADY_INITIALIZED", { path: path5 });
|
|
50
51
|
this.name = "AlreadyInitializedError";
|
|
51
52
|
}
|
|
52
53
|
};
|
|
@@ -54,7 +55,9 @@ var NotInitializedError = class extends SpecBridgeError {
|
|
|
54
55
|
constructor() {
|
|
55
56
|
super(
|
|
56
57
|
'SpecBridge is not initialized. Run "specbridge init" first.',
|
|
57
|
-
"NOT_INITIALIZED"
|
|
58
|
+
"NOT_INITIALIZED",
|
|
59
|
+
void 0,
|
|
60
|
+
"Run `specbridge init` in this directory to create .specbridge/"
|
|
58
61
|
);
|
|
59
62
|
this.name = "NotInitializedError";
|
|
60
63
|
}
|
|
@@ -78,6 +81,10 @@ ${detailsStr}`;
|
|
|
78
81
|
if (error instanceof DecisionValidationError && error.validationErrors.length > 0) {
|
|
79
82
|
message += "\nValidation errors:\n" + error.validationErrors.map((e) => ` - ${e}`).join("\n");
|
|
80
83
|
}
|
|
84
|
+
if (error.suggestion) {
|
|
85
|
+
message += `
|
|
86
|
+
Suggestion: ${error.suggestion}`;
|
|
87
|
+
}
|
|
81
88
|
return message;
|
|
82
89
|
}
|
|
83
90
|
return `Error: ${error.message}`;
|
|
@@ -93,23 +100,23 @@ import { join as join2 } from "path";
|
|
|
93
100
|
import { readFile, writeFile, mkdir, access, readdir, stat } from "fs/promises";
|
|
94
101
|
import { join, dirname } from "path";
|
|
95
102
|
import { constants } from "fs";
|
|
96
|
-
async function pathExists(
|
|
103
|
+
async function pathExists(path5) {
|
|
97
104
|
try {
|
|
98
|
-
await access(
|
|
105
|
+
await access(path5, constants.F_OK);
|
|
99
106
|
return true;
|
|
100
107
|
} catch {
|
|
101
108
|
return false;
|
|
102
109
|
}
|
|
103
110
|
}
|
|
104
|
-
async function ensureDir(
|
|
105
|
-
await mkdir(
|
|
111
|
+
async function ensureDir(path5) {
|
|
112
|
+
await mkdir(path5, { recursive: true });
|
|
106
113
|
}
|
|
107
|
-
async function readTextFile(
|
|
108
|
-
return readFile(
|
|
114
|
+
async function readTextFile(path5) {
|
|
115
|
+
return readFile(path5, "utf-8");
|
|
109
116
|
}
|
|
110
|
-
async function writeTextFile(
|
|
111
|
-
await ensureDir(dirname(
|
|
112
|
-
await writeFile(
|
|
117
|
+
async function writeTextFile(path5, content) {
|
|
118
|
+
await ensureDir(dirname(path5));
|
|
119
|
+
await writeFile(path5, content, "utf-8");
|
|
113
120
|
}
|
|
114
121
|
async function readFilesInDir(dirPath, filter) {
|
|
115
122
|
try {
|
|
@@ -419,8 +426,8 @@ var CodeScanner = class {
|
|
|
419
426
|
/**
|
|
420
427
|
* Get a specific file
|
|
421
428
|
*/
|
|
422
|
-
getFile(
|
|
423
|
-
return this.scannedFiles.get(
|
|
429
|
+
getFile(path5) {
|
|
430
|
+
return this.scannedFiles.get(path5);
|
|
424
431
|
}
|
|
425
432
|
/**
|
|
426
433
|
* Get project instance for advanced analysis
|
|
@@ -433,12 +440,12 @@ var CodeScanner = class {
|
|
|
433
440
|
*/
|
|
434
441
|
findClasses() {
|
|
435
442
|
const classes = [];
|
|
436
|
-
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
443
|
+
for (const { path: path5, sourceFile } of this.scannedFiles.values()) {
|
|
437
444
|
for (const classDecl of sourceFile.getClasses()) {
|
|
438
445
|
const name = classDecl.getName();
|
|
439
446
|
if (name) {
|
|
440
447
|
classes.push({
|
|
441
|
-
file:
|
|
448
|
+
file: path5,
|
|
442
449
|
name,
|
|
443
450
|
line: classDecl.getStartLineNumber()
|
|
444
451
|
});
|
|
@@ -452,12 +459,12 @@ var CodeScanner = class {
|
|
|
452
459
|
*/
|
|
453
460
|
findFunctions() {
|
|
454
461
|
const functions = [];
|
|
455
|
-
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
462
|
+
for (const { path: path5, sourceFile } of this.scannedFiles.values()) {
|
|
456
463
|
for (const funcDecl of sourceFile.getFunctions()) {
|
|
457
464
|
const name = funcDecl.getName();
|
|
458
465
|
if (name) {
|
|
459
466
|
functions.push({
|
|
460
|
-
file:
|
|
467
|
+
file: path5,
|
|
461
468
|
name,
|
|
462
469
|
line: funcDecl.getStartLineNumber(),
|
|
463
470
|
isExported: funcDecl.isExported()
|
|
@@ -468,7 +475,7 @@ var CodeScanner = class {
|
|
|
468
475
|
const init = varDecl.getInitializer();
|
|
469
476
|
if (init && Node.isArrowFunction(init)) {
|
|
470
477
|
functions.push({
|
|
471
|
-
file:
|
|
478
|
+
file: path5,
|
|
472
479
|
name: varDecl.getName(),
|
|
473
480
|
line: varDecl.getStartLineNumber(),
|
|
474
481
|
isExported: varDecl.isExported()
|
|
@@ -483,12 +490,12 @@ var CodeScanner = class {
|
|
|
483
490
|
*/
|
|
484
491
|
findImports() {
|
|
485
492
|
const imports = [];
|
|
486
|
-
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
493
|
+
for (const { path: path5, sourceFile } of this.scannedFiles.values()) {
|
|
487
494
|
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
488
495
|
const module = importDecl.getModuleSpecifierValue();
|
|
489
496
|
const namedImports = importDecl.getNamedImports().map((n) => n.getName());
|
|
490
497
|
imports.push({
|
|
491
|
-
file:
|
|
498
|
+
file: path5,
|
|
492
499
|
module,
|
|
493
500
|
named: namedImports,
|
|
494
501
|
line: importDecl.getStartLineNumber()
|
|
@@ -502,10 +509,10 @@ var CodeScanner = class {
|
|
|
502
509
|
*/
|
|
503
510
|
findInterfaces() {
|
|
504
511
|
const interfaces = [];
|
|
505
|
-
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
512
|
+
for (const { path: path5, sourceFile } of this.scannedFiles.values()) {
|
|
506
513
|
for (const interfaceDecl of sourceFile.getInterfaces()) {
|
|
507
514
|
interfaces.push({
|
|
508
|
-
file:
|
|
515
|
+
file: path5,
|
|
509
516
|
name: interfaceDecl.getName(),
|
|
510
517
|
line: interfaceDecl.getStartLineNumber()
|
|
511
518
|
});
|
|
@@ -518,10 +525,10 @@ var CodeScanner = class {
|
|
|
518
525
|
*/
|
|
519
526
|
findTypeAliases() {
|
|
520
527
|
const types = [];
|
|
521
|
-
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
528
|
+
for (const { path: path5, sourceFile } of this.scannedFiles.values()) {
|
|
522
529
|
for (const typeAlias of sourceFile.getTypeAliases()) {
|
|
523
530
|
types.push({
|
|
524
|
-
file:
|
|
531
|
+
file: path5,
|
|
525
532
|
name: typeAlias.getName(),
|
|
526
533
|
line: typeAlias.getStartLineNumber()
|
|
527
534
|
});
|
|
@@ -534,13 +541,13 @@ var CodeScanner = class {
|
|
|
534
541
|
*/
|
|
535
542
|
findTryCatchBlocks() {
|
|
536
543
|
const blocks = [];
|
|
537
|
-
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
544
|
+
for (const { path: path5, sourceFile } of this.scannedFiles.values()) {
|
|
538
545
|
sourceFile.forEachDescendant((node) => {
|
|
539
546
|
if (Node.isTryStatement(node)) {
|
|
540
547
|
const catchClause = node.getCatchClause();
|
|
541
548
|
const hasThrow = catchClause ? catchClause.getDescendantsOfKind(SyntaxKind.ThrowStatement).length > 0 : false;
|
|
542
549
|
blocks.push({
|
|
543
|
-
file:
|
|
550
|
+
file: path5,
|
|
544
551
|
line: node.getStartLineNumber(),
|
|
545
552
|
hasThrow
|
|
546
553
|
});
|
|
@@ -1105,7 +1112,7 @@ var ErrorsAnalyzer = class {
|
|
|
1105
1112
|
let throwNewError = 0;
|
|
1106
1113
|
let throwCustom = 0;
|
|
1107
1114
|
const examples = [];
|
|
1108
|
-
for (const { path, sourceFile } of files) {
|
|
1115
|
+
for (const { path: path5, sourceFile } of files) {
|
|
1109
1116
|
sourceFile.forEachDescendant((node) => {
|
|
1110
1117
|
if (Node2.isThrowStatement(node)) {
|
|
1111
1118
|
const expression = node.getExpression();
|
|
@@ -1118,7 +1125,7 @@ var ErrorsAnalyzer = class {
|
|
|
1118
1125
|
if (examples.length < 3) {
|
|
1119
1126
|
const snippet = text.length > 50 ? text.slice(0, 50) + "..." : text;
|
|
1120
1127
|
examples.push({
|
|
1121
|
-
file:
|
|
1128
|
+
file: path5,
|
|
1122
1129
|
line: node.getStartLineNumber(),
|
|
1123
1130
|
snippet: `throw ${snippet}`
|
|
1124
1131
|
});
|
|
@@ -1415,8 +1422,8 @@ function validateDecision(data) {
|
|
|
1415
1422
|
}
|
|
1416
1423
|
function formatValidationErrors(errors) {
|
|
1417
1424
|
return errors.errors.map((err) => {
|
|
1418
|
-
const
|
|
1419
|
-
return `${
|
|
1425
|
+
const path5 = err.path.join(".");
|
|
1426
|
+
return `${path5}: ${err.message}`;
|
|
1420
1427
|
});
|
|
1421
1428
|
}
|
|
1422
1429
|
|
|
@@ -1795,6 +1802,7 @@ var NamingVerifier = class {
|
|
|
1795
1802
|
};
|
|
1796
1803
|
|
|
1797
1804
|
// src/verification/verifiers/imports.ts
|
|
1805
|
+
import path from "path";
|
|
1798
1806
|
var ImportsVerifier = class {
|
|
1799
1807
|
id = "imports";
|
|
1800
1808
|
name = "Import Pattern Verifier";
|
|
@@ -1803,6 +1811,39 @@ var ImportsVerifier = class {
|
|
|
1803
1811
|
const violations = [];
|
|
1804
1812
|
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
1805
1813
|
const rule = constraint.rule.toLowerCase();
|
|
1814
|
+
if (rule.includes(".js") && rule.includes("extension") || rule.includes("esm") || rule.includes("add .js")) {
|
|
1815
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
1816
|
+
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
1817
|
+
if (!moduleSpec.startsWith(".")) continue;
|
|
1818
|
+
const ext = path.posix.extname(moduleSpec);
|
|
1819
|
+
let suggested = null;
|
|
1820
|
+
if (!ext) {
|
|
1821
|
+
suggested = `${moduleSpec}.js`;
|
|
1822
|
+
} else if (ext === ".ts" || ext === ".tsx" || ext === ".jsx") {
|
|
1823
|
+
suggested = moduleSpec.slice(0, -ext.length) + ".js";
|
|
1824
|
+
} else if (ext !== ".js") {
|
|
1825
|
+
continue;
|
|
1826
|
+
}
|
|
1827
|
+
if (!suggested || suggested === moduleSpec) continue;
|
|
1828
|
+
const ms = importDecl.getModuleSpecifier();
|
|
1829
|
+
const start = ms.getStart() + 1;
|
|
1830
|
+
const end = ms.getEnd() - 1;
|
|
1831
|
+
violations.push(createViolation({
|
|
1832
|
+
decisionId,
|
|
1833
|
+
constraintId: constraint.id,
|
|
1834
|
+
type: constraint.type,
|
|
1835
|
+
severity: constraint.severity,
|
|
1836
|
+
message: `Relative import "${moduleSpec}" should include a .js extension`,
|
|
1837
|
+
file: filePath,
|
|
1838
|
+
line: importDecl.getStartLineNumber(),
|
|
1839
|
+
suggestion: `Update to "${suggested}"`,
|
|
1840
|
+
autofix: {
|
|
1841
|
+
description: "Add/normalize .js extension in import specifier",
|
|
1842
|
+
edits: [{ start, end, text: suggested }]
|
|
1843
|
+
}
|
|
1844
|
+
}));
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1806
1847
|
if (rule.includes("barrel") || rule.includes("index")) {
|
|
1807
1848
|
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
1808
1849
|
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
@@ -2041,12 +2082,590 @@ var RegexVerifier = class {
|
|
|
2041
2082
|
}
|
|
2042
2083
|
};
|
|
2043
2084
|
|
|
2085
|
+
// src/verification/verifiers/dependencies.ts
|
|
2086
|
+
import path2 from "path";
|
|
2087
|
+
var graphCache = /* @__PURE__ */ new WeakMap();
|
|
2088
|
+
function normalizeFsPath(p) {
|
|
2089
|
+
return p.replaceAll("\\", "/");
|
|
2090
|
+
}
|
|
2091
|
+
function joinLike(fromFilePath, relative2) {
|
|
2092
|
+
const fromNorm = normalizeFsPath(fromFilePath);
|
|
2093
|
+
const relNorm = normalizeFsPath(relative2);
|
|
2094
|
+
if (path2.isAbsolute(fromNorm)) {
|
|
2095
|
+
return normalizeFsPath(path2.resolve(path2.dirname(fromNorm), relNorm));
|
|
2096
|
+
}
|
|
2097
|
+
const dir = path2.posix.dirname(fromNorm);
|
|
2098
|
+
return path2.posix.normalize(path2.posix.join(dir, relNorm));
|
|
2099
|
+
}
|
|
2100
|
+
function resolveToSourceFilePath(project, fromFilePath, moduleSpec) {
|
|
2101
|
+
if (!moduleSpec.startsWith(".")) return null;
|
|
2102
|
+
const candidates = [];
|
|
2103
|
+
const raw = joinLike(fromFilePath, moduleSpec);
|
|
2104
|
+
const addCandidate = (p) => candidates.push(normalizeFsPath(p));
|
|
2105
|
+
const ext = path2.posix.extname(raw);
|
|
2106
|
+
if (ext) {
|
|
2107
|
+
addCandidate(raw);
|
|
2108
|
+
if (ext === ".js") addCandidate(raw.slice(0, -3) + ".ts");
|
|
2109
|
+
if (ext === ".jsx") addCandidate(raw.slice(0, -4) + ".tsx");
|
|
2110
|
+
if (ext === ".ts") addCandidate(raw.slice(0, -3) + ".js");
|
|
2111
|
+
if (ext === ".tsx") addCandidate(raw.slice(0, -4) + ".jsx");
|
|
2112
|
+
addCandidate(path2.posix.join(raw.replace(/\/$/, ""), "index.ts"));
|
|
2113
|
+
addCandidate(path2.posix.join(raw.replace(/\/$/, ""), "index.tsx"));
|
|
2114
|
+
addCandidate(path2.posix.join(raw.replace(/\/$/, ""), "index.js"));
|
|
2115
|
+
addCandidate(path2.posix.join(raw.replace(/\/$/, ""), "index.jsx"));
|
|
2116
|
+
} else {
|
|
2117
|
+
addCandidate(raw + ".ts");
|
|
2118
|
+
addCandidate(raw + ".tsx");
|
|
2119
|
+
addCandidate(raw + ".js");
|
|
2120
|
+
addCandidate(raw + ".jsx");
|
|
2121
|
+
addCandidate(path2.posix.join(raw, "index.ts"));
|
|
2122
|
+
addCandidate(path2.posix.join(raw, "index.tsx"));
|
|
2123
|
+
addCandidate(path2.posix.join(raw, "index.js"));
|
|
2124
|
+
addCandidate(path2.posix.join(raw, "index.jsx"));
|
|
2125
|
+
}
|
|
2126
|
+
for (const candidate of candidates) {
|
|
2127
|
+
const sf = project.getSourceFile(candidate);
|
|
2128
|
+
if (sf) return sf.getFilePath();
|
|
2129
|
+
}
|
|
2130
|
+
return null;
|
|
2131
|
+
}
|
|
2132
|
+
function buildDependencyGraph(project) {
|
|
2133
|
+
const cached = graphCache.get(project);
|
|
2134
|
+
const sourceFiles = project.getSourceFiles();
|
|
2135
|
+
if (cached && cached.fileCount === sourceFiles.length) {
|
|
2136
|
+
return cached.graph;
|
|
2137
|
+
}
|
|
2138
|
+
const graph = /* @__PURE__ */ new Map();
|
|
2139
|
+
for (const sf of sourceFiles) {
|
|
2140
|
+
const from = normalizeFsPath(sf.getFilePath());
|
|
2141
|
+
if (!graph.has(from)) graph.set(from, /* @__PURE__ */ new Set());
|
|
2142
|
+
for (const importDecl of sf.getImportDeclarations()) {
|
|
2143
|
+
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
2144
|
+
const resolved = resolveToSourceFilePath(project, from, moduleSpec);
|
|
2145
|
+
if (resolved) {
|
|
2146
|
+
graph.get(from).add(normalizeFsPath(resolved));
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
graphCache.set(project, { fileCount: sourceFiles.length, graph });
|
|
2151
|
+
return graph;
|
|
2152
|
+
}
|
|
2153
|
+
function tarjanScc(graph) {
|
|
2154
|
+
let index = 0;
|
|
2155
|
+
const stack = [];
|
|
2156
|
+
const onStack = /* @__PURE__ */ new Set();
|
|
2157
|
+
const indices = /* @__PURE__ */ new Map();
|
|
2158
|
+
const lowlink = /* @__PURE__ */ new Map();
|
|
2159
|
+
const result = [];
|
|
2160
|
+
const strongConnect = (v) => {
|
|
2161
|
+
indices.set(v, index);
|
|
2162
|
+
lowlink.set(v, index);
|
|
2163
|
+
index++;
|
|
2164
|
+
stack.push(v);
|
|
2165
|
+
onStack.add(v);
|
|
2166
|
+
const edges = graph.get(v) || /* @__PURE__ */ new Set();
|
|
2167
|
+
for (const w of edges) {
|
|
2168
|
+
if (!indices.has(w)) {
|
|
2169
|
+
strongConnect(w);
|
|
2170
|
+
lowlink.set(v, Math.min(lowlink.get(v), lowlink.get(w)));
|
|
2171
|
+
} else if (onStack.has(w)) {
|
|
2172
|
+
lowlink.set(v, Math.min(lowlink.get(v), indices.get(w)));
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
if (lowlink.get(v) === indices.get(v)) {
|
|
2176
|
+
const scc = [];
|
|
2177
|
+
while (stack.length > 0) {
|
|
2178
|
+
const w = stack.pop();
|
|
2179
|
+
if (!w) break;
|
|
2180
|
+
onStack.delete(w);
|
|
2181
|
+
scc.push(w);
|
|
2182
|
+
if (w === v) break;
|
|
2183
|
+
}
|
|
2184
|
+
result.push(scc);
|
|
2185
|
+
}
|
|
2186
|
+
};
|
|
2187
|
+
for (const v of graph.keys()) {
|
|
2188
|
+
if (!indices.has(v)) strongConnect(v);
|
|
2189
|
+
}
|
|
2190
|
+
return result;
|
|
2191
|
+
}
|
|
2192
|
+
function parseMaxImportDepth(rule) {
|
|
2193
|
+
const m = rule.match(/maximum\s+import\s+depth\s*[:=]?\s*(\d+)/i);
|
|
2194
|
+
return m ? Number.parseInt(m[1], 10) : null;
|
|
2195
|
+
}
|
|
2196
|
+
function parseBannedDependency(rule) {
|
|
2197
|
+
const m = rule.match(/no\s+dependencies?\s+on\s+(?:package\s+)?(.+?)(?:\.|$)/i);
|
|
2198
|
+
if (!m) return null;
|
|
2199
|
+
const value = m[1].trim();
|
|
2200
|
+
return value.length > 0 ? value : null;
|
|
2201
|
+
}
|
|
2202
|
+
function parseLayerRule(rule) {
|
|
2203
|
+
const m = rule.match(/(\w+)\s+layer\s+cannot\s+depend\s+on\s+(\w+)\s+layer/i);
|
|
2204
|
+
if (!m) return null;
|
|
2205
|
+
return { fromLayer: m[1].toLowerCase(), toLayer: m[2].toLowerCase() };
|
|
2206
|
+
}
|
|
2207
|
+
function fileInLayer(filePath, layer) {
|
|
2208
|
+
const fp = normalizeFsPath(filePath).toLowerCase();
|
|
2209
|
+
return fp.includes(`/${layer}/`) || fp.endsWith(`/${layer}.ts`) || fp.endsWith(`/${layer}.tsx`);
|
|
2210
|
+
}
|
|
2211
|
+
var DependencyVerifier = class {
|
|
2212
|
+
id = "dependencies";
|
|
2213
|
+
name = "Dependency Verifier";
|
|
2214
|
+
description = "Checks dependency constraints, import depth, and circular dependencies";
|
|
2215
|
+
async verify(ctx) {
|
|
2216
|
+
const violations = [];
|
|
2217
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
2218
|
+
const rule = constraint.rule;
|
|
2219
|
+
const lowerRule = rule.toLowerCase();
|
|
2220
|
+
const project = sourceFile.getProject();
|
|
2221
|
+
const projectFilePath = normalizeFsPath(sourceFile.getFilePath());
|
|
2222
|
+
if (lowerRule.includes("circular") || lowerRule.includes("cycle")) {
|
|
2223
|
+
const graph = buildDependencyGraph(project);
|
|
2224
|
+
const sccs = tarjanScc(graph);
|
|
2225
|
+
const current = projectFilePath;
|
|
2226
|
+
for (const scc of sccs) {
|
|
2227
|
+
const hasSelfLoop = scc.length === 1 && (graph.get(scc[0])?.has(scc[0]) ?? false);
|
|
2228
|
+
const isCycle = scc.length > 1 || hasSelfLoop;
|
|
2229
|
+
if (!isCycle) continue;
|
|
2230
|
+
if (!scc.includes(current)) continue;
|
|
2231
|
+
const sorted = [...scc].sort();
|
|
2232
|
+
if (sorted[0] !== current) continue;
|
|
2233
|
+
violations.push(createViolation({
|
|
2234
|
+
decisionId,
|
|
2235
|
+
constraintId: constraint.id,
|
|
2236
|
+
type: constraint.type,
|
|
2237
|
+
severity: constraint.severity,
|
|
2238
|
+
message: `Circular dependency detected across: ${sorted.join(" -> ")}`,
|
|
2239
|
+
file: filePath,
|
|
2240
|
+
line: 1,
|
|
2241
|
+
suggestion: "Break the cycle by extracting shared abstractions or reversing the dependency"
|
|
2242
|
+
}));
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
const layerRule = parseLayerRule(rule);
|
|
2246
|
+
if (layerRule && fileInLayer(projectFilePath, layerRule.fromLayer)) {
|
|
2247
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
2248
|
+
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
2249
|
+
const resolved = resolveToSourceFilePath(project, projectFilePath, moduleSpec);
|
|
2250
|
+
if (!resolved) continue;
|
|
2251
|
+
if (fileInLayer(resolved, layerRule.toLayer)) {
|
|
2252
|
+
violations.push(createViolation({
|
|
2253
|
+
decisionId,
|
|
2254
|
+
constraintId: constraint.id,
|
|
2255
|
+
type: constraint.type,
|
|
2256
|
+
severity: constraint.severity,
|
|
2257
|
+
message: `Layer violation: ${layerRule.fromLayer} depends on ${layerRule.toLayer} via import "${moduleSpec}"`,
|
|
2258
|
+
file: filePath,
|
|
2259
|
+
line: importDecl.getStartLineNumber(),
|
|
2260
|
+
suggestion: `Refactor to remove dependency from ${layerRule.fromLayer} to ${layerRule.toLayer}`
|
|
2261
|
+
}));
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
const banned = parseBannedDependency(rule);
|
|
2266
|
+
if (banned) {
|
|
2267
|
+
const bannedLower = banned.toLowerCase();
|
|
2268
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
2269
|
+
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
2270
|
+
if (moduleSpec.toLowerCase().includes(bannedLower)) {
|
|
2271
|
+
violations.push(createViolation({
|
|
2272
|
+
decisionId,
|
|
2273
|
+
constraintId: constraint.id,
|
|
2274
|
+
type: constraint.type,
|
|
2275
|
+
severity: constraint.severity,
|
|
2276
|
+
message: `Banned dependency import detected: "${moduleSpec}"`,
|
|
2277
|
+
file: filePath,
|
|
2278
|
+
line: importDecl.getStartLineNumber(),
|
|
2279
|
+
suggestion: `Remove or replace dependency "${banned}"`
|
|
2280
|
+
}));
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
const maxDepth = parseMaxImportDepth(rule);
|
|
2285
|
+
if (maxDepth !== null) {
|
|
2286
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
2287
|
+
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
2288
|
+
if (!moduleSpec.startsWith(".")) continue;
|
|
2289
|
+
const depth = (moduleSpec.match(/\.\.\//g) || []).length;
|
|
2290
|
+
if (depth > maxDepth) {
|
|
2291
|
+
violations.push(createViolation({
|
|
2292
|
+
decisionId,
|
|
2293
|
+
constraintId: constraint.id,
|
|
2294
|
+
type: constraint.type,
|
|
2295
|
+
severity: constraint.severity,
|
|
2296
|
+
message: `Import depth ${depth} exceeds maximum ${maxDepth}: "${moduleSpec}"`,
|
|
2297
|
+
file: filePath,
|
|
2298
|
+
line: importDecl.getStartLineNumber(),
|
|
2299
|
+
suggestion: "Use a shallower module boundary (or introduce a public entrypoint for this dependency)"
|
|
2300
|
+
}));
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
return violations;
|
|
2305
|
+
}
|
|
2306
|
+
};
|
|
2307
|
+
|
|
2308
|
+
// src/verification/verifiers/complexity.ts
|
|
2309
|
+
import { SyntaxKind as SyntaxKind2 } from "ts-morph";
|
|
2310
|
+
function parseLimit(rule, pattern) {
|
|
2311
|
+
const m = rule.match(pattern);
|
|
2312
|
+
return m ? Number.parseInt(m[1], 10) : null;
|
|
2313
|
+
}
|
|
2314
|
+
function getFileLineCount(text) {
|
|
2315
|
+
if (text.length === 0) return 0;
|
|
2316
|
+
return text.split("\n").length;
|
|
2317
|
+
}
|
|
2318
|
+
function getDecisionPoints(fn) {
|
|
2319
|
+
let points = 0;
|
|
2320
|
+
for (const d of fn.getDescendants()) {
|
|
2321
|
+
switch (d.getKind()) {
|
|
2322
|
+
case SyntaxKind2.IfStatement:
|
|
2323
|
+
case SyntaxKind2.ForStatement:
|
|
2324
|
+
case SyntaxKind2.ForInStatement:
|
|
2325
|
+
case SyntaxKind2.ForOfStatement:
|
|
2326
|
+
case SyntaxKind2.WhileStatement:
|
|
2327
|
+
case SyntaxKind2.DoStatement:
|
|
2328
|
+
case SyntaxKind2.CatchClause:
|
|
2329
|
+
case SyntaxKind2.ConditionalExpression:
|
|
2330
|
+
case SyntaxKind2.CaseClause:
|
|
2331
|
+
points++;
|
|
2332
|
+
break;
|
|
2333
|
+
case SyntaxKind2.BinaryExpression: {
|
|
2334
|
+
const text = d.getText();
|
|
2335
|
+
if (text.includes("&&") || text.includes("||")) points++;
|
|
2336
|
+
break;
|
|
2337
|
+
}
|
|
2338
|
+
default:
|
|
2339
|
+
break;
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
return points;
|
|
2343
|
+
}
|
|
2344
|
+
function calculateCyclomaticComplexity(fn) {
|
|
2345
|
+
return 1 + getDecisionPoints(fn);
|
|
2346
|
+
}
|
|
2347
|
+
function getFunctionDisplayName(fn) {
|
|
2348
|
+
if ("getName" in fn && typeof fn.getName === "function") {
|
|
2349
|
+
const name = fn.getName();
|
|
2350
|
+
if (typeof name === "string" && name.length > 0) return name;
|
|
2351
|
+
}
|
|
2352
|
+
const parent = fn.getParent();
|
|
2353
|
+
if (parent?.getKind() === SyntaxKind2.VariableDeclaration) {
|
|
2354
|
+
const vd = parent;
|
|
2355
|
+
if (typeof vd.getName === "function") return vd.getName();
|
|
2356
|
+
}
|
|
2357
|
+
return "<anonymous>";
|
|
2358
|
+
}
|
|
2359
|
+
function maxNestingDepth(node) {
|
|
2360
|
+
let maxDepth = 0;
|
|
2361
|
+
const walk = (n, depth) => {
|
|
2362
|
+
maxDepth = Math.max(maxDepth, depth);
|
|
2363
|
+
const kind = n.getKind();
|
|
2364
|
+
const isNestingNode = kind === SyntaxKind2.IfStatement || kind === SyntaxKind2.ForStatement || kind === SyntaxKind2.ForInStatement || kind === SyntaxKind2.ForOfStatement || kind === SyntaxKind2.WhileStatement || kind === SyntaxKind2.DoStatement || kind === SyntaxKind2.SwitchStatement || kind === SyntaxKind2.TryStatement;
|
|
2365
|
+
for (const child of n.getChildren()) {
|
|
2366
|
+
if (child.getKind() === SyntaxKind2.FunctionDeclaration || child.getKind() === SyntaxKind2.FunctionExpression || child.getKind() === SyntaxKind2.ArrowFunction || child.getKind() === SyntaxKind2.MethodDeclaration) {
|
|
2367
|
+
continue;
|
|
2368
|
+
}
|
|
2369
|
+
walk(child, isNestingNode ? depth + 1 : depth);
|
|
2370
|
+
}
|
|
2371
|
+
};
|
|
2372
|
+
walk(node, 0);
|
|
2373
|
+
return maxDepth;
|
|
2374
|
+
}
|
|
2375
|
+
var ComplexityVerifier = class {
|
|
2376
|
+
id = "complexity";
|
|
2377
|
+
name = "Complexity Verifier";
|
|
2378
|
+
description = "Checks cyclomatic complexity, file size, parameters, and nesting depth";
|
|
2379
|
+
async verify(ctx) {
|
|
2380
|
+
const violations = [];
|
|
2381
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
2382
|
+
const rule = constraint.rule;
|
|
2383
|
+
const maxComplexity = parseLimit(rule, /complexity\s+(?:must\s+)?not\s+exceed\s+(\d+)/i);
|
|
2384
|
+
const maxLines = parseLimit(rule, /file\s+size\s+(?:must\s+)?not\s+exceed\s+(\d+)\s+lines?/i);
|
|
2385
|
+
const maxParams = parseLimit(rule, /at\s+most\s+(\d+)\s+parameters?/i) ?? parseLimit(rule, /parameters?\s+(?:must\s+)?not\s+exceed\s+(\d+)/i);
|
|
2386
|
+
const maxNesting = parseLimit(rule, /nesting\s+depth\s+(?:must\s+)?not\s+exceed\s+(\d+)/i);
|
|
2387
|
+
if (maxLines !== null) {
|
|
2388
|
+
const lineCount = getFileLineCount(sourceFile.getFullText());
|
|
2389
|
+
if (lineCount > maxLines) {
|
|
2390
|
+
violations.push(createViolation({
|
|
2391
|
+
decisionId,
|
|
2392
|
+
constraintId: constraint.id,
|
|
2393
|
+
type: constraint.type,
|
|
2394
|
+
severity: constraint.severity,
|
|
2395
|
+
message: `File has ${lineCount} lines which exceeds maximum ${maxLines}`,
|
|
2396
|
+
file: filePath,
|
|
2397
|
+
line: 1,
|
|
2398
|
+
suggestion: "Split the file into smaller modules"
|
|
2399
|
+
}));
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
const functionLikes = [
|
|
2403
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind2.FunctionDeclaration),
|
|
2404
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind2.FunctionExpression),
|
|
2405
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind2.ArrowFunction),
|
|
2406
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind2.MethodDeclaration)
|
|
2407
|
+
];
|
|
2408
|
+
for (const fn of functionLikes) {
|
|
2409
|
+
const fnName = getFunctionDisplayName(fn);
|
|
2410
|
+
if (maxComplexity !== null) {
|
|
2411
|
+
const complexity = calculateCyclomaticComplexity(fn);
|
|
2412
|
+
if (complexity > maxComplexity) {
|
|
2413
|
+
violations.push(createViolation({
|
|
2414
|
+
decisionId,
|
|
2415
|
+
constraintId: constraint.id,
|
|
2416
|
+
type: constraint.type,
|
|
2417
|
+
severity: constraint.severity,
|
|
2418
|
+
message: `Function ${fnName} has cyclomatic complexity ${complexity} which exceeds maximum ${maxComplexity}`,
|
|
2419
|
+
file: filePath,
|
|
2420
|
+
line: fn.getStartLineNumber(),
|
|
2421
|
+
suggestion: "Refactor to reduce branching or extract smaller functions"
|
|
2422
|
+
}));
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
if (maxParams !== null && "getParameters" in fn) {
|
|
2426
|
+
const params = fn.getParameters();
|
|
2427
|
+
const paramCount = Array.isArray(params) ? params.length : 0;
|
|
2428
|
+
if (paramCount > maxParams) {
|
|
2429
|
+
violations.push(createViolation({
|
|
2430
|
+
decisionId,
|
|
2431
|
+
constraintId: constraint.id,
|
|
2432
|
+
type: constraint.type,
|
|
2433
|
+
severity: constraint.severity,
|
|
2434
|
+
message: `Function ${fnName} has ${paramCount} parameters which exceeds maximum ${maxParams}`,
|
|
2435
|
+
file: filePath,
|
|
2436
|
+
line: fn.getStartLineNumber(),
|
|
2437
|
+
suggestion: "Consider grouping parameters into an options object"
|
|
2438
|
+
}));
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
if (maxNesting !== null) {
|
|
2442
|
+
const depth = maxNestingDepth(fn);
|
|
2443
|
+
if (depth > maxNesting) {
|
|
2444
|
+
violations.push(createViolation({
|
|
2445
|
+
decisionId,
|
|
2446
|
+
constraintId: constraint.id,
|
|
2447
|
+
type: constraint.type,
|
|
2448
|
+
severity: constraint.severity,
|
|
2449
|
+
message: `Function ${fnName} has nesting depth ${depth} which exceeds maximum ${maxNesting}`,
|
|
2450
|
+
file: filePath,
|
|
2451
|
+
line: fn.getStartLineNumber(),
|
|
2452
|
+
suggestion: "Reduce nesting by using early returns or extracting functions"
|
|
2453
|
+
}));
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
return violations;
|
|
2458
|
+
}
|
|
2459
|
+
};
|
|
2460
|
+
|
|
2461
|
+
// src/verification/verifiers/security.ts
|
|
2462
|
+
import { SyntaxKind as SyntaxKind3 } from "ts-morph";
|
|
2463
|
+
var SECRET_NAME_RE = /(api[_-]?key|password|secret|token)/i;
|
|
2464
|
+
function isStringLiteralLike(node) {
|
|
2465
|
+
const k = node.getKind();
|
|
2466
|
+
return k === SyntaxKind3.StringLiteral || k === SyntaxKind3.NoSubstitutionTemplateLiteral;
|
|
2467
|
+
}
|
|
2468
|
+
var SecurityVerifier = class {
|
|
2469
|
+
id = "security";
|
|
2470
|
+
name = "Security Verifier";
|
|
2471
|
+
description = "Detects common security footguns (secrets, eval, XSS/SQL injection heuristics)";
|
|
2472
|
+
async verify(ctx) {
|
|
2473
|
+
const violations = [];
|
|
2474
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
2475
|
+
const rule = constraint.rule.toLowerCase();
|
|
2476
|
+
const checkSecrets = rule.includes("secret") || rule.includes("password") || rule.includes("token") || rule.includes("api key") || rule.includes("hardcoded");
|
|
2477
|
+
const checkEval = rule.includes("eval") || rule.includes("function constructor");
|
|
2478
|
+
const checkXss = rule.includes("xss") || rule.includes("innerhtml") || rule.includes("dangerouslysetinnerhtml");
|
|
2479
|
+
const checkSql = rule.includes("sql") || rule.includes("injection");
|
|
2480
|
+
const checkProto = rule.includes("prototype pollution") || rule.includes("__proto__");
|
|
2481
|
+
if (checkSecrets) {
|
|
2482
|
+
for (const vd of sourceFile.getVariableDeclarations()) {
|
|
2483
|
+
const name = vd.getName();
|
|
2484
|
+
if (!SECRET_NAME_RE.test(name)) continue;
|
|
2485
|
+
const init = vd.getInitializer();
|
|
2486
|
+
if (!init || !isStringLiteralLike(init)) continue;
|
|
2487
|
+
const value = init.getText().slice(1, -1);
|
|
2488
|
+
if (value.length === 0) continue;
|
|
2489
|
+
violations.push(createViolation({
|
|
2490
|
+
decisionId,
|
|
2491
|
+
constraintId: constraint.id,
|
|
2492
|
+
type: constraint.type,
|
|
2493
|
+
severity: constraint.severity,
|
|
2494
|
+
message: `Possible hardcoded secret in variable "${name}"`,
|
|
2495
|
+
file: filePath,
|
|
2496
|
+
line: vd.getStartLineNumber(),
|
|
2497
|
+
suggestion: "Move secrets to environment variables or a secret manager"
|
|
2498
|
+
}));
|
|
2499
|
+
}
|
|
2500
|
+
for (const pa of sourceFile.getDescendantsOfKind(SyntaxKind3.PropertyAssignment)) {
|
|
2501
|
+
const nameNode = pa.getNameNode?.();
|
|
2502
|
+
const propName = nameNode?.getText?.() ?? "";
|
|
2503
|
+
if (!SECRET_NAME_RE.test(propName)) continue;
|
|
2504
|
+
const init = pa.getInitializer();
|
|
2505
|
+
if (!init || !isStringLiteralLike(init)) continue;
|
|
2506
|
+
violations.push(createViolation({
|
|
2507
|
+
decisionId,
|
|
2508
|
+
constraintId: constraint.id,
|
|
2509
|
+
type: constraint.type,
|
|
2510
|
+
severity: constraint.severity,
|
|
2511
|
+
message: `Possible hardcoded secret in object property ${propName}`,
|
|
2512
|
+
file: filePath,
|
|
2513
|
+
line: pa.getStartLineNumber(),
|
|
2514
|
+
suggestion: "Move secrets to environment variables or a secret manager"
|
|
2515
|
+
}));
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
if (checkEval) {
|
|
2519
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind3.CallExpression)) {
|
|
2520
|
+
const exprText = call.getExpression().getText();
|
|
2521
|
+
if (exprText === "eval" || exprText === "Function") {
|
|
2522
|
+
violations.push(createViolation({
|
|
2523
|
+
decisionId,
|
|
2524
|
+
constraintId: constraint.id,
|
|
2525
|
+
type: constraint.type,
|
|
2526
|
+
severity: constraint.severity,
|
|
2527
|
+
message: `Unsafe dynamic code execution via ${exprText}()`,
|
|
2528
|
+
file: filePath,
|
|
2529
|
+
line: call.getStartLineNumber(),
|
|
2530
|
+
suggestion: "Avoid eval/Function; use safer alternatives"
|
|
2531
|
+
}));
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
if (checkXss) {
|
|
2536
|
+
for (const bin of sourceFile.getDescendantsOfKind(SyntaxKind3.BinaryExpression)) {
|
|
2537
|
+
const left = bin.getLeft();
|
|
2538
|
+
if (left.getKind() !== SyntaxKind3.PropertyAccessExpression) continue;
|
|
2539
|
+
const pa = left;
|
|
2540
|
+
if (pa.getName?.() === "innerHTML") {
|
|
2541
|
+
violations.push(createViolation({
|
|
2542
|
+
decisionId,
|
|
2543
|
+
constraintId: constraint.id,
|
|
2544
|
+
type: constraint.type,
|
|
2545
|
+
severity: constraint.severity,
|
|
2546
|
+
message: "Potential XSS: assignment to innerHTML",
|
|
2547
|
+
file: filePath,
|
|
2548
|
+
line: bin.getStartLineNumber(),
|
|
2549
|
+
suggestion: "Prefer textContent or a safe templating/escaping strategy"
|
|
2550
|
+
}));
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
if (sourceFile.getFullText().includes("dangerouslySetInnerHTML")) {
|
|
2554
|
+
violations.push(createViolation({
|
|
2555
|
+
decisionId,
|
|
2556
|
+
constraintId: constraint.id,
|
|
2557
|
+
type: constraint.type,
|
|
2558
|
+
severity: constraint.severity,
|
|
2559
|
+
message: "Potential XSS: usage of dangerouslySetInnerHTML",
|
|
2560
|
+
file: filePath,
|
|
2561
|
+
line: 1,
|
|
2562
|
+
suggestion: "Avoid dangerouslySetInnerHTML or ensure content is sanitized"
|
|
2563
|
+
}));
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
if (checkSql) {
|
|
2567
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind3.CallExpression)) {
|
|
2568
|
+
const expr = call.getExpression();
|
|
2569
|
+
if (expr.getKind() !== SyntaxKind3.PropertyAccessExpression) continue;
|
|
2570
|
+
const name = expr.getName?.();
|
|
2571
|
+
if (name !== "query" && name !== "execute") continue;
|
|
2572
|
+
const arg = call.getArguments()[0];
|
|
2573
|
+
if (!arg) continue;
|
|
2574
|
+
const isTemplate = arg.getKind() === SyntaxKind3.TemplateExpression;
|
|
2575
|
+
const isConcat = arg.getKind() === SyntaxKind3.BinaryExpression && arg.getText().includes("+");
|
|
2576
|
+
if (!isTemplate && !isConcat) continue;
|
|
2577
|
+
const text = arg.getText().toLowerCase();
|
|
2578
|
+
if (!text.includes("select") && !text.includes("insert") && !text.includes("update") && !text.includes("delete")) {
|
|
2579
|
+
continue;
|
|
2580
|
+
}
|
|
2581
|
+
violations.push(createViolation({
|
|
2582
|
+
decisionId,
|
|
2583
|
+
constraintId: constraint.id,
|
|
2584
|
+
type: constraint.type,
|
|
2585
|
+
severity: constraint.severity,
|
|
2586
|
+
message: "Potential SQL injection: dynamically constructed SQL query",
|
|
2587
|
+
file: filePath,
|
|
2588
|
+
line: call.getStartLineNumber(),
|
|
2589
|
+
suggestion: "Use parameterized queries / prepared statements"
|
|
2590
|
+
}));
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
if (checkProto) {
|
|
2594
|
+
const text = sourceFile.getFullText();
|
|
2595
|
+
if (text.includes("__proto__") || text.includes("constructor.prototype")) {
|
|
2596
|
+
violations.push(createViolation({
|
|
2597
|
+
decisionId,
|
|
2598
|
+
constraintId: constraint.id,
|
|
2599
|
+
type: constraint.type,
|
|
2600
|
+
severity: constraint.severity,
|
|
2601
|
+
message: "Potential prototype pollution pattern detected",
|
|
2602
|
+
file: filePath,
|
|
2603
|
+
line: 1,
|
|
2604
|
+
suggestion: "Avoid writing to __proto__/prototype; validate object keys"
|
|
2605
|
+
}));
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
return violations;
|
|
2609
|
+
}
|
|
2610
|
+
};
|
|
2611
|
+
|
|
2612
|
+
// src/verification/verifiers/api.ts
|
|
2613
|
+
import { SyntaxKind as SyntaxKind4 } from "ts-morph";
|
|
2614
|
+
var HTTP_METHODS = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "options", "head", "all"]);
|
|
2615
|
+
function isKebabPath(pathValue) {
|
|
2616
|
+
const parts = pathValue.split("/").filter(Boolean);
|
|
2617
|
+
for (const part of parts) {
|
|
2618
|
+
if (part.startsWith(":")) continue;
|
|
2619
|
+
if (!/^[a-z0-9-]+$/.test(part)) return false;
|
|
2620
|
+
}
|
|
2621
|
+
return true;
|
|
2622
|
+
}
|
|
2623
|
+
var ApiVerifier = class {
|
|
2624
|
+
id = "api";
|
|
2625
|
+
name = "API Consistency Verifier";
|
|
2626
|
+
description = "Checks basic REST endpoint naming conventions in common frameworks";
|
|
2627
|
+
async verify(ctx) {
|
|
2628
|
+
const violations = [];
|
|
2629
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
2630
|
+
const rule = constraint.rule.toLowerCase();
|
|
2631
|
+
const enforceKebab = rule.includes("kebab") || rule.includes("kebab-case");
|
|
2632
|
+
if (!enforceKebab) return violations;
|
|
2633
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind4.CallExpression)) {
|
|
2634
|
+
const expr = call.getExpression();
|
|
2635
|
+
if (expr.getKind() !== SyntaxKind4.PropertyAccessExpression) continue;
|
|
2636
|
+
const method = expr.getName?.();
|
|
2637
|
+
if (!method || !HTTP_METHODS.has(String(method))) continue;
|
|
2638
|
+
const firstArg = call.getArguments()[0];
|
|
2639
|
+
if (!firstArg || firstArg.getKind() !== SyntaxKind4.StringLiteral) continue;
|
|
2640
|
+
const pathValue = firstArg.getLiteralValue?.() ?? firstArg.getText().slice(1, -1);
|
|
2641
|
+
if (typeof pathValue !== "string") continue;
|
|
2642
|
+
if (!isKebabPath(pathValue)) {
|
|
2643
|
+
violations.push(createViolation({
|
|
2644
|
+
decisionId,
|
|
2645
|
+
constraintId: constraint.id,
|
|
2646
|
+
type: constraint.type,
|
|
2647
|
+
severity: constraint.severity,
|
|
2648
|
+
message: `Endpoint path "${pathValue}" is not kebab-case`,
|
|
2649
|
+
file: filePath,
|
|
2650
|
+
line: call.getStartLineNumber(),
|
|
2651
|
+
suggestion: "Use lowercase and hyphens in static path segments (e.g., /user-settings)"
|
|
2652
|
+
}));
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
return violations;
|
|
2656
|
+
}
|
|
2657
|
+
};
|
|
2658
|
+
|
|
2044
2659
|
// src/verification/verifiers/index.ts
|
|
2045
2660
|
var builtinVerifiers = {
|
|
2046
2661
|
naming: () => new NamingVerifier(),
|
|
2047
2662
|
imports: () => new ImportsVerifier(),
|
|
2048
2663
|
errors: () => new ErrorsVerifier(),
|
|
2049
|
-
regex: () => new RegexVerifier()
|
|
2664
|
+
regex: () => new RegexVerifier(),
|
|
2665
|
+
dependencies: () => new DependencyVerifier(),
|
|
2666
|
+
complexity: () => new ComplexityVerifier(),
|
|
2667
|
+
security: () => new SecurityVerifier(),
|
|
2668
|
+
api: () => new ApiVerifier()
|
|
2050
2669
|
};
|
|
2051
2670
|
function getVerifier(id) {
|
|
2052
2671
|
const factory = builtinVerifiers[id];
|
|
@@ -2057,6 +2676,18 @@ function selectVerifierForConstraint(rule, specifiedVerifier) {
|
|
|
2057
2676
|
return getVerifier(specifiedVerifier);
|
|
2058
2677
|
}
|
|
2059
2678
|
const lowerRule = rule.toLowerCase();
|
|
2679
|
+
if (lowerRule.includes("dependency") || lowerRule.includes("circular dependenc") || lowerRule.includes("import depth") || lowerRule.includes("layer") && lowerRule.includes("depend on")) {
|
|
2680
|
+
return getVerifier("dependencies");
|
|
2681
|
+
}
|
|
2682
|
+
if (lowerRule.includes("cyclomatic") || lowerRule.includes("complexity") || lowerRule.includes("nesting") || lowerRule.includes("parameters") || lowerRule.includes("file size")) {
|
|
2683
|
+
return getVerifier("complexity");
|
|
2684
|
+
}
|
|
2685
|
+
if (lowerRule.includes("security") || lowerRule.includes("secret") || lowerRule.includes("password") || lowerRule.includes("token") || lowerRule.includes("xss") || lowerRule.includes("sql") || lowerRule.includes("eval")) {
|
|
2686
|
+
return getVerifier("security");
|
|
2687
|
+
}
|
|
2688
|
+
if (lowerRule.includes("endpoint") || lowerRule.includes("rest") || lowerRule.includes("api") && lowerRule.includes("path")) {
|
|
2689
|
+
return getVerifier("api");
|
|
2690
|
+
}
|
|
2060
2691
|
if (lowerRule.includes("naming") || lowerRule.includes("case")) {
|
|
2061
2692
|
return getVerifier("naming");
|
|
2062
2693
|
}
|
|
@@ -2072,10 +2703,60 @@ function selectVerifierForConstraint(rule, specifiedVerifier) {
|
|
|
2072
2703
|
return getVerifier("regex");
|
|
2073
2704
|
}
|
|
2074
2705
|
|
|
2706
|
+
// src/verification/cache.ts
|
|
2707
|
+
import { stat as stat2 } from "fs/promises";
|
|
2708
|
+
var AstCache = class {
|
|
2709
|
+
cache = /* @__PURE__ */ new Map();
|
|
2710
|
+
async get(filePath, project) {
|
|
2711
|
+
try {
|
|
2712
|
+
const info = await stat2(filePath);
|
|
2713
|
+
const cached = this.cache.get(filePath);
|
|
2714
|
+
if (cached && cached.mtimeMs >= info.mtimeMs) {
|
|
2715
|
+
return cached.sourceFile;
|
|
2716
|
+
}
|
|
2717
|
+
let sourceFile = project.getSourceFile(filePath);
|
|
2718
|
+
if (!sourceFile) {
|
|
2719
|
+
sourceFile = project.addSourceFileAtPath(filePath);
|
|
2720
|
+
} else {
|
|
2721
|
+
sourceFile.refreshFromFileSystemSync();
|
|
2722
|
+
}
|
|
2723
|
+
this.cache.set(filePath, { sourceFile, mtimeMs: info.mtimeMs });
|
|
2724
|
+
return sourceFile;
|
|
2725
|
+
} catch {
|
|
2726
|
+
return null;
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
clear() {
|
|
2730
|
+
this.cache.clear();
|
|
2731
|
+
}
|
|
2732
|
+
};
|
|
2733
|
+
|
|
2734
|
+
// src/verification/applicability.ts
|
|
2735
|
+
function isConstraintExcepted(filePath, constraint, cwd) {
|
|
2736
|
+
if (!constraint.exceptions) return false;
|
|
2737
|
+
return constraint.exceptions.some((exception) => {
|
|
2738
|
+
if (exception.expiresAt) {
|
|
2739
|
+
const expiryDate = new Date(exception.expiresAt);
|
|
2740
|
+
if (expiryDate < /* @__PURE__ */ new Date()) {
|
|
2741
|
+
return false;
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
return matchesPattern(filePath, exception.pattern, { cwd });
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
function shouldApplyConstraintToFile(params) {
|
|
2748
|
+
const { filePath, constraint, cwd, severityFilter } = params;
|
|
2749
|
+
if (!matchesPattern(filePath, constraint.scope, { cwd })) return false;
|
|
2750
|
+
if (severityFilter && !severityFilter.includes(constraint.severity)) return false;
|
|
2751
|
+
if (isConstraintExcepted(filePath, constraint, cwd)) return false;
|
|
2752
|
+
return true;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2075
2755
|
// src/verification/engine.ts
|
|
2076
2756
|
var VerificationEngine = class {
|
|
2077
2757
|
registry;
|
|
2078
2758
|
project;
|
|
2759
|
+
astCache;
|
|
2079
2760
|
constructor(registry) {
|
|
2080
2761
|
this.registry = registry || createRegistry();
|
|
2081
2762
|
this.project = new Project2({
|
|
@@ -2087,6 +2768,7 @@ var VerificationEngine = class {
|
|
|
2087
2768
|
},
|
|
2088
2769
|
skipAddingFilesFromTsConfig: true
|
|
2089
2770
|
});
|
|
2771
|
+
this.astCache = new AstCache();
|
|
2090
2772
|
}
|
|
2091
2773
|
/**
|
|
2092
2774
|
* Run verification
|
|
@@ -2129,8 +2811,8 @@ var VerificationEngine = class {
|
|
|
2129
2811
|
let failed = 0;
|
|
2130
2812
|
const skipped = 0;
|
|
2131
2813
|
let timeoutHandle = null;
|
|
2132
|
-
const timeoutPromise = new Promise((
|
|
2133
|
-
timeoutHandle = setTimeout(() =>
|
|
2814
|
+
const timeoutPromise = new Promise((resolve2) => {
|
|
2815
|
+
timeoutHandle = setTimeout(() => resolve2("timeout"), timeout);
|
|
2134
2816
|
timeoutHandle.unref();
|
|
2135
2817
|
});
|
|
2136
2818
|
const verificationPromise = this.verifyFiles(
|
|
@@ -2192,23 +2874,11 @@ var VerificationEngine = class {
|
|
|
2192
2874
|
*/
|
|
2193
2875
|
async verifyFile(filePath, decisions, severityFilter, cwd = process.cwd()) {
|
|
2194
2876
|
const violations = [];
|
|
2195
|
-
|
|
2196
|
-
if (!sourceFile)
|
|
2197
|
-
try {
|
|
2198
|
-
sourceFile = this.project.addSourceFileAtPath(filePath);
|
|
2199
|
-
} catch {
|
|
2200
|
-
return violations;
|
|
2201
|
-
}
|
|
2202
|
-
}
|
|
2877
|
+
const sourceFile = await this.astCache.get(filePath, this.project);
|
|
2878
|
+
if (!sourceFile) return violations;
|
|
2203
2879
|
for (const decision of decisions) {
|
|
2204
2880
|
for (const constraint of decision.constraints) {
|
|
2205
|
-
if (!
|
|
2206
|
-
continue;
|
|
2207
|
-
}
|
|
2208
|
-
if (severityFilter && !severityFilter.includes(constraint.severity)) {
|
|
2209
|
-
continue;
|
|
2210
|
-
}
|
|
2211
|
-
if (this.isExcepted(filePath, constraint, cwd)) {
|
|
2881
|
+
if (!shouldApplyConstraintToFile({ filePath, constraint, cwd, severityFilter })) {
|
|
2212
2882
|
continue;
|
|
2213
2883
|
}
|
|
2214
2884
|
const verifier = selectVerifierForConstraint(constraint.rule, constraint.verifier);
|
|
@@ -2234,25 +2904,16 @@ var VerificationEngine = class {
|
|
|
2234
2904
|
* Verify multiple files
|
|
2235
2905
|
*/
|
|
2236
2906
|
async verifyFiles(files, decisions, severityFilter, cwd, onFileVerified) {
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
isExcepted(filePath, constraint, cwd) {
|
|
2246
|
-
if (!constraint.exceptions) return false;
|
|
2247
|
-
return constraint.exceptions.some((exception) => {
|
|
2248
|
-
if (exception.expiresAt) {
|
|
2249
|
-
const expiryDate = new Date(exception.expiresAt);
|
|
2250
|
-
if (expiryDate < /* @__PURE__ */ new Date()) {
|
|
2251
|
-
return false;
|
|
2252
|
-
}
|
|
2907
|
+
const BATCH_SIZE = 10;
|
|
2908
|
+
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
2909
|
+
const batch = files.slice(i, i + BATCH_SIZE);
|
|
2910
|
+
const results = await Promise.all(
|
|
2911
|
+
batch.map((file) => this.verifyFile(file, decisions, severityFilter, cwd))
|
|
2912
|
+
);
|
|
2913
|
+
for (const violations of results) {
|
|
2914
|
+
onFileVerified(violations);
|
|
2253
2915
|
}
|
|
2254
|
-
|
|
2255
|
-
});
|
|
2916
|
+
}
|
|
2256
2917
|
}
|
|
2257
2918
|
/**
|
|
2258
2919
|
* Get registry
|
|
@@ -2265,8 +2926,111 @@ function createVerificationEngine(registry) {
|
|
|
2265
2926
|
return new VerificationEngine(registry);
|
|
2266
2927
|
}
|
|
2267
2928
|
|
|
2929
|
+
// src/verification/autofix/engine.ts
|
|
2930
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
2931
|
+
import readline from "readline/promises";
|
|
2932
|
+
import { stdin, stdout } from "process";
|
|
2933
|
+
function applyEdits(content, edits) {
|
|
2934
|
+
const sorted = [...edits].sort((a, b) => b.start - a.start);
|
|
2935
|
+
let next = content;
|
|
2936
|
+
const patches = [];
|
|
2937
|
+
let skippedEdits = 0;
|
|
2938
|
+
let lastStart = Number.POSITIVE_INFINITY;
|
|
2939
|
+
for (const edit of sorted) {
|
|
2940
|
+
if (edit.start < 0 || edit.end < edit.start || edit.end > next.length) {
|
|
2941
|
+
skippedEdits++;
|
|
2942
|
+
continue;
|
|
2943
|
+
}
|
|
2944
|
+
if (edit.end > lastStart) {
|
|
2945
|
+
skippedEdits++;
|
|
2946
|
+
continue;
|
|
2947
|
+
}
|
|
2948
|
+
lastStart = edit.start;
|
|
2949
|
+
const originalText = next.slice(edit.start, edit.end);
|
|
2950
|
+
next = next.slice(0, edit.start) + edit.text + next.slice(edit.end);
|
|
2951
|
+
patches.push({
|
|
2952
|
+
filePath: "",
|
|
2953
|
+
description: edit.description,
|
|
2954
|
+
start: edit.start,
|
|
2955
|
+
end: edit.end,
|
|
2956
|
+
originalText,
|
|
2957
|
+
fixedText: edit.text
|
|
2958
|
+
});
|
|
2959
|
+
}
|
|
2960
|
+
return { next, patches, skippedEdits };
|
|
2961
|
+
}
|
|
2962
|
+
async function confirmFix(prompt) {
|
|
2963
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
2964
|
+
try {
|
|
2965
|
+
const answer = await rl.question(`${prompt} (y/N) `);
|
|
2966
|
+
return answer.trim().toLowerCase() === "y";
|
|
2967
|
+
} finally {
|
|
2968
|
+
rl.close();
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
var AutofixEngine = class {
|
|
2972
|
+
async applyFixes(violations, options = {}) {
|
|
2973
|
+
const fixable = violations.filter((v) => v.autofix && v.autofix.edits.length > 0);
|
|
2974
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
2975
|
+
for (const v of fixable) {
|
|
2976
|
+
const list = byFile.get(v.file) ?? [];
|
|
2977
|
+
list.push(v);
|
|
2978
|
+
byFile.set(v.file, list);
|
|
2979
|
+
}
|
|
2980
|
+
const applied = [];
|
|
2981
|
+
let skippedViolations = 0;
|
|
2982
|
+
for (const [filePath, fileViolations] of byFile) {
|
|
2983
|
+
const original = await readFile2(filePath, "utf-8");
|
|
2984
|
+
const edits = [];
|
|
2985
|
+
for (const violation of fileViolations) {
|
|
2986
|
+
const fix = violation.autofix;
|
|
2987
|
+
if (options.interactive) {
|
|
2988
|
+
const ok = await confirmFix(`Apply fix: ${fix.description} (${filePath}:${violation.line ?? 1})?`);
|
|
2989
|
+
if (!ok) {
|
|
2990
|
+
skippedViolations++;
|
|
2991
|
+
continue;
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
for (const edit of fix.edits) {
|
|
2995
|
+
edits.push({ ...edit, description: fix.description });
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
if (edits.length === 0) continue;
|
|
2999
|
+
const { next, patches, skippedEdits } = applyEdits(original, edits);
|
|
3000
|
+
skippedViolations += skippedEdits;
|
|
3001
|
+
if (!options.dryRun) {
|
|
3002
|
+
await writeFile2(filePath, next, "utf-8");
|
|
3003
|
+
}
|
|
3004
|
+
for (const patch of patches) {
|
|
3005
|
+
applied.push({ ...patch, filePath });
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
return { applied, skipped: skippedViolations };
|
|
3009
|
+
}
|
|
3010
|
+
};
|
|
3011
|
+
|
|
3012
|
+
// src/verification/incremental.ts
|
|
3013
|
+
import { execFile } from "child_process";
|
|
3014
|
+
import { promisify } from "util";
|
|
3015
|
+
import { resolve } from "path";
|
|
3016
|
+
var execFileAsync = promisify(execFile);
|
|
3017
|
+
async function getChangedFiles(cwd) {
|
|
3018
|
+
try {
|
|
3019
|
+
const { stdout: stdout2 } = await execFileAsync("git", ["diff", "--name-only", "--diff-filter=AM", "HEAD"], { cwd });
|
|
3020
|
+
const rel = stdout2.trim().split("\n").map((s) => s.trim()).filter(Boolean);
|
|
3021
|
+
const abs = [];
|
|
3022
|
+
for (const file of rel) {
|
|
3023
|
+
const full = resolve(cwd, file);
|
|
3024
|
+
if (await pathExists(full)) abs.push(full);
|
|
3025
|
+
}
|
|
3026
|
+
return abs;
|
|
3027
|
+
} catch {
|
|
3028
|
+
return [];
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
|
|
2268
3032
|
// src/cli/commands/verify.ts
|
|
2269
|
-
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("--
|
|
3033
|
+
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("--incremental", "Only verify changed files (git diff --name-only --diff-filter=AM HEAD)").option("--fix", "Apply auto-fixes for supported violations").option("--dry-run", "Show what would be fixed without applying (requires --fix)").option("--interactive", "Confirm each fix interactively (requires --fix)").action(async (options) => {
|
|
2270
3034
|
const cwd = process.cwd();
|
|
2271
3035
|
if (!await pathExists(getSpecBridgeDir(cwd))) {
|
|
2272
3036
|
throw new NotInitializedError();
|
|
@@ -2275,23 +3039,60 @@ var verifyCommand = new Command3("verify").description("Verify code compliance a
|
|
|
2275
3039
|
try {
|
|
2276
3040
|
const config = await loadConfig(cwd);
|
|
2277
3041
|
const level = options.level || "full";
|
|
2278
|
-
|
|
3042
|
+
let files = options.files?.split(",").map((f) => f.trim());
|
|
2279
3043
|
const decisions = options.decisions?.split(",").map((d) => d.trim());
|
|
2280
3044
|
const severity = options.severity?.split(",").map((s) => s.trim());
|
|
3045
|
+
if (options.incremental) {
|
|
3046
|
+
const changed = await getChangedFiles(cwd);
|
|
3047
|
+
files = changed.length > 0 ? changed : [];
|
|
3048
|
+
}
|
|
2281
3049
|
spinner.text = `Running ${level}-level verification...`;
|
|
2282
3050
|
const engine = createVerificationEngine();
|
|
2283
|
-
|
|
3051
|
+
let result = await engine.verify(config, {
|
|
2284
3052
|
level,
|
|
2285
3053
|
files,
|
|
2286
3054
|
decisions,
|
|
2287
3055
|
severity,
|
|
2288
3056
|
cwd
|
|
2289
3057
|
});
|
|
3058
|
+
let fixResult;
|
|
3059
|
+
if (options.fix && result.violations.length > 0) {
|
|
3060
|
+
const fixableCount = result.violations.filter((v) => v.autofix).length;
|
|
3061
|
+
if (fixableCount === 0) {
|
|
3062
|
+
spinner.stop();
|
|
3063
|
+
if (!options.json) {
|
|
3064
|
+
console.log(chalk3.yellow("No auto-fixable violations found"));
|
|
3065
|
+
}
|
|
3066
|
+
} else {
|
|
3067
|
+
spinner.text = `Applying ${fixableCount} auto-fix(es)...`;
|
|
3068
|
+
const fixer = new AutofixEngine();
|
|
3069
|
+
fixResult = await fixer.applyFixes(result.violations, {
|
|
3070
|
+
dryRun: options.dryRun,
|
|
3071
|
+
interactive: options.interactive
|
|
3072
|
+
});
|
|
3073
|
+
if (!options.dryRun && fixResult.applied.length > 0) {
|
|
3074
|
+
result = await engine.verify(config, {
|
|
3075
|
+
level,
|
|
3076
|
+
files,
|
|
3077
|
+
decisions,
|
|
3078
|
+
severity,
|
|
3079
|
+
cwd
|
|
3080
|
+
});
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
2290
3084
|
spinner.stop();
|
|
2291
3085
|
if (options.json) {
|
|
2292
|
-
console.log(JSON.stringify(result, null, 2));
|
|
3086
|
+
console.log(JSON.stringify({ ...result, autofix: fixResult }, null, 2));
|
|
2293
3087
|
} else {
|
|
2294
3088
|
printResult(result, level);
|
|
3089
|
+
if (options.fix && fixResult) {
|
|
3090
|
+
console.log(chalk3.green(`\u2713 Applied ${fixResult.applied.length} fix(es)`));
|
|
3091
|
+
if (fixResult.skipped > 0) {
|
|
3092
|
+
console.log(chalk3.yellow(`\u2298 Skipped ${fixResult.skipped} fix(es)`));
|
|
3093
|
+
}
|
|
3094
|
+
console.log("");
|
|
3095
|
+
}
|
|
2295
3096
|
}
|
|
2296
3097
|
if (!result.success) {
|
|
2297
3098
|
process.exit(1);
|
|
@@ -2748,17 +3549,10 @@ var HOOK_SCRIPT = `#!/bin/sh
|
|
|
2748
3549
|
# SpecBridge pre-commit hook
|
|
2749
3550
|
# Runs verification on staged files
|
|
2750
3551
|
|
|
2751
|
-
# Get list of staged TypeScript files
|
|
2752
|
-
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\\.(ts|tsx)$')
|
|
2753
|
-
|
|
2754
|
-
if [ -z "$STAGED_FILES" ]; then
|
|
2755
|
-
exit 0
|
|
2756
|
-
fi
|
|
2757
|
-
|
|
2758
3552
|
echo "Running SpecBridge verification..."
|
|
2759
3553
|
|
|
2760
|
-
# Run specbridge
|
|
2761
|
-
npx specbridge hook run --level commit
|
|
3554
|
+
# Run specbridge hook (it will detect staged files automatically)
|
|
3555
|
+
npx specbridge hook run --level commit
|
|
2762
3556
|
|
|
2763
3557
|
exit $?
|
|
2764
3558
|
`;
|
|
@@ -2840,7 +3634,18 @@ function createHookCommand() {
|
|
|
2840
3634
|
try {
|
|
2841
3635
|
const config = await loadConfig(cwd);
|
|
2842
3636
|
const level = options.level || "commit";
|
|
2843
|
-
|
|
3637
|
+
let files = options.files ? options.files.split(/[\s,]+/).filter((f) => f.length > 0) : void 0;
|
|
3638
|
+
if (!files || files.length === 0) {
|
|
3639
|
+
const { execFile: execFile2 } = await import("child_process");
|
|
3640
|
+
const { promisify: promisify2 } = await import("util");
|
|
3641
|
+
const execFileAsync2 = promisify2(execFile2);
|
|
3642
|
+
try {
|
|
3643
|
+
const { stdout: stdout2 } = await execFileAsync2("git", ["diff", "--cached", "--name-only", "--diff-filter=AM"], { cwd });
|
|
3644
|
+
files = stdout2.trim().split("\n").map((s) => s.trim()).filter(Boolean).filter((f) => /\.(ts|tsx|js|jsx)$/.test(f));
|
|
3645
|
+
} catch {
|
|
3646
|
+
files = [];
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
2844
3649
|
if (!files || files.length === 0) {
|
|
2845
3650
|
process.exit(0);
|
|
2846
3651
|
}
|
|
@@ -3322,11 +4127,547 @@ var contextCommand = new Command11("context").description("Generate architectura
|
|
|
3322
4127
|
}
|
|
3323
4128
|
});
|
|
3324
4129
|
|
|
4130
|
+
// src/cli/commands/lsp.ts
|
|
4131
|
+
import { Command as Command12 } from "commander";
|
|
4132
|
+
|
|
4133
|
+
// src/lsp/server.ts
|
|
4134
|
+
import { createConnection, ProposedFeatures, TextDocuments, TextDocumentSyncKind, DiagnosticSeverity, CodeActionKind } from "vscode-languageserver/node.js";
|
|
4135
|
+
import { TextDocument } from "vscode-languageserver-textdocument";
|
|
4136
|
+
import { fileURLToPath } from "url";
|
|
4137
|
+
import path3 from "path";
|
|
4138
|
+
import { Project as Project3 } from "ts-morph";
|
|
4139
|
+
import chalk12 from "chalk";
|
|
4140
|
+
function severityToDiagnostic(severity) {
|
|
4141
|
+
switch (severity) {
|
|
4142
|
+
case "critical":
|
|
4143
|
+
return DiagnosticSeverity.Error;
|
|
4144
|
+
case "high":
|
|
4145
|
+
return DiagnosticSeverity.Warning;
|
|
4146
|
+
case "medium":
|
|
4147
|
+
return DiagnosticSeverity.Information;
|
|
4148
|
+
case "low":
|
|
4149
|
+
return DiagnosticSeverity.Hint;
|
|
4150
|
+
default:
|
|
4151
|
+
return DiagnosticSeverity.Information;
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
function uriToFilePath(uri) {
|
|
4155
|
+
if (uri.startsWith("file://")) return fileURLToPath(uri);
|
|
4156
|
+
return uri;
|
|
4157
|
+
}
|
|
4158
|
+
function violationToRange(doc, v) {
|
|
4159
|
+
const edit = v.autofix?.edits?.[0];
|
|
4160
|
+
if (edit) {
|
|
4161
|
+
return {
|
|
4162
|
+
start: doc.positionAt(edit.start),
|
|
4163
|
+
end: doc.positionAt(edit.end)
|
|
4164
|
+
};
|
|
4165
|
+
}
|
|
4166
|
+
const line = Math.max(0, (v.line ?? 1) - 1);
|
|
4167
|
+
const char = Math.max(0, (v.column ?? 1) - 1);
|
|
4168
|
+
return {
|
|
4169
|
+
start: { line, character: char },
|
|
4170
|
+
end: { line, character: char + 1 }
|
|
4171
|
+
};
|
|
4172
|
+
}
|
|
4173
|
+
var SpecBridgeLspServer = class {
|
|
4174
|
+
connection = createConnection(ProposedFeatures.all);
|
|
4175
|
+
documents = new TextDocuments(TextDocument);
|
|
4176
|
+
options;
|
|
4177
|
+
registry = null;
|
|
4178
|
+
decisions = [];
|
|
4179
|
+
cwd;
|
|
4180
|
+
project;
|
|
4181
|
+
cache = /* @__PURE__ */ new Map();
|
|
4182
|
+
initError = null;
|
|
4183
|
+
constructor(options) {
|
|
4184
|
+
this.options = options;
|
|
4185
|
+
this.cwd = options.cwd;
|
|
4186
|
+
this.project = new Project3({
|
|
4187
|
+
compilerOptions: {
|
|
4188
|
+
allowJs: true,
|
|
4189
|
+
checkJs: false,
|
|
4190
|
+
noEmit: true,
|
|
4191
|
+
skipLibCheck: true
|
|
4192
|
+
},
|
|
4193
|
+
skipAddingFilesFromTsConfig: true
|
|
4194
|
+
});
|
|
4195
|
+
}
|
|
4196
|
+
async initialize() {
|
|
4197
|
+
this.connection.onInitialize(async () => {
|
|
4198
|
+
await this.initializeWorkspace();
|
|
4199
|
+
return {
|
|
4200
|
+
capabilities: {
|
|
4201
|
+
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
4202
|
+
codeActionProvider: true
|
|
4203
|
+
}
|
|
4204
|
+
};
|
|
4205
|
+
});
|
|
4206
|
+
this.documents.onDidOpen((e) => {
|
|
4207
|
+
void this.validateDocument(e.document);
|
|
4208
|
+
});
|
|
4209
|
+
this.documents.onDidChangeContent((change) => {
|
|
4210
|
+
void this.validateDocument(change.document);
|
|
4211
|
+
});
|
|
4212
|
+
this.documents.onDidClose((e) => {
|
|
4213
|
+
this.cache.delete(e.document.uri);
|
|
4214
|
+
this.connection.sendDiagnostics({ uri: e.document.uri, diagnostics: [] });
|
|
4215
|
+
});
|
|
4216
|
+
this.connection.onCodeAction((params) => {
|
|
4217
|
+
const violations = this.cache.get(params.textDocument.uri) || [];
|
|
4218
|
+
const doc = this.documents.get(params.textDocument.uri);
|
|
4219
|
+
if (!doc) return [];
|
|
4220
|
+
return violations.filter((v) => v.autofix && v.autofix.edits.length > 0).map((v) => {
|
|
4221
|
+
const edits = v.autofix.edits.map((edit) => ({
|
|
4222
|
+
range: {
|
|
4223
|
+
start: doc.positionAt(edit.start),
|
|
4224
|
+
end: doc.positionAt(edit.end)
|
|
4225
|
+
},
|
|
4226
|
+
newText: edit.text
|
|
4227
|
+
}));
|
|
4228
|
+
return {
|
|
4229
|
+
title: v.autofix.description,
|
|
4230
|
+
kind: CodeActionKind.QuickFix,
|
|
4231
|
+
edit: {
|
|
4232
|
+
changes: {
|
|
4233
|
+
[params.textDocument.uri]: edits
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
};
|
|
4237
|
+
});
|
|
4238
|
+
});
|
|
4239
|
+
this.documents.listen(this.connection);
|
|
4240
|
+
this.connection.listen();
|
|
4241
|
+
}
|
|
4242
|
+
async initializeWorkspace() {
|
|
4243
|
+
if (!await pathExists(getSpecBridgeDir(this.cwd))) {
|
|
4244
|
+
const err = new NotInitializedError();
|
|
4245
|
+
this.initError = err.message;
|
|
4246
|
+
if (this.options.verbose) this.connection.console.error(chalk12.red(this.initError));
|
|
4247
|
+
return;
|
|
4248
|
+
}
|
|
4249
|
+
try {
|
|
4250
|
+
const config = await loadConfig(this.cwd);
|
|
4251
|
+
this.registry = createRegistry({ basePath: this.cwd });
|
|
4252
|
+
await this.registry.load();
|
|
4253
|
+
this.decisions = this.registry.getActive();
|
|
4254
|
+
for (const root of config.project.sourceRoots) {
|
|
4255
|
+
const rootPath = path3.isAbsolute(root) ? root : path3.join(this.cwd, root);
|
|
4256
|
+
const dir = rootPath.includes("*") ? rootPath.split("*")[0] : rootPath;
|
|
4257
|
+
if (dir && await pathExists(dir)) {
|
|
4258
|
+
this.project.addSourceFilesAtPaths(path3.join(dir, "**/*.{ts,tsx,js,jsx}"));
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
if (this.options.verbose) {
|
|
4262
|
+
this.connection.console.log(chalk12.dim(`Loaded ${this.decisions.length} active decision(s)`));
|
|
4263
|
+
}
|
|
4264
|
+
} catch (error) {
|
|
4265
|
+
this.initError = error instanceof Error ? error.message : String(error);
|
|
4266
|
+
if (this.options.verbose) this.connection.console.error(chalk12.red(this.initError));
|
|
4267
|
+
}
|
|
4268
|
+
}
|
|
4269
|
+
async verifyTextDocument(doc) {
|
|
4270
|
+
if (this.initError) {
|
|
4271
|
+
throw new Error(this.initError);
|
|
4272
|
+
}
|
|
4273
|
+
if (!this.registry) return [];
|
|
4274
|
+
const filePath = uriToFilePath(doc.uri);
|
|
4275
|
+
const sourceFile = this.project.createSourceFile(filePath, doc.getText(), { overwrite: true });
|
|
4276
|
+
const violations = [];
|
|
4277
|
+
for (const decision of this.decisions) {
|
|
4278
|
+
for (const constraint of decision.constraints) {
|
|
4279
|
+
if (!shouldApplyConstraintToFile({ filePath, constraint, cwd: this.cwd })) continue;
|
|
4280
|
+
const verifier = selectVerifierForConstraint(constraint.rule, constraint.verifier);
|
|
4281
|
+
if (!verifier) continue;
|
|
4282
|
+
const ctx = {
|
|
4283
|
+
filePath,
|
|
4284
|
+
sourceFile,
|
|
4285
|
+
constraint,
|
|
4286
|
+
decisionId: decision.metadata.id
|
|
4287
|
+
};
|
|
4288
|
+
try {
|
|
4289
|
+
const constraintViolations = await verifier.verify(ctx);
|
|
4290
|
+
violations.push(...constraintViolations);
|
|
4291
|
+
} catch {
|
|
4292
|
+
}
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
return violations;
|
|
4296
|
+
}
|
|
4297
|
+
async validateDocument(doc) {
|
|
4298
|
+
try {
|
|
4299
|
+
const violations = await this.verifyTextDocument(doc);
|
|
4300
|
+
this.cache.set(doc.uri, violations);
|
|
4301
|
+
const diagnostics = violations.map((v) => ({
|
|
4302
|
+
range: violationToRange(doc, v),
|
|
4303
|
+
severity: severityToDiagnostic(v.severity),
|
|
4304
|
+
message: v.message,
|
|
4305
|
+
source: "specbridge"
|
|
4306
|
+
}));
|
|
4307
|
+
this.connection.sendDiagnostics({ uri: doc.uri, diagnostics });
|
|
4308
|
+
} catch (error) {
|
|
4309
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
4310
|
+
this.connection.sendDiagnostics({
|
|
4311
|
+
uri: doc.uri,
|
|
4312
|
+
diagnostics: [
|
|
4313
|
+
{
|
|
4314
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
|
|
4315
|
+
severity: DiagnosticSeverity.Error,
|
|
4316
|
+
message: msg,
|
|
4317
|
+
source: "specbridge"
|
|
4318
|
+
}
|
|
4319
|
+
]
|
|
4320
|
+
});
|
|
4321
|
+
}
|
|
4322
|
+
}
|
|
4323
|
+
};
|
|
4324
|
+
|
|
4325
|
+
// src/lsp/index.ts
|
|
4326
|
+
async function startLspServer(options) {
|
|
4327
|
+
const server = new SpecBridgeLspServer(options);
|
|
4328
|
+
await server.initialize();
|
|
4329
|
+
}
|
|
4330
|
+
|
|
4331
|
+
// src/cli/commands/lsp.ts
|
|
4332
|
+
var lspCommand = new Command12("lsp").description("Start SpecBridge language server (stdio)").option("--verbose", "Enable verbose server logging", false).action(async (options) => {
|
|
4333
|
+
await startLspServer({ cwd: process.cwd(), verbose: Boolean(options.verbose) });
|
|
4334
|
+
});
|
|
4335
|
+
|
|
4336
|
+
// src/cli/commands/watch.ts
|
|
4337
|
+
import { Command as Command13 } from "commander";
|
|
4338
|
+
import chalk13 from "chalk";
|
|
4339
|
+
import chokidar from "chokidar";
|
|
4340
|
+
import path4 from "path";
|
|
4341
|
+
var watchCommand = new Command13("watch").description("Watch for changes and verify files continuously").option("-l, --level <level>", "Verification level (commit, pr, full)", "full").option("--debounce <ms>", "Debounce verify on rapid changes", "150").action(async (options) => {
|
|
4342
|
+
const cwd = process.cwd();
|
|
4343
|
+
if (!await pathExists(getSpecBridgeDir(cwd))) {
|
|
4344
|
+
throw new NotInitializedError();
|
|
4345
|
+
}
|
|
4346
|
+
const config = await loadConfig(cwd);
|
|
4347
|
+
const engine = createVerificationEngine();
|
|
4348
|
+
const level = options.level || "full";
|
|
4349
|
+
const debounceMs = Number.parseInt(options.debounce || "150", 10);
|
|
4350
|
+
let timer = null;
|
|
4351
|
+
let pendingPath = null;
|
|
4352
|
+
const run = async (changedPath) => {
|
|
4353
|
+
const absolutePath = path4.isAbsolute(changedPath) ? changedPath : path4.join(cwd, changedPath);
|
|
4354
|
+
const result = await engine.verify(config, {
|
|
4355
|
+
level,
|
|
4356
|
+
files: [absolutePath],
|
|
4357
|
+
cwd
|
|
4358
|
+
});
|
|
4359
|
+
const prefix = result.success ? chalk13.green("\u2713") : chalk13.red("\u2717");
|
|
4360
|
+
const summary = `${prefix} ${path4.relative(cwd, absolutePath)}: ${result.violations.length} violation(s)`;
|
|
4361
|
+
console.log(summary);
|
|
4362
|
+
for (const v of result.violations.slice(0, 20)) {
|
|
4363
|
+
const loc = v.line ? `:${v.line}${v.column ? `:${v.column}` : ""}` : "";
|
|
4364
|
+
console.log(chalk13.dim(` - ${v.file}${loc}: ${v.message} [${v.severity}]`));
|
|
4365
|
+
}
|
|
4366
|
+
if (result.violations.length > 20) {
|
|
4367
|
+
console.log(chalk13.dim(` \u2026 ${result.violations.length - 20} more`));
|
|
4368
|
+
}
|
|
4369
|
+
};
|
|
4370
|
+
const watcher = chokidar.watch(config.project.sourceRoots, {
|
|
4371
|
+
cwd,
|
|
4372
|
+
ignored: config.project.exclude,
|
|
4373
|
+
ignoreInitial: true,
|
|
4374
|
+
persistent: true
|
|
4375
|
+
});
|
|
4376
|
+
console.log(chalk13.blue("Watching for changes..."));
|
|
4377
|
+
watcher.on("change", (changedPath) => {
|
|
4378
|
+
pendingPath = changedPath;
|
|
4379
|
+
if (timer) clearTimeout(timer);
|
|
4380
|
+
timer = setTimeout(() => {
|
|
4381
|
+
if (!pendingPath) return;
|
|
4382
|
+
void run(pendingPath);
|
|
4383
|
+
pendingPath = null;
|
|
4384
|
+
}, debounceMs);
|
|
4385
|
+
});
|
|
4386
|
+
});
|
|
4387
|
+
|
|
4388
|
+
// src/cli/commands/mcp-server.ts
|
|
4389
|
+
import { Command as Command14 } from "commander";
|
|
4390
|
+
import { readFileSync } from "fs";
|
|
4391
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4392
|
+
import { dirname as dirname3, join as join9 } from "path";
|
|
4393
|
+
|
|
4394
|
+
// src/mcp/server.ts
|
|
4395
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4396
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4397
|
+
import { z as z3 } from "zod";
|
|
4398
|
+
var SpecBridgeMcpServer = class {
|
|
4399
|
+
server;
|
|
4400
|
+
cwd;
|
|
4401
|
+
config = null;
|
|
4402
|
+
registry = null;
|
|
4403
|
+
constructor(options) {
|
|
4404
|
+
this.cwd = options.cwd;
|
|
4405
|
+
this.server = new McpServer({ name: "specbridge", version: options.version });
|
|
4406
|
+
}
|
|
4407
|
+
async initialize() {
|
|
4408
|
+
this.config = await loadConfig(this.cwd);
|
|
4409
|
+
this.registry = createRegistry({ basePath: this.cwd });
|
|
4410
|
+
await this.registry.load();
|
|
4411
|
+
this.registerResources();
|
|
4412
|
+
this.registerTools();
|
|
4413
|
+
}
|
|
4414
|
+
async startStdio() {
|
|
4415
|
+
const transport = new StdioServerTransport();
|
|
4416
|
+
await this.server.connect(transport);
|
|
4417
|
+
}
|
|
4418
|
+
getReady() {
|
|
4419
|
+
if (!this.config || !this.registry) {
|
|
4420
|
+
throw new Error("SpecBridge MCP server not initialized. Call initialize() first.");
|
|
4421
|
+
}
|
|
4422
|
+
return { config: this.config, registry: this.registry };
|
|
4423
|
+
}
|
|
4424
|
+
registerResources() {
|
|
4425
|
+
const { config, registry } = this.getReady();
|
|
4426
|
+
this.server.registerResource(
|
|
4427
|
+
"decisions",
|
|
4428
|
+
"decision:///",
|
|
4429
|
+
{
|
|
4430
|
+
title: "Architectural Decisions",
|
|
4431
|
+
description: "List of all architectural decisions",
|
|
4432
|
+
mimeType: "application/json"
|
|
4433
|
+
},
|
|
4434
|
+
async (uri) => {
|
|
4435
|
+
const decisions = registry.getAll();
|
|
4436
|
+
return {
|
|
4437
|
+
contents: [
|
|
4438
|
+
{
|
|
4439
|
+
uri: uri.href,
|
|
4440
|
+
mimeType: "application/json",
|
|
4441
|
+
text: JSON.stringify(decisions, null, 2)
|
|
4442
|
+
}
|
|
4443
|
+
]
|
|
4444
|
+
};
|
|
4445
|
+
}
|
|
4446
|
+
);
|
|
4447
|
+
this.server.registerResource(
|
|
4448
|
+
"decision",
|
|
4449
|
+
new ResourceTemplate("decision://{id}", { list: void 0 }),
|
|
4450
|
+
{
|
|
4451
|
+
title: "Architectural Decision",
|
|
4452
|
+
description: "A specific architectural decision by id",
|
|
4453
|
+
mimeType: "application/json"
|
|
4454
|
+
},
|
|
4455
|
+
async (uri, variables) => {
|
|
4456
|
+
const raw = variables.id;
|
|
4457
|
+
const decisionId = Array.isArray(raw) ? raw[0] ?? "" : raw ?? "";
|
|
4458
|
+
const decision = registry.get(String(decisionId));
|
|
4459
|
+
return {
|
|
4460
|
+
contents: [
|
|
4461
|
+
{
|
|
4462
|
+
uri: uri.href,
|
|
4463
|
+
mimeType: "application/json",
|
|
4464
|
+
text: JSON.stringify(decision, null, 2)
|
|
4465
|
+
}
|
|
4466
|
+
]
|
|
4467
|
+
};
|
|
4468
|
+
}
|
|
4469
|
+
);
|
|
4470
|
+
this.server.registerResource(
|
|
4471
|
+
"latest_report",
|
|
4472
|
+
"report:///latest",
|
|
4473
|
+
{
|
|
4474
|
+
title: "Latest Compliance Report",
|
|
4475
|
+
description: "Most recent compliance report (generated on demand)",
|
|
4476
|
+
mimeType: "application/json"
|
|
4477
|
+
},
|
|
4478
|
+
async (uri) => {
|
|
4479
|
+
const report = await generateReport(config, { cwd: this.cwd });
|
|
4480
|
+
return {
|
|
4481
|
+
contents: [
|
|
4482
|
+
{
|
|
4483
|
+
uri: uri.href,
|
|
4484
|
+
mimeType: "application/json",
|
|
4485
|
+
text: JSON.stringify(report, null, 2)
|
|
4486
|
+
}
|
|
4487
|
+
]
|
|
4488
|
+
};
|
|
4489
|
+
}
|
|
4490
|
+
);
|
|
4491
|
+
}
|
|
4492
|
+
registerTools() {
|
|
4493
|
+
const { config } = this.getReady();
|
|
4494
|
+
this.server.registerTool(
|
|
4495
|
+
"generate_context",
|
|
4496
|
+
{
|
|
4497
|
+
title: "Generate architectural context",
|
|
4498
|
+
description: "Generate architectural context for a file from applicable decisions",
|
|
4499
|
+
inputSchema: {
|
|
4500
|
+
filePath: z3.string().describe("Path to the file to analyze"),
|
|
4501
|
+
includeRationale: z3.boolean().optional().default(true),
|
|
4502
|
+
format: z3.enum(["markdown", "json", "mcp"]).optional().default("markdown")
|
|
4503
|
+
}
|
|
4504
|
+
},
|
|
4505
|
+
async (args) => {
|
|
4506
|
+
const text = await generateFormattedContext(args.filePath, config, {
|
|
4507
|
+
includeRationale: args.includeRationale,
|
|
4508
|
+
format: args.format,
|
|
4509
|
+
cwd: this.cwd
|
|
4510
|
+
});
|
|
4511
|
+
return { content: [{ type: "text", text }] };
|
|
4512
|
+
}
|
|
4513
|
+
);
|
|
4514
|
+
this.server.registerTool(
|
|
4515
|
+
"verify_compliance",
|
|
4516
|
+
{
|
|
4517
|
+
title: "Verify compliance",
|
|
4518
|
+
description: "Verify code compliance against constraints",
|
|
4519
|
+
inputSchema: {
|
|
4520
|
+
level: z3.enum(["commit", "pr", "full"]).optional().default("full"),
|
|
4521
|
+
files: z3.array(z3.string()).optional(),
|
|
4522
|
+
decisions: z3.array(z3.string()).optional(),
|
|
4523
|
+
severity: z3.array(z3.enum(["critical", "high", "medium", "low"])).optional()
|
|
4524
|
+
}
|
|
4525
|
+
},
|
|
4526
|
+
async (args) => {
|
|
4527
|
+
const engine = createVerificationEngine();
|
|
4528
|
+
const result = await engine.verify(config, {
|
|
4529
|
+
level: args.level,
|
|
4530
|
+
files: args.files,
|
|
4531
|
+
decisions: args.decisions,
|
|
4532
|
+
severity: args.severity,
|
|
4533
|
+
cwd: this.cwd
|
|
4534
|
+
});
|
|
4535
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
4536
|
+
}
|
|
4537
|
+
);
|
|
4538
|
+
this.server.registerTool(
|
|
4539
|
+
"get_report",
|
|
4540
|
+
{
|
|
4541
|
+
title: "Get compliance report",
|
|
4542
|
+
description: "Generate a compliance report for the current workspace",
|
|
4543
|
+
inputSchema: {
|
|
4544
|
+
format: z3.enum(["summary", "detailed", "json", "markdown"]).optional().default("summary"),
|
|
4545
|
+
includeAll: z3.boolean().optional().default(false)
|
|
4546
|
+
}
|
|
4547
|
+
},
|
|
4548
|
+
async (args) => {
|
|
4549
|
+
const report = await generateReport(config, { cwd: this.cwd, includeAll: args.includeAll });
|
|
4550
|
+
if (args.format === "json") {
|
|
4551
|
+
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
4552
|
+
}
|
|
4553
|
+
if (args.format === "markdown") {
|
|
4554
|
+
return { content: [{ type: "text", text: formatMarkdownReport(report) }] };
|
|
4555
|
+
}
|
|
4556
|
+
if (args.format === "detailed") {
|
|
4557
|
+
return { content: [{ type: "text", text: formatConsoleReport(report) }] };
|
|
4558
|
+
}
|
|
4559
|
+
const summary = {
|
|
4560
|
+
timestamp: report.timestamp,
|
|
4561
|
+
project: report.project,
|
|
4562
|
+
compliance: report.summary.compliance,
|
|
4563
|
+
violations: report.summary.violations,
|
|
4564
|
+
decisions: report.summary.activeDecisions
|
|
4565
|
+
};
|
|
4566
|
+
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
|
4567
|
+
}
|
|
4568
|
+
);
|
|
4569
|
+
}
|
|
4570
|
+
};
|
|
4571
|
+
|
|
4572
|
+
// src/cli/commands/mcp-server.ts
|
|
4573
|
+
function getCliVersion() {
|
|
4574
|
+
try {
|
|
4575
|
+
const __dirname2 = dirname3(fileURLToPath2(import.meta.url));
|
|
4576
|
+
const packageJsonPath2 = join9(__dirname2, "../package.json");
|
|
4577
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath2, "utf-8"));
|
|
4578
|
+
return String(pkg.version || "0.0.0");
|
|
4579
|
+
} catch {
|
|
4580
|
+
return "0.0.0";
|
|
4581
|
+
}
|
|
4582
|
+
}
|
|
4583
|
+
var mcpServerCommand = new Command14("mcp-server").description("Start SpecBridge MCP server (stdio)").action(async () => {
|
|
4584
|
+
const server = new SpecBridgeMcpServer({
|
|
4585
|
+
cwd: process.cwd(),
|
|
4586
|
+
version: getCliVersion()
|
|
4587
|
+
});
|
|
4588
|
+
await server.initialize();
|
|
4589
|
+
console.error("SpecBridge MCP server starting (stdio)...");
|
|
4590
|
+
await server.startStdio();
|
|
4591
|
+
});
|
|
4592
|
+
|
|
4593
|
+
// src/cli/commands/prompt.ts
|
|
4594
|
+
import { Command as Command15 } from "commander";
|
|
4595
|
+
|
|
4596
|
+
// src/agent/templates.ts
|
|
4597
|
+
var templates = {
|
|
4598
|
+
"code-review": {
|
|
4599
|
+
name: "Code Review",
|
|
4600
|
+
description: "Review code for architectural compliance",
|
|
4601
|
+
generate: (context) => {
|
|
4602
|
+
return [
|
|
4603
|
+
"You are reviewing code for architectural compliance.",
|
|
4604
|
+
"",
|
|
4605
|
+
formatContextAsMarkdown(context),
|
|
4606
|
+
"",
|
|
4607
|
+
"Task:",
|
|
4608
|
+
"- Identify violations of the constraints above.",
|
|
4609
|
+
"- Suggest concrete changes to achieve compliance."
|
|
4610
|
+
].join("\n");
|
|
4611
|
+
}
|
|
4612
|
+
},
|
|
4613
|
+
refactoring: {
|
|
4614
|
+
name: "Refactoring Guidance",
|
|
4615
|
+
description: "Guide refactoring to meet constraints",
|
|
4616
|
+
generate: (context) => {
|
|
4617
|
+
return [
|
|
4618
|
+
"You are helping refactor code to meet architectural constraints.",
|
|
4619
|
+
"",
|
|
4620
|
+
formatContextAsMarkdown(context),
|
|
4621
|
+
"",
|
|
4622
|
+
"Task:",
|
|
4623
|
+
"- Propose a step-by-step refactoring plan to satisfy all invariants first, then conventions/guidelines.",
|
|
4624
|
+
"- Highlight risky changes and suggest safe incremental steps."
|
|
4625
|
+
].join("\n");
|
|
4626
|
+
}
|
|
4627
|
+
},
|
|
4628
|
+
migration: {
|
|
4629
|
+
name: "Migration Plan",
|
|
4630
|
+
description: "Generate a migration plan for a new/changed decision",
|
|
4631
|
+
generate: (context, options) => {
|
|
4632
|
+
const decisionId = String(options?.decisionId ?? "");
|
|
4633
|
+
return [
|
|
4634
|
+
`A new architectural decision has been introduced: ${decisionId || "<decision-id>"}`,
|
|
4635
|
+
"",
|
|
4636
|
+
formatContextAsMarkdown(context),
|
|
4637
|
+
"",
|
|
4638
|
+
"Task:",
|
|
4639
|
+
"- Provide an impact analysis.",
|
|
4640
|
+
"- Produce a step-by-step migration plan.",
|
|
4641
|
+
"- Include a checklist for completion."
|
|
4642
|
+
].join("\n");
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
};
|
|
4646
|
+
|
|
4647
|
+
// src/cli/commands/prompt.ts
|
|
4648
|
+
var promptCommand = new Command15("prompt").description("Generate AI agent prompt templates").argument("<template>", "Template name (code-review|refactoring|migration)").argument("<file>", "File path (used to select applicable decisions)").option("--decision <id>", "Decision id (required for migration)").action(async (templateName, file, options) => {
|
|
4649
|
+
const cwd = process.cwd();
|
|
4650
|
+
if (!await pathExists(getSpecBridgeDir(cwd))) {
|
|
4651
|
+
throw new NotInitializedError();
|
|
4652
|
+
}
|
|
4653
|
+
const tpl = templates[templateName];
|
|
4654
|
+
if (!tpl) {
|
|
4655
|
+
throw new Error(`Unknown template: ${templateName}`);
|
|
4656
|
+
}
|
|
4657
|
+
if (templateName === "migration" && !options.decision) {
|
|
4658
|
+
throw new Error("Missing --decision <id> for migration template");
|
|
4659
|
+
}
|
|
4660
|
+
const config = await loadConfig(cwd);
|
|
4661
|
+
const context = await generateContext(file, config, { cwd, includeRationale: true });
|
|
4662
|
+
const prompt = tpl.generate(context, { decisionId: options.decision });
|
|
4663
|
+
console.log(prompt);
|
|
4664
|
+
});
|
|
4665
|
+
|
|
3325
4666
|
// src/cli/index.ts
|
|
3326
|
-
var __dirname =
|
|
3327
|
-
var packageJsonPath =
|
|
3328
|
-
var packageJson = JSON.parse(
|
|
3329
|
-
var program = new
|
|
4667
|
+
var __dirname = dirname4(fileURLToPath3(import.meta.url));
|
|
4668
|
+
var packageJsonPath = join10(__dirname, "../package.json");
|
|
4669
|
+
var packageJson = JSON.parse(readFileSync2(packageJsonPath, "utf-8"));
|
|
4670
|
+
var program = new Command16();
|
|
3330
4671
|
program.name("specbridge").description("Architecture Decision Runtime - Transform architectural decisions into executable, verifiable constraints").version(packageJson.version);
|
|
3331
4672
|
program.addCommand(initCommand);
|
|
3332
4673
|
program.addCommand(inferCommand);
|
|
@@ -3335,6 +4676,10 @@ program.addCommand(decisionCommand);
|
|
|
3335
4676
|
program.addCommand(hookCommand);
|
|
3336
4677
|
program.addCommand(reportCommand);
|
|
3337
4678
|
program.addCommand(contextCommand);
|
|
4679
|
+
program.addCommand(lspCommand);
|
|
4680
|
+
program.addCommand(watchCommand);
|
|
4681
|
+
program.addCommand(mcpServerCommand);
|
|
4682
|
+
program.addCommand(promptCommand);
|
|
3338
4683
|
program.exitOverride((err) => {
|
|
3339
4684
|
if (err.code === "commander.help" || err.code === "commander.helpDisplayed") {
|
|
3340
4685
|
process.exit(0);
|
|
@@ -3342,11 +4687,11 @@ program.exitOverride((err) => {
|
|
|
3342
4687
|
if (err.code === "commander.version") {
|
|
3343
4688
|
process.exit(0);
|
|
3344
4689
|
}
|
|
3345
|
-
console.error(
|
|
4690
|
+
console.error(chalk14.red(formatError(err)));
|
|
3346
4691
|
process.exit(1);
|
|
3347
4692
|
});
|
|
3348
4693
|
program.parseAsync(process.argv).catch((error) => {
|
|
3349
|
-
console.error(
|
|
4694
|
+
console.error(chalk14.red(formatError(error)));
|
|
3350
4695
|
process.exit(1);
|
|
3351
4696
|
});
|
|
3352
4697
|
//# sourceMappingURL=cli.js.map
|