@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/index.js
CHANGED
|
@@ -66,8 +66,8 @@ function validateDecision(data) {
|
|
|
66
66
|
}
|
|
67
67
|
function formatValidationErrors(errors) {
|
|
68
68
|
return errors.errors.map((err) => {
|
|
69
|
-
const
|
|
70
|
-
return `${
|
|
69
|
+
const path3 = err.path.join(".");
|
|
70
|
+
return `${path3}: ${err.message}`;
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -147,10 +147,11 @@ var defaultConfig = {
|
|
|
147
147
|
|
|
148
148
|
// src/core/errors/index.ts
|
|
149
149
|
var SpecBridgeError = class extends Error {
|
|
150
|
-
constructor(message, code, details) {
|
|
150
|
+
constructor(message, code, details, suggestion) {
|
|
151
151
|
super(message);
|
|
152
152
|
this.code = code;
|
|
153
153
|
this.details = details;
|
|
154
|
+
this.suggestion = suggestion;
|
|
154
155
|
this.name = "SpecBridgeError";
|
|
155
156
|
Error.captureStackTrace(this, this.constructor);
|
|
156
157
|
}
|
|
@@ -194,15 +195,15 @@ var InferenceError = class extends SpecBridgeError {
|
|
|
194
195
|
}
|
|
195
196
|
};
|
|
196
197
|
var FileSystemError = class extends SpecBridgeError {
|
|
197
|
-
constructor(message,
|
|
198
|
-
super(message, "FILE_SYSTEM_ERROR", { path });
|
|
199
|
-
this.path =
|
|
198
|
+
constructor(message, path3) {
|
|
199
|
+
super(message, "FILE_SYSTEM_ERROR", { path: path3 });
|
|
200
|
+
this.path = path3;
|
|
200
201
|
this.name = "FileSystemError";
|
|
201
202
|
}
|
|
202
203
|
};
|
|
203
204
|
var AlreadyInitializedError = class extends SpecBridgeError {
|
|
204
|
-
constructor(
|
|
205
|
-
super(`SpecBridge is already initialized at ${
|
|
205
|
+
constructor(path3) {
|
|
206
|
+
super(`SpecBridge is already initialized at ${path3}`, "ALREADY_INITIALIZED", { path: path3 });
|
|
206
207
|
this.name = "AlreadyInitializedError";
|
|
207
208
|
}
|
|
208
209
|
};
|
|
@@ -210,7 +211,9 @@ var NotInitializedError = class extends SpecBridgeError {
|
|
|
210
211
|
constructor() {
|
|
211
212
|
super(
|
|
212
213
|
'SpecBridge is not initialized. Run "specbridge init" first.',
|
|
213
|
-
"NOT_INITIALIZED"
|
|
214
|
+
"NOT_INITIALIZED",
|
|
215
|
+
void 0,
|
|
216
|
+
"Run `specbridge init` in this directory to create .specbridge/"
|
|
214
217
|
);
|
|
215
218
|
this.name = "NotInitializedError";
|
|
216
219
|
}
|
|
@@ -246,6 +249,10 @@ ${detailsStr}`;
|
|
|
246
249
|
if (error instanceof DecisionValidationError && error.validationErrors.length > 0) {
|
|
247
250
|
message += "\nValidation errors:\n" + error.validationErrors.map((e) => ` - ${e}`).join("\n");
|
|
248
251
|
}
|
|
252
|
+
if (error.suggestion) {
|
|
253
|
+
message += `
|
|
254
|
+
Suggestion: ${error.suggestion}`;
|
|
255
|
+
}
|
|
249
256
|
return message;
|
|
250
257
|
}
|
|
251
258
|
return `Error: ${error.message}`;
|
|
@@ -255,31 +262,31 @@ ${detailsStr}`;
|
|
|
255
262
|
import { readFile, writeFile, mkdir, access, readdir, stat } from "fs/promises";
|
|
256
263
|
import { join, dirname } from "path";
|
|
257
264
|
import { constants } from "fs";
|
|
258
|
-
async function pathExists(
|
|
265
|
+
async function pathExists(path3) {
|
|
259
266
|
try {
|
|
260
|
-
await access(
|
|
267
|
+
await access(path3, constants.F_OK);
|
|
261
268
|
return true;
|
|
262
269
|
} catch {
|
|
263
270
|
return false;
|
|
264
271
|
}
|
|
265
272
|
}
|
|
266
|
-
async function isDirectory(
|
|
273
|
+
async function isDirectory(path3) {
|
|
267
274
|
try {
|
|
268
|
-
const stats = await stat(
|
|
275
|
+
const stats = await stat(path3);
|
|
269
276
|
return stats.isDirectory();
|
|
270
277
|
} catch {
|
|
271
278
|
return false;
|
|
272
279
|
}
|
|
273
280
|
}
|
|
274
|
-
async function ensureDir(
|
|
275
|
-
await mkdir(
|
|
281
|
+
async function ensureDir(path3) {
|
|
282
|
+
await mkdir(path3, { recursive: true });
|
|
276
283
|
}
|
|
277
|
-
async function readTextFile(
|
|
278
|
-
return readFile(
|
|
284
|
+
async function readTextFile(path3) {
|
|
285
|
+
return readFile(path3, "utf-8");
|
|
279
286
|
}
|
|
280
|
-
async function writeTextFile(
|
|
281
|
-
await ensureDir(dirname(
|
|
282
|
-
await writeFile(
|
|
287
|
+
async function writeTextFile(path3, content) {
|
|
288
|
+
await ensureDir(dirname(path3));
|
|
289
|
+
await writeFile(path3, content, "utf-8");
|
|
283
290
|
}
|
|
284
291
|
async function readFilesInDir(dirPath, filter) {
|
|
285
292
|
try {
|
|
@@ -327,15 +334,15 @@ function stringifyYaml(data, options) {
|
|
|
327
334
|
function parseYamlDocument(content) {
|
|
328
335
|
return parseDocument(content);
|
|
329
336
|
}
|
|
330
|
-
function updateYamlDocument(doc,
|
|
337
|
+
function updateYamlDocument(doc, path3, value) {
|
|
331
338
|
let current = doc.contents;
|
|
332
|
-
for (let i = 0; i <
|
|
333
|
-
const key =
|
|
339
|
+
for (let i = 0; i < path3.length - 1; i++) {
|
|
340
|
+
const key = path3[i];
|
|
334
341
|
if (key && current && typeof current === "object" && "get" in current) {
|
|
335
342
|
current = current.get(key);
|
|
336
343
|
}
|
|
337
344
|
}
|
|
338
|
-
const lastKey =
|
|
345
|
+
const lastKey = path3[path3.length - 1];
|
|
339
346
|
if (lastKey && current && typeof current === "object" && "set" in current) {
|
|
340
347
|
current.set(lastKey, value);
|
|
341
348
|
}
|
|
@@ -728,8 +735,8 @@ var CodeScanner = class {
|
|
|
728
735
|
/**
|
|
729
736
|
* Get a specific file
|
|
730
737
|
*/
|
|
731
|
-
getFile(
|
|
732
|
-
return this.scannedFiles.get(
|
|
738
|
+
getFile(path3) {
|
|
739
|
+
return this.scannedFiles.get(path3);
|
|
733
740
|
}
|
|
734
741
|
/**
|
|
735
742
|
* Get project instance for advanced analysis
|
|
@@ -742,12 +749,12 @@ var CodeScanner = class {
|
|
|
742
749
|
*/
|
|
743
750
|
findClasses() {
|
|
744
751
|
const classes = [];
|
|
745
|
-
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
752
|
+
for (const { path: path3, sourceFile } of this.scannedFiles.values()) {
|
|
746
753
|
for (const classDecl of sourceFile.getClasses()) {
|
|
747
754
|
const name = classDecl.getName();
|
|
748
755
|
if (name) {
|
|
749
756
|
classes.push({
|
|
750
|
-
file:
|
|
757
|
+
file: path3,
|
|
751
758
|
name,
|
|
752
759
|
line: classDecl.getStartLineNumber()
|
|
753
760
|
});
|
|
@@ -761,12 +768,12 @@ var CodeScanner = class {
|
|
|
761
768
|
*/
|
|
762
769
|
findFunctions() {
|
|
763
770
|
const functions = [];
|
|
764
|
-
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
771
|
+
for (const { path: path3, sourceFile } of this.scannedFiles.values()) {
|
|
765
772
|
for (const funcDecl of sourceFile.getFunctions()) {
|
|
766
773
|
const name = funcDecl.getName();
|
|
767
774
|
if (name) {
|
|
768
775
|
functions.push({
|
|
769
|
-
file:
|
|
776
|
+
file: path3,
|
|
770
777
|
name,
|
|
771
778
|
line: funcDecl.getStartLineNumber(),
|
|
772
779
|
isExported: funcDecl.isExported()
|
|
@@ -777,7 +784,7 @@ var CodeScanner = class {
|
|
|
777
784
|
const init = varDecl.getInitializer();
|
|
778
785
|
if (init && Node.isArrowFunction(init)) {
|
|
779
786
|
functions.push({
|
|
780
|
-
file:
|
|
787
|
+
file: path3,
|
|
781
788
|
name: varDecl.getName(),
|
|
782
789
|
line: varDecl.getStartLineNumber(),
|
|
783
790
|
isExported: varDecl.isExported()
|
|
@@ -792,12 +799,12 @@ var CodeScanner = class {
|
|
|
792
799
|
*/
|
|
793
800
|
findImports() {
|
|
794
801
|
const imports = [];
|
|
795
|
-
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
802
|
+
for (const { path: path3, sourceFile } of this.scannedFiles.values()) {
|
|
796
803
|
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
797
804
|
const module = importDecl.getModuleSpecifierValue();
|
|
798
805
|
const namedImports = importDecl.getNamedImports().map((n) => n.getName());
|
|
799
806
|
imports.push({
|
|
800
|
-
file:
|
|
807
|
+
file: path3,
|
|
801
808
|
module,
|
|
802
809
|
named: namedImports,
|
|
803
810
|
line: importDecl.getStartLineNumber()
|
|
@@ -811,10 +818,10 @@ var CodeScanner = class {
|
|
|
811
818
|
*/
|
|
812
819
|
findInterfaces() {
|
|
813
820
|
const interfaces = [];
|
|
814
|
-
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
821
|
+
for (const { path: path3, sourceFile } of this.scannedFiles.values()) {
|
|
815
822
|
for (const interfaceDecl of sourceFile.getInterfaces()) {
|
|
816
823
|
interfaces.push({
|
|
817
|
-
file:
|
|
824
|
+
file: path3,
|
|
818
825
|
name: interfaceDecl.getName(),
|
|
819
826
|
line: interfaceDecl.getStartLineNumber()
|
|
820
827
|
});
|
|
@@ -827,10 +834,10 @@ var CodeScanner = class {
|
|
|
827
834
|
*/
|
|
828
835
|
findTypeAliases() {
|
|
829
836
|
const types = [];
|
|
830
|
-
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
837
|
+
for (const { path: path3, sourceFile } of this.scannedFiles.values()) {
|
|
831
838
|
for (const typeAlias of sourceFile.getTypeAliases()) {
|
|
832
839
|
types.push({
|
|
833
|
-
file:
|
|
840
|
+
file: path3,
|
|
834
841
|
name: typeAlias.getName(),
|
|
835
842
|
line: typeAlias.getStartLineNumber()
|
|
836
843
|
});
|
|
@@ -843,13 +850,13 @@ var CodeScanner = class {
|
|
|
843
850
|
*/
|
|
844
851
|
findTryCatchBlocks() {
|
|
845
852
|
const blocks = [];
|
|
846
|
-
for (const { path, sourceFile } of this.scannedFiles.values()) {
|
|
853
|
+
for (const { path: path3, sourceFile } of this.scannedFiles.values()) {
|
|
847
854
|
sourceFile.forEachDescendant((node) => {
|
|
848
855
|
if (Node.isTryStatement(node)) {
|
|
849
856
|
const catchClause = node.getCatchClause();
|
|
850
857
|
const hasThrow = catchClause ? catchClause.getDescendantsOfKind(SyntaxKind.ThrowStatement).length > 0 : false;
|
|
851
858
|
blocks.push({
|
|
852
|
-
file:
|
|
859
|
+
file: path3,
|
|
853
860
|
line: node.getStartLineNumber(),
|
|
854
861
|
hasThrow
|
|
855
862
|
});
|
|
@@ -1423,7 +1430,7 @@ var ErrorsAnalyzer = class {
|
|
|
1423
1430
|
let throwNewError = 0;
|
|
1424
1431
|
let throwCustom = 0;
|
|
1425
1432
|
const examples = [];
|
|
1426
|
-
for (const { path, sourceFile } of files) {
|
|
1433
|
+
for (const { path: path3, sourceFile } of files) {
|
|
1427
1434
|
sourceFile.forEachDescendant((node) => {
|
|
1428
1435
|
if (Node2.isThrowStatement(node)) {
|
|
1429
1436
|
const expression = node.getExpression();
|
|
@@ -1436,7 +1443,7 @@ var ErrorsAnalyzer = class {
|
|
|
1436
1443
|
if (examples.length < 3) {
|
|
1437
1444
|
const snippet = text.length > 50 ? text.slice(0, 50) + "..." : text;
|
|
1438
1445
|
examples.push({
|
|
1439
|
-
file:
|
|
1446
|
+
file: path3,
|
|
1440
1447
|
line: node.getStartLineNumber(),
|
|
1441
1448
|
snippet: `throw ${snippet}`
|
|
1442
1449
|
});
|
|
@@ -1698,6 +1705,7 @@ var NamingVerifier = class {
|
|
|
1698
1705
|
};
|
|
1699
1706
|
|
|
1700
1707
|
// src/verification/verifiers/imports.ts
|
|
1708
|
+
import path from "path";
|
|
1701
1709
|
var ImportsVerifier = class {
|
|
1702
1710
|
id = "imports";
|
|
1703
1711
|
name = "Import Pattern Verifier";
|
|
@@ -1706,6 +1714,39 @@ var ImportsVerifier = class {
|
|
|
1706
1714
|
const violations = [];
|
|
1707
1715
|
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
1708
1716
|
const rule = constraint.rule.toLowerCase();
|
|
1717
|
+
if (rule.includes(".js") && rule.includes("extension") || rule.includes("esm") || rule.includes("add .js")) {
|
|
1718
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
1719
|
+
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
1720
|
+
if (!moduleSpec.startsWith(".")) continue;
|
|
1721
|
+
const ext = path.posix.extname(moduleSpec);
|
|
1722
|
+
let suggested = null;
|
|
1723
|
+
if (!ext) {
|
|
1724
|
+
suggested = `${moduleSpec}.js`;
|
|
1725
|
+
} else if (ext === ".ts" || ext === ".tsx" || ext === ".jsx") {
|
|
1726
|
+
suggested = moduleSpec.slice(0, -ext.length) + ".js";
|
|
1727
|
+
} else if (ext !== ".js") {
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
if (!suggested || suggested === moduleSpec) continue;
|
|
1731
|
+
const ms = importDecl.getModuleSpecifier();
|
|
1732
|
+
const start = ms.getStart() + 1;
|
|
1733
|
+
const end = ms.getEnd() - 1;
|
|
1734
|
+
violations.push(createViolation({
|
|
1735
|
+
decisionId,
|
|
1736
|
+
constraintId: constraint.id,
|
|
1737
|
+
type: constraint.type,
|
|
1738
|
+
severity: constraint.severity,
|
|
1739
|
+
message: `Relative import "${moduleSpec}" should include a .js extension`,
|
|
1740
|
+
file: filePath,
|
|
1741
|
+
line: importDecl.getStartLineNumber(),
|
|
1742
|
+
suggestion: `Update to "${suggested}"`,
|
|
1743
|
+
autofix: {
|
|
1744
|
+
description: "Add/normalize .js extension in import specifier",
|
|
1745
|
+
edits: [{ start, end, text: suggested }]
|
|
1746
|
+
}
|
|
1747
|
+
}));
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1709
1750
|
if (rule.includes("barrel") || rule.includes("index")) {
|
|
1710
1751
|
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
1711
1752
|
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
@@ -1944,12 +1985,590 @@ var RegexVerifier = class {
|
|
|
1944
1985
|
}
|
|
1945
1986
|
};
|
|
1946
1987
|
|
|
1988
|
+
// src/verification/verifiers/dependencies.ts
|
|
1989
|
+
import path2 from "path";
|
|
1990
|
+
var graphCache = /* @__PURE__ */ new WeakMap();
|
|
1991
|
+
function normalizeFsPath(p) {
|
|
1992
|
+
return p.replaceAll("\\", "/");
|
|
1993
|
+
}
|
|
1994
|
+
function joinLike(fromFilePath, relative2) {
|
|
1995
|
+
const fromNorm = normalizeFsPath(fromFilePath);
|
|
1996
|
+
const relNorm = normalizeFsPath(relative2);
|
|
1997
|
+
if (path2.isAbsolute(fromNorm)) {
|
|
1998
|
+
return normalizeFsPath(path2.resolve(path2.dirname(fromNorm), relNorm));
|
|
1999
|
+
}
|
|
2000
|
+
const dir = path2.posix.dirname(fromNorm);
|
|
2001
|
+
return path2.posix.normalize(path2.posix.join(dir, relNorm));
|
|
2002
|
+
}
|
|
2003
|
+
function resolveToSourceFilePath(project, fromFilePath, moduleSpec) {
|
|
2004
|
+
if (!moduleSpec.startsWith(".")) return null;
|
|
2005
|
+
const candidates = [];
|
|
2006
|
+
const raw = joinLike(fromFilePath, moduleSpec);
|
|
2007
|
+
const addCandidate = (p) => candidates.push(normalizeFsPath(p));
|
|
2008
|
+
const ext = path2.posix.extname(raw);
|
|
2009
|
+
if (ext) {
|
|
2010
|
+
addCandidate(raw);
|
|
2011
|
+
if (ext === ".js") addCandidate(raw.slice(0, -3) + ".ts");
|
|
2012
|
+
if (ext === ".jsx") addCandidate(raw.slice(0, -4) + ".tsx");
|
|
2013
|
+
if (ext === ".ts") addCandidate(raw.slice(0, -3) + ".js");
|
|
2014
|
+
if (ext === ".tsx") addCandidate(raw.slice(0, -4) + ".jsx");
|
|
2015
|
+
addCandidate(path2.posix.join(raw.replace(/\/$/, ""), "index.ts"));
|
|
2016
|
+
addCandidate(path2.posix.join(raw.replace(/\/$/, ""), "index.tsx"));
|
|
2017
|
+
addCandidate(path2.posix.join(raw.replace(/\/$/, ""), "index.js"));
|
|
2018
|
+
addCandidate(path2.posix.join(raw.replace(/\/$/, ""), "index.jsx"));
|
|
2019
|
+
} else {
|
|
2020
|
+
addCandidate(raw + ".ts");
|
|
2021
|
+
addCandidate(raw + ".tsx");
|
|
2022
|
+
addCandidate(raw + ".js");
|
|
2023
|
+
addCandidate(raw + ".jsx");
|
|
2024
|
+
addCandidate(path2.posix.join(raw, "index.ts"));
|
|
2025
|
+
addCandidate(path2.posix.join(raw, "index.tsx"));
|
|
2026
|
+
addCandidate(path2.posix.join(raw, "index.js"));
|
|
2027
|
+
addCandidate(path2.posix.join(raw, "index.jsx"));
|
|
2028
|
+
}
|
|
2029
|
+
for (const candidate of candidates) {
|
|
2030
|
+
const sf = project.getSourceFile(candidate);
|
|
2031
|
+
if (sf) return sf.getFilePath();
|
|
2032
|
+
}
|
|
2033
|
+
return null;
|
|
2034
|
+
}
|
|
2035
|
+
function buildDependencyGraph(project) {
|
|
2036
|
+
const cached = graphCache.get(project);
|
|
2037
|
+
const sourceFiles = project.getSourceFiles();
|
|
2038
|
+
if (cached && cached.fileCount === sourceFiles.length) {
|
|
2039
|
+
return cached.graph;
|
|
2040
|
+
}
|
|
2041
|
+
const graph = /* @__PURE__ */ new Map();
|
|
2042
|
+
for (const sf of sourceFiles) {
|
|
2043
|
+
const from = normalizeFsPath(sf.getFilePath());
|
|
2044
|
+
if (!graph.has(from)) graph.set(from, /* @__PURE__ */ new Set());
|
|
2045
|
+
for (const importDecl of sf.getImportDeclarations()) {
|
|
2046
|
+
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
2047
|
+
const resolved = resolveToSourceFilePath(project, from, moduleSpec);
|
|
2048
|
+
if (resolved) {
|
|
2049
|
+
graph.get(from).add(normalizeFsPath(resolved));
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
graphCache.set(project, { fileCount: sourceFiles.length, graph });
|
|
2054
|
+
return graph;
|
|
2055
|
+
}
|
|
2056
|
+
function tarjanScc(graph) {
|
|
2057
|
+
let index = 0;
|
|
2058
|
+
const stack = [];
|
|
2059
|
+
const onStack = /* @__PURE__ */ new Set();
|
|
2060
|
+
const indices = /* @__PURE__ */ new Map();
|
|
2061
|
+
const lowlink = /* @__PURE__ */ new Map();
|
|
2062
|
+
const result = [];
|
|
2063
|
+
const strongConnect = (v) => {
|
|
2064
|
+
indices.set(v, index);
|
|
2065
|
+
lowlink.set(v, index);
|
|
2066
|
+
index++;
|
|
2067
|
+
stack.push(v);
|
|
2068
|
+
onStack.add(v);
|
|
2069
|
+
const edges = graph.get(v) || /* @__PURE__ */ new Set();
|
|
2070
|
+
for (const w of edges) {
|
|
2071
|
+
if (!indices.has(w)) {
|
|
2072
|
+
strongConnect(w);
|
|
2073
|
+
lowlink.set(v, Math.min(lowlink.get(v), lowlink.get(w)));
|
|
2074
|
+
} else if (onStack.has(w)) {
|
|
2075
|
+
lowlink.set(v, Math.min(lowlink.get(v), indices.get(w)));
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
if (lowlink.get(v) === indices.get(v)) {
|
|
2079
|
+
const scc = [];
|
|
2080
|
+
while (stack.length > 0) {
|
|
2081
|
+
const w = stack.pop();
|
|
2082
|
+
if (!w) break;
|
|
2083
|
+
onStack.delete(w);
|
|
2084
|
+
scc.push(w);
|
|
2085
|
+
if (w === v) break;
|
|
2086
|
+
}
|
|
2087
|
+
result.push(scc);
|
|
2088
|
+
}
|
|
2089
|
+
};
|
|
2090
|
+
for (const v of graph.keys()) {
|
|
2091
|
+
if (!indices.has(v)) strongConnect(v);
|
|
2092
|
+
}
|
|
2093
|
+
return result;
|
|
2094
|
+
}
|
|
2095
|
+
function parseMaxImportDepth(rule) {
|
|
2096
|
+
const m = rule.match(/maximum\s+import\s+depth\s*[:=]?\s*(\d+)/i);
|
|
2097
|
+
return m ? Number.parseInt(m[1], 10) : null;
|
|
2098
|
+
}
|
|
2099
|
+
function parseBannedDependency(rule) {
|
|
2100
|
+
const m = rule.match(/no\s+dependencies?\s+on\s+(?:package\s+)?(.+?)(?:\.|$)/i);
|
|
2101
|
+
if (!m) return null;
|
|
2102
|
+
const value = m[1].trim();
|
|
2103
|
+
return value.length > 0 ? value : null;
|
|
2104
|
+
}
|
|
2105
|
+
function parseLayerRule(rule) {
|
|
2106
|
+
const m = rule.match(/(\w+)\s+layer\s+cannot\s+depend\s+on\s+(\w+)\s+layer/i);
|
|
2107
|
+
if (!m) return null;
|
|
2108
|
+
return { fromLayer: m[1].toLowerCase(), toLayer: m[2].toLowerCase() };
|
|
2109
|
+
}
|
|
2110
|
+
function fileInLayer(filePath, layer) {
|
|
2111
|
+
const fp = normalizeFsPath(filePath).toLowerCase();
|
|
2112
|
+
return fp.includes(`/${layer}/`) || fp.endsWith(`/${layer}.ts`) || fp.endsWith(`/${layer}.tsx`);
|
|
2113
|
+
}
|
|
2114
|
+
var DependencyVerifier = class {
|
|
2115
|
+
id = "dependencies";
|
|
2116
|
+
name = "Dependency Verifier";
|
|
2117
|
+
description = "Checks dependency constraints, import depth, and circular dependencies";
|
|
2118
|
+
async verify(ctx) {
|
|
2119
|
+
const violations = [];
|
|
2120
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
2121
|
+
const rule = constraint.rule;
|
|
2122
|
+
const lowerRule = rule.toLowerCase();
|
|
2123
|
+
const project = sourceFile.getProject();
|
|
2124
|
+
const projectFilePath = normalizeFsPath(sourceFile.getFilePath());
|
|
2125
|
+
if (lowerRule.includes("circular") || lowerRule.includes("cycle")) {
|
|
2126
|
+
const graph = buildDependencyGraph(project);
|
|
2127
|
+
const sccs = tarjanScc(graph);
|
|
2128
|
+
const current = projectFilePath;
|
|
2129
|
+
for (const scc of sccs) {
|
|
2130
|
+
const hasSelfLoop = scc.length === 1 && (graph.get(scc[0])?.has(scc[0]) ?? false);
|
|
2131
|
+
const isCycle = scc.length > 1 || hasSelfLoop;
|
|
2132
|
+
if (!isCycle) continue;
|
|
2133
|
+
if (!scc.includes(current)) continue;
|
|
2134
|
+
const sorted = [...scc].sort();
|
|
2135
|
+
if (sorted[0] !== current) continue;
|
|
2136
|
+
violations.push(createViolation({
|
|
2137
|
+
decisionId,
|
|
2138
|
+
constraintId: constraint.id,
|
|
2139
|
+
type: constraint.type,
|
|
2140
|
+
severity: constraint.severity,
|
|
2141
|
+
message: `Circular dependency detected across: ${sorted.join(" -> ")}`,
|
|
2142
|
+
file: filePath,
|
|
2143
|
+
line: 1,
|
|
2144
|
+
suggestion: "Break the cycle by extracting shared abstractions or reversing the dependency"
|
|
2145
|
+
}));
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
const layerRule = parseLayerRule(rule);
|
|
2149
|
+
if (layerRule && fileInLayer(projectFilePath, layerRule.fromLayer)) {
|
|
2150
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
2151
|
+
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
2152
|
+
const resolved = resolveToSourceFilePath(project, projectFilePath, moduleSpec);
|
|
2153
|
+
if (!resolved) continue;
|
|
2154
|
+
if (fileInLayer(resolved, layerRule.toLayer)) {
|
|
2155
|
+
violations.push(createViolation({
|
|
2156
|
+
decisionId,
|
|
2157
|
+
constraintId: constraint.id,
|
|
2158
|
+
type: constraint.type,
|
|
2159
|
+
severity: constraint.severity,
|
|
2160
|
+
message: `Layer violation: ${layerRule.fromLayer} depends on ${layerRule.toLayer} via import "${moduleSpec}"`,
|
|
2161
|
+
file: filePath,
|
|
2162
|
+
line: importDecl.getStartLineNumber(),
|
|
2163
|
+
suggestion: `Refactor to remove dependency from ${layerRule.fromLayer} to ${layerRule.toLayer}`
|
|
2164
|
+
}));
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
const banned = parseBannedDependency(rule);
|
|
2169
|
+
if (banned) {
|
|
2170
|
+
const bannedLower = banned.toLowerCase();
|
|
2171
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
2172
|
+
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
2173
|
+
if (moduleSpec.toLowerCase().includes(bannedLower)) {
|
|
2174
|
+
violations.push(createViolation({
|
|
2175
|
+
decisionId,
|
|
2176
|
+
constraintId: constraint.id,
|
|
2177
|
+
type: constraint.type,
|
|
2178
|
+
severity: constraint.severity,
|
|
2179
|
+
message: `Banned dependency import detected: "${moduleSpec}"`,
|
|
2180
|
+
file: filePath,
|
|
2181
|
+
line: importDecl.getStartLineNumber(),
|
|
2182
|
+
suggestion: `Remove or replace dependency "${banned}"`
|
|
2183
|
+
}));
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
const maxDepth = parseMaxImportDepth(rule);
|
|
2188
|
+
if (maxDepth !== null) {
|
|
2189
|
+
for (const importDecl of sourceFile.getImportDeclarations()) {
|
|
2190
|
+
const moduleSpec = importDecl.getModuleSpecifierValue();
|
|
2191
|
+
if (!moduleSpec.startsWith(".")) continue;
|
|
2192
|
+
const depth = (moduleSpec.match(/\.\.\//g) || []).length;
|
|
2193
|
+
if (depth > maxDepth) {
|
|
2194
|
+
violations.push(createViolation({
|
|
2195
|
+
decisionId,
|
|
2196
|
+
constraintId: constraint.id,
|
|
2197
|
+
type: constraint.type,
|
|
2198
|
+
severity: constraint.severity,
|
|
2199
|
+
message: `Import depth ${depth} exceeds maximum ${maxDepth}: "${moduleSpec}"`,
|
|
2200
|
+
file: filePath,
|
|
2201
|
+
line: importDecl.getStartLineNumber(),
|
|
2202
|
+
suggestion: "Use a shallower module boundary (or introduce a public entrypoint for this dependency)"
|
|
2203
|
+
}));
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
return violations;
|
|
2208
|
+
}
|
|
2209
|
+
};
|
|
2210
|
+
|
|
2211
|
+
// src/verification/verifiers/complexity.ts
|
|
2212
|
+
import { SyntaxKind as SyntaxKind2 } from "ts-morph";
|
|
2213
|
+
function parseLimit(rule, pattern) {
|
|
2214
|
+
const m = rule.match(pattern);
|
|
2215
|
+
return m ? Number.parseInt(m[1], 10) : null;
|
|
2216
|
+
}
|
|
2217
|
+
function getFileLineCount(text) {
|
|
2218
|
+
if (text.length === 0) return 0;
|
|
2219
|
+
return text.split("\n").length;
|
|
2220
|
+
}
|
|
2221
|
+
function getDecisionPoints(fn) {
|
|
2222
|
+
let points = 0;
|
|
2223
|
+
for (const d of fn.getDescendants()) {
|
|
2224
|
+
switch (d.getKind()) {
|
|
2225
|
+
case SyntaxKind2.IfStatement:
|
|
2226
|
+
case SyntaxKind2.ForStatement:
|
|
2227
|
+
case SyntaxKind2.ForInStatement:
|
|
2228
|
+
case SyntaxKind2.ForOfStatement:
|
|
2229
|
+
case SyntaxKind2.WhileStatement:
|
|
2230
|
+
case SyntaxKind2.DoStatement:
|
|
2231
|
+
case SyntaxKind2.CatchClause:
|
|
2232
|
+
case SyntaxKind2.ConditionalExpression:
|
|
2233
|
+
case SyntaxKind2.CaseClause:
|
|
2234
|
+
points++;
|
|
2235
|
+
break;
|
|
2236
|
+
case SyntaxKind2.BinaryExpression: {
|
|
2237
|
+
const text = d.getText();
|
|
2238
|
+
if (text.includes("&&") || text.includes("||")) points++;
|
|
2239
|
+
break;
|
|
2240
|
+
}
|
|
2241
|
+
default:
|
|
2242
|
+
break;
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
return points;
|
|
2246
|
+
}
|
|
2247
|
+
function calculateCyclomaticComplexity(fn) {
|
|
2248
|
+
return 1 + getDecisionPoints(fn);
|
|
2249
|
+
}
|
|
2250
|
+
function getFunctionDisplayName(fn) {
|
|
2251
|
+
if ("getName" in fn && typeof fn.getName === "function") {
|
|
2252
|
+
const name = fn.getName();
|
|
2253
|
+
if (typeof name === "string" && name.length > 0) return name;
|
|
2254
|
+
}
|
|
2255
|
+
const parent = fn.getParent();
|
|
2256
|
+
if (parent?.getKind() === SyntaxKind2.VariableDeclaration) {
|
|
2257
|
+
const vd = parent;
|
|
2258
|
+
if (typeof vd.getName === "function") return vd.getName();
|
|
2259
|
+
}
|
|
2260
|
+
return "<anonymous>";
|
|
2261
|
+
}
|
|
2262
|
+
function maxNestingDepth(node) {
|
|
2263
|
+
let maxDepth = 0;
|
|
2264
|
+
const walk = (n, depth) => {
|
|
2265
|
+
maxDepth = Math.max(maxDepth, depth);
|
|
2266
|
+
const kind = n.getKind();
|
|
2267
|
+
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;
|
|
2268
|
+
for (const child of n.getChildren()) {
|
|
2269
|
+
if (child.getKind() === SyntaxKind2.FunctionDeclaration || child.getKind() === SyntaxKind2.FunctionExpression || child.getKind() === SyntaxKind2.ArrowFunction || child.getKind() === SyntaxKind2.MethodDeclaration) {
|
|
2270
|
+
continue;
|
|
2271
|
+
}
|
|
2272
|
+
walk(child, isNestingNode ? depth + 1 : depth);
|
|
2273
|
+
}
|
|
2274
|
+
};
|
|
2275
|
+
walk(node, 0);
|
|
2276
|
+
return maxDepth;
|
|
2277
|
+
}
|
|
2278
|
+
var ComplexityVerifier = class {
|
|
2279
|
+
id = "complexity";
|
|
2280
|
+
name = "Complexity Verifier";
|
|
2281
|
+
description = "Checks cyclomatic complexity, file size, parameters, and nesting depth";
|
|
2282
|
+
async verify(ctx) {
|
|
2283
|
+
const violations = [];
|
|
2284
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
2285
|
+
const rule = constraint.rule;
|
|
2286
|
+
const maxComplexity = parseLimit(rule, /complexity\s+(?:must\s+)?not\s+exceed\s+(\d+)/i);
|
|
2287
|
+
const maxLines = parseLimit(rule, /file\s+size\s+(?:must\s+)?not\s+exceed\s+(\d+)\s+lines?/i);
|
|
2288
|
+
const maxParams = parseLimit(rule, /at\s+most\s+(\d+)\s+parameters?/i) ?? parseLimit(rule, /parameters?\s+(?:must\s+)?not\s+exceed\s+(\d+)/i);
|
|
2289
|
+
const maxNesting = parseLimit(rule, /nesting\s+depth\s+(?:must\s+)?not\s+exceed\s+(\d+)/i);
|
|
2290
|
+
if (maxLines !== null) {
|
|
2291
|
+
const lineCount = getFileLineCount(sourceFile.getFullText());
|
|
2292
|
+
if (lineCount > maxLines) {
|
|
2293
|
+
violations.push(createViolation({
|
|
2294
|
+
decisionId,
|
|
2295
|
+
constraintId: constraint.id,
|
|
2296
|
+
type: constraint.type,
|
|
2297
|
+
severity: constraint.severity,
|
|
2298
|
+
message: `File has ${lineCount} lines which exceeds maximum ${maxLines}`,
|
|
2299
|
+
file: filePath,
|
|
2300
|
+
line: 1,
|
|
2301
|
+
suggestion: "Split the file into smaller modules"
|
|
2302
|
+
}));
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
const functionLikes = [
|
|
2306
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind2.FunctionDeclaration),
|
|
2307
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind2.FunctionExpression),
|
|
2308
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind2.ArrowFunction),
|
|
2309
|
+
...sourceFile.getDescendantsOfKind(SyntaxKind2.MethodDeclaration)
|
|
2310
|
+
];
|
|
2311
|
+
for (const fn of functionLikes) {
|
|
2312
|
+
const fnName = getFunctionDisplayName(fn);
|
|
2313
|
+
if (maxComplexity !== null) {
|
|
2314
|
+
const complexity = calculateCyclomaticComplexity(fn);
|
|
2315
|
+
if (complexity > maxComplexity) {
|
|
2316
|
+
violations.push(createViolation({
|
|
2317
|
+
decisionId,
|
|
2318
|
+
constraintId: constraint.id,
|
|
2319
|
+
type: constraint.type,
|
|
2320
|
+
severity: constraint.severity,
|
|
2321
|
+
message: `Function ${fnName} has cyclomatic complexity ${complexity} which exceeds maximum ${maxComplexity}`,
|
|
2322
|
+
file: filePath,
|
|
2323
|
+
line: fn.getStartLineNumber(),
|
|
2324
|
+
suggestion: "Refactor to reduce branching or extract smaller functions"
|
|
2325
|
+
}));
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
if (maxParams !== null && "getParameters" in fn) {
|
|
2329
|
+
const params = fn.getParameters();
|
|
2330
|
+
const paramCount = Array.isArray(params) ? params.length : 0;
|
|
2331
|
+
if (paramCount > maxParams) {
|
|
2332
|
+
violations.push(createViolation({
|
|
2333
|
+
decisionId,
|
|
2334
|
+
constraintId: constraint.id,
|
|
2335
|
+
type: constraint.type,
|
|
2336
|
+
severity: constraint.severity,
|
|
2337
|
+
message: `Function ${fnName} has ${paramCount} parameters which exceeds maximum ${maxParams}`,
|
|
2338
|
+
file: filePath,
|
|
2339
|
+
line: fn.getStartLineNumber(),
|
|
2340
|
+
suggestion: "Consider grouping parameters into an options object"
|
|
2341
|
+
}));
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
if (maxNesting !== null) {
|
|
2345
|
+
const depth = maxNestingDepth(fn);
|
|
2346
|
+
if (depth > maxNesting) {
|
|
2347
|
+
violations.push(createViolation({
|
|
2348
|
+
decisionId,
|
|
2349
|
+
constraintId: constraint.id,
|
|
2350
|
+
type: constraint.type,
|
|
2351
|
+
severity: constraint.severity,
|
|
2352
|
+
message: `Function ${fnName} has nesting depth ${depth} which exceeds maximum ${maxNesting}`,
|
|
2353
|
+
file: filePath,
|
|
2354
|
+
line: fn.getStartLineNumber(),
|
|
2355
|
+
suggestion: "Reduce nesting by using early returns or extracting functions"
|
|
2356
|
+
}));
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
return violations;
|
|
2361
|
+
}
|
|
2362
|
+
};
|
|
2363
|
+
|
|
2364
|
+
// src/verification/verifiers/security.ts
|
|
2365
|
+
import { SyntaxKind as SyntaxKind3 } from "ts-morph";
|
|
2366
|
+
var SECRET_NAME_RE = /(api[_-]?key|password|secret|token)/i;
|
|
2367
|
+
function isStringLiteralLike(node) {
|
|
2368
|
+
const k = node.getKind();
|
|
2369
|
+
return k === SyntaxKind3.StringLiteral || k === SyntaxKind3.NoSubstitutionTemplateLiteral;
|
|
2370
|
+
}
|
|
2371
|
+
var SecurityVerifier = class {
|
|
2372
|
+
id = "security";
|
|
2373
|
+
name = "Security Verifier";
|
|
2374
|
+
description = "Detects common security footguns (secrets, eval, XSS/SQL injection heuristics)";
|
|
2375
|
+
async verify(ctx) {
|
|
2376
|
+
const violations = [];
|
|
2377
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
2378
|
+
const rule = constraint.rule.toLowerCase();
|
|
2379
|
+
const checkSecrets = rule.includes("secret") || rule.includes("password") || rule.includes("token") || rule.includes("api key") || rule.includes("hardcoded");
|
|
2380
|
+
const checkEval = rule.includes("eval") || rule.includes("function constructor");
|
|
2381
|
+
const checkXss = rule.includes("xss") || rule.includes("innerhtml") || rule.includes("dangerouslysetinnerhtml");
|
|
2382
|
+
const checkSql = rule.includes("sql") || rule.includes("injection");
|
|
2383
|
+
const checkProto = rule.includes("prototype pollution") || rule.includes("__proto__");
|
|
2384
|
+
if (checkSecrets) {
|
|
2385
|
+
for (const vd of sourceFile.getVariableDeclarations()) {
|
|
2386
|
+
const name = vd.getName();
|
|
2387
|
+
if (!SECRET_NAME_RE.test(name)) continue;
|
|
2388
|
+
const init = vd.getInitializer();
|
|
2389
|
+
if (!init || !isStringLiteralLike(init)) continue;
|
|
2390
|
+
const value = init.getText().slice(1, -1);
|
|
2391
|
+
if (value.length === 0) continue;
|
|
2392
|
+
violations.push(createViolation({
|
|
2393
|
+
decisionId,
|
|
2394
|
+
constraintId: constraint.id,
|
|
2395
|
+
type: constraint.type,
|
|
2396
|
+
severity: constraint.severity,
|
|
2397
|
+
message: `Possible hardcoded secret in variable "${name}"`,
|
|
2398
|
+
file: filePath,
|
|
2399
|
+
line: vd.getStartLineNumber(),
|
|
2400
|
+
suggestion: "Move secrets to environment variables or a secret manager"
|
|
2401
|
+
}));
|
|
2402
|
+
}
|
|
2403
|
+
for (const pa of sourceFile.getDescendantsOfKind(SyntaxKind3.PropertyAssignment)) {
|
|
2404
|
+
const nameNode = pa.getNameNode?.();
|
|
2405
|
+
const propName = nameNode?.getText?.() ?? "";
|
|
2406
|
+
if (!SECRET_NAME_RE.test(propName)) continue;
|
|
2407
|
+
const init = pa.getInitializer();
|
|
2408
|
+
if (!init || !isStringLiteralLike(init)) continue;
|
|
2409
|
+
violations.push(createViolation({
|
|
2410
|
+
decisionId,
|
|
2411
|
+
constraintId: constraint.id,
|
|
2412
|
+
type: constraint.type,
|
|
2413
|
+
severity: constraint.severity,
|
|
2414
|
+
message: `Possible hardcoded secret in object property ${propName}`,
|
|
2415
|
+
file: filePath,
|
|
2416
|
+
line: pa.getStartLineNumber(),
|
|
2417
|
+
suggestion: "Move secrets to environment variables or a secret manager"
|
|
2418
|
+
}));
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
if (checkEval) {
|
|
2422
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind3.CallExpression)) {
|
|
2423
|
+
const exprText = call.getExpression().getText();
|
|
2424
|
+
if (exprText === "eval" || exprText === "Function") {
|
|
2425
|
+
violations.push(createViolation({
|
|
2426
|
+
decisionId,
|
|
2427
|
+
constraintId: constraint.id,
|
|
2428
|
+
type: constraint.type,
|
|
2429
|
+
severity: constraint.severity,
|
|
2430
|
+
message: `Unsafe dynamic code execution via ${exprText}()`,
|
|
2431
|
+
file: filePath,
|
|
2432
|
+
line: call.getStartLineNumber(),
|
|
2433
|
+
suggestion: "Avoid eval/Function; use safer alternatives"
|
|
2434
|
+
}));
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
if (checkXss) {
|
|
2439
|
+
for (const bin of sourceFile.getDescendantsOfKind(SyntaxKind3.BinaryExpression)) {
|
|
2440
|
+
const left = bin.getLeft();
|
|
2441
|
+
if (left.getKind() !== SyntaxKind3.PropertyAccessExpression) continue;
|
|
2442
|
+
const pa = left;
|
|
2443
|
+
if (pa.getName?.() === "innerHTML") {
|
|
2444
|
+
violations.push(createViolation({
|
|
2445
|
+
decisionId,
|
|
2446
|
+
constraintId: constraint.id,
|
|
2447
|
+
type: constraint.type,
|
|
2448
|
+
severity: constraint.severity,
|
|
2449
|
+
message: "Potential XSS: assignment to innerHTML",
|
|
2450
|
+
file: filePath,
|
|
2451
|
+
line: bin.getStartLineNumber(),
|
|
2452
|
+
suggestion: "Prefer textContent or a safe templating/escaping strategy"
|
|
2453
|
+
}));
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
if (sourceFile.getFullText().includes("dangerouslySetInnerHTML")) {
|
|
2457
|
+
violations.push(createViolation({
|
|
2458
|
+
decisionId,
|
|
2459
|
+
constraintId: constraint.id,
|
|
2460
|
+
type: constraint.type,
|
|
2461
|
+
severity: constraint.severity,
|
|
2462
|
+
message: "Potential XSS: usage of dangerouslySetInnerHTML",
|
|
2463
|
+
file: filePath,
|
|
2464
|
+
line: 1,
|
|
2465
|
+
suggestion: "Avoid dangerouslySetInnerHTML or ensure content is sanitized"
|
|
2466
|
+
}));
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
if (checkSql) {
|
|
2470
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind3.CallExpression)) {
|
|
2471
|
+
const expr = call.getExpression();
|
|
2472
|
+
if (expr.getKind() !== SyntaxKind3.PropertyAccessExpression) continue;
|
|
2473
|
+
const name = expr.getName?.();
|
|
2474
|
+
if (name !== "query" && name !== "execute") continue;
|
|
2475
|
+
const arg = call.getArguments()[0];
|
|
2476
|
+
if (!arg) continue;
|
|
2477
|
+
const isTemplate = arg.getKind() === SyntaxKind3.TemplateExpression;
|
|
2478
|
+
const isConcat = arg.getKind() === SyntaxKind3.BinaryExpression && arg.getText().includes("+");
|
|
2479
|
+
if (!isTemplate && !isConcat) continue;
|
|
2480
|
+
const text = arg.getText().toLowerCase();
|
|
2481
|
+
if (!text.includes("select") && !text.includes("insert") && !text.includes("update") && !text.includes("delete")) {
|
|
2482
|
+
continue;
|
|
2483
|
+
}
|
|
2484
|
+
violations.push(createViolation({
|
|
2485
|
+
decisionId,
|
|
2486
|
+
constraintId: constraint.id,
|
|
2487
|
+
type: constraint.type,
|
|
2488
|
+
severity: constraint.severity,
|
|
2489
|
+
message: "Potential SQL injection: dynamically constructed SQL query",
|
|
2490
|
+
file: filePath,
|
|
2491
|
+
line: call.getStartLineNumber(),
|
|
2492
|
+
suggestion: "Use parameterized queries / prepared statements"
|
|
2493
|
+
}));
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
if (checkProto) {
|
|
2497
|
+
const text = sourceFile.getFullText();
|
|
2498
|
+
if (text.includes("__proto__") || text.includes("constructor.prototype")) {
|
|
2499
|
+
violations.push(createViolation({
|
|
2500
|
+
decisionId,
|
|
2501
|
+
constraintId: constraint.id,
|
|
2502
|
+
type: constraint.type,
|
|
2503
|
+
severity: constraint.severity,
|
|
2504
|
+
message: "Potential prototype pollution pattern detected",
|
|
2505
|
+
file: filePath,
|
|
2506
|
+
line: 1,
|
|
2507
|
+
suggestion: "Avoid writing to __proto__/prototype; validate object keys"
|
|
2508
|
+
}));
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
return violations;
|
|
2512
|
+
}
|
|
2513
|
+
};
|
|
2514
|
+
|
|
2515
|
+
// src/verification/verifiers/api.ts
|
|
2516
|
+
import { SyntaxKind as SyntaxKind4 } from "ts-morph";
|
|
2517
|
+
var HTTP_METHODS = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "options", "head", "all"]);
|
|
2518
|
+
function isKebabPath(pathValue) {
|
|
2519
|
+
const parts = pathValue.split("/").filter(Boolean);
|
|
2520
|
+
for (const part of parts) {
|
|
2521
|
+
if (part.startsWith(":")) continue;
|
|
2522
|
+
if (!/^[a-z0-9-]+$/.test(part)) return false;
|
|
2523
|
+
}
|
|
2524
|
+
return true;
|
|
2525
|
+
}
|
|
2526
|
+
var ApiVerifier = class {
|
|
2527
|
+
id = "api";
|
|
2528
|
+
name = "API Consistency Verifier";
|
|
2529
|
+
description = "Checks basic REST endpoint naming conventions in common frameworks";
|
|
2530
|
+
async verify(ctx) {
|
|
2531
|
+
const violations = [];
|
|
2532
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
2533
|
+
const rule = constraint.rule.toLowerCase();
|
|
2534
|
+
const enforceKebab = rule.includes("kebab") || rule.includes("kebab-case");
|
|
2535
|
+
if (!enforceKebab) return violations;
|
|
2536
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind4.CallExpression)) {
|
|
2537
|
+
const expr = call.getExpression();
|
|
2538
|
+
if (expr.getKind() !== SyntaxKind4.PropertyAccessExpression) continue;
|
|
2539
|
+
const method = expr.getName?.();
|
|
2540
|
+
if (!method || !HTTP_METHODS.has(String(method))) continue;
|
|
2541
|
+
const firstArg = call.getArguments()[0];
|
|
2542
|
+
if (!firstArg || firstArg.getKind() !== SyntaxKind4.StringLiteral) continue;
|
|
2543
|
+
const pathValue = firstArg.getLiteralValue?.() ?? firstArg.getText().slice(1, -1);
|
|
2544
|
+
if (typeof pathValue !== "string") continue;
|
|
2545
|
+
if (!isKebabPath(pathValue)) {
|
|
2546
|
+
violations.push(createViolation({
|
|
2547
|
+
decisionId,
|
|
2548
|
+
constraintId: constraint.id,
|
|
2549
|
+
type: constraint.type,
|
|
2550
|
+
severity: constraint.severity,
|
|
2551
|
+
message: `Endpoint path "${pathValue}" is not kebab-case`,
|
|
2552
|
+
file: filePath,
|
|
2553
|
+
line: call.getStartLineNumber(),
|
|
2554
|
+
suggestion: "Use lowercase and hyphens in static path segments (e.g., /user-settings)"
|
|
2555
|
+
}));
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
return violations;
|
|
2559
|
+
}
|
|
2560
|
+
};
|
|
2561
|
+
|
|
1947
2562
|
// src/verification/verifiers/index.ts
|
|
1948
2563
|
var builtinVerifiers = {
|
|
1949
2564
|
naming: () => new NamingVerifier(),
|
|
1950
2565
|
imports: () => new ImportsVerifier(),
|
|
1951
2566
|
errors: () => new ErrorsVerifier(),
|
|
1952
|
-
regex: () => new RegexVerifier()
|
|
2567
|
+
regex: () => new RegexVerifier(),
|
|
2568
|
+
dependencies: () => new DependencyVerifier(),
|
|
2569
|
+
complexity: () => new ComplexityVerifier(),
|
|
2570
|
+
security: () => new SecurityVerifier(),
|
|
2571
|
+
api: () => new ApiVerifier()
|
|
1953
2572
|
};
|
|
1954
2573
|
function getVerifier(id) {
|
|
1955
2574
|
const factory = builtinVerifiers[id];
|
|
@@ -1963,6 +2582,18 @@ function selectVerifierForConstraint(rule, specifiedVerifier) {
|
|
|
1963
2582
|
return getVerifier(specifiedVerifier);
|
|
1964
2583
|
}
|
|
1965
2584
|
const lowerRule = rule.toLowerCase();
|
|
2585
|
+
if (lowerRule.includes("dependency") || lowerRule.includes("circular dependenc") || lowerRule.includes("import depth") || lowerRule.includes("layer") && lowerRule.includes("depend on")) {
|
|
2586
|
+
return getVerifier("dependencies");
|
|
2587
|
+
}
|
|
2588
|
+
if (lowerRule.includes("cyclomatic") || lowerRule.includes("complexity") || lowerRule.includes("nesting") || lowerRule.includes("parameters") || lowerRule.includes("file size")) {
|
|
2589
|
+
return getVerifier("complexity");
|
|
2590
|
+
}
|
|
2591
|
+
if (lowerRule.includes("security") || lowerRule.includes("secret") || lowerRule.includes("password") || lowerRule.includes("token") || lowerRule.includes("xss") || lowerRule.includes("sql") || lowerRule.includes("eval")) {
|
|
2592
|
+
return getVerifier("security");
|
|
2593
|
+
}
|
|
2594
|
+
if (lowerRule.includes("endpoint") || lowerRule.includes("rest") || lowerRule.includes("api") && lowerRule.includes("path")) {
|
|
2595
|
+
return getVerifier("api");
|
|
2596
|
+
}
|
|
1966
2597
|
if (lowerRule.includes("naming") || lowerRule.includes("case")) {
|
|
1967
2598
|
return getVerifier("naming");
|
|
1968
2599
|
}
|
|
@@ -1978,10 +2609,60 @@ function selectVerifierForConstraint(rule, specifiedVerifier) {
|
|
|
1978
2609
|
return getVerifier("regex");
|
|
1979
2610
|
}
|
|
1980
2611
|
|
|
2612
|
+
// src/verification/cache.ts
|
|
2613
|
+
import { stat as stat2 } from "fs/promises";
|
|
2614
|
+
var AstCache = class {
|
|
2615
|
+
cache = /* @__PURE__ */ new Map();
|
|
2616
|
+
async get(filePath, project) {
|
|
2617
|
+
try {
|
|
2618
|
+
const info = await stat2(filePath);
|
|
2619
|
+
const cached = this.cache.get(filePath);
|
|
2620
|
+
if (cached && cached.mtimeMs >= info.mtimeMs) {
|
|
2621
|
+
return cached.sourceFile;
|
|
2622
|
+
}
|
|
2623
|
+
let sourceFile = project.getSourceFile(filePath);
|
|
2624
|
+
if (!sourceFile) {
|
|
2625
|
+
sourceFile = project.addSourceFileAtPath(filePath);
|
|
2626
|
+
} else {
|
|
2627
|
+
sourceFile.refreshFromFileSystemSync();
|
|
2628
|
+
}
|
|
2629
|
+
this.cache.set(filePath, { sourceFile, mtimeMs: info.mtimeMs });
|
|
2630
|
+
return sourceFile;
|
|
2631
|
+
} catch {
|
|
2632
|
+
return null;
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
clear() {
|
|
2636
|
+
this.cache.clear();
|
|
2637
|
+
}
|
|
2638
|
+
};
|
|
2639
|
+
|
|
2640
|
+
// src/verification/applicability.ts
|
|
2641
|
+
function isConstraintExcepted(filePath, constraint, cwd) {
|
|
2642
|
+
if (!constraint.exceptions) return false;
|
|
2643
|
+
return constraint.exceptions.some((exception) => {
|
|
2644
|
+
if (exception.expiresAt) {
|
|
2645
|
+
const expiryDate = new Date(exception.expiresAt);
|
|
2646
|
+
if (expiryDate < /* @__PURE__ */ new Date()) {
|
|
2647
|
+
return false;
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
return matchesPattern(filePath, exception.pattern, { cwd });
|
|
2651
|
+
});
|
|
2652
|
+
}
|
|
2653
|
+
function shouldApplyConstraintToFile(params) {
|
|
2654
|
+
const { filePath, constraint, cwd, severityFilter } = params;
|
|
2655
|
+
if (!matchesPattern(filePath, constraint.scope, { cwd })) return false;
|
|
2656
|
+
if (severityFilter && !severityFilter.includes(constraint.severity)) return false;
|
|
2657
|
+
if (isConstraintExcepted(filePath, constraint, cwd)) return false;
|
|
2658
|
+
return true;
|
|
2659
|
+
}
|
|
2660
|
+
|
|
1981
2661
|
// src/verification/engine.ts
|
|
1982
2662
|
var VerificationEngine = class {
|
|
1983
2663
|
registry;
|
|
1984
2664
|
project;
|
|
2665
|
+
astCache;
|
|
1985
2666
|
constructor(registry) {
|
|
1986
2667
|
this.registry = registry || createRegistry();
|
|
1987
2668
|
this.project = new Project2({
|
|
@@ -1993,6 +2674,7 @@ var VerificationEngine = class {
|
|
|
1993
2674
|
},
|
|
1994
2675
|
skipAddingFilesFromTsConfig: true
|
|
1995
2676
|
});
|
|
2677
|
+
this.astCache = new AstCache();
|
|
1996
2678
|
}
|
|
1997
2679
|
/**
|
|
1998
2680
|
* Run verification
|
|
@@ -2035,8 +2717,8 @@ var VerificationEngine = class {
|
|
|
2035
2717
|
let failed = 0;
|
|
2036
2718
|
const skipped = 0;
|
|
2037
2719
|
let timeoutHandle = null;
|
|
2038
|
-
const timeoutPromise = new Promise((
|
|
2039
|
-
timeoutHandle = setTimeout(() =>
|
|
2720
|
+
const timeoutPromise = new Promise((resolve2) => {
|
|
2721
|
+
timeoutHandle = setTimeout(() => resolve2("timeout"), timeout);
|
|
2040
2722
|
timeoutHandle.unref();
|
|
2041
2723
|
});
|
|
2042
2724
|
const verificationPromise = this.verifyFiles(
|
|
@@ -2098,23 +2780,11 @@ var VerificationEngine = class {
|
|
|
2098
2780
|
*/
|
|
2099
2781
|
async verifyFile(filePath, decisions, severityFilter, cwd = process.cwd()) {
|
|
2100
2782
|
const violations = [];
|
|
2101
|
-
|
|
2102
|
-
if (!sourceFile)
|
|
2103
|
-
try {
|
|
2104
|
-
sourceFile = this.project.addSourceFileAtPath(filePath);
|
|
2105
|
-
} catch {
|
|
2106
|
-
return violations;
|
|
2107
|
-
}
|
|
2108
|
-
}
|
|
2783
|
+
const sourceFile = await this.astCache.get(filePath, this.project);
|
|
2784
|
+
if (!sourceFile) return violations;
|
|
2109
2785
|
for (const decision of decisions) {
|
|
2110
2786
|
for (const constraint of decision.constraints) {
|
|
2111
|
-
if (!
|
|
2112
|
-
continue;
|
|
2113
|
-
}
|
|
2114
|
-
if (severityFilter && !severityFilter.includes(constraint.severity)) {
|
|
2115
|
-
continue;
|
|
2116
|
-
}
|
|
2117
|
-
if (this.isExcepted(filePath, constraint, cwd)) {
|
|
2787
|
+
if (!shouldApplyConstraintToFile({ filePath, constraint, cwd, severityFilter })) {
|
|
2118
2788
|
continue;
|
|
2119
2789
|
}
|
|
2120
2790
|
const verifier = selectVerifierForConstraint(constraint.rule, constraint.verifier);
|
|
@@ -2140,25 +2810,16 @@ var VerificationEngine = class {
|
|
|
2140
2810
|
* Verify multiple files
|
|
2141
2811
|
*/
|
|
2142
2812
|
async verifyFiles(files, decisions, severityFilter, cwd, onFileVerified) {
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
isExcepted(filePath, constraint, cwd) {
|
|
2152
|
-
if (!constraint.exceptions) return false;
|
|
2153
|
-
return constraint.exceptions.some((exception) => {
|
|
2154
|
-
if (exception.expiresAt) {
|
|
2155
|
-
const expiryDate = new Date(exception.expiresAt);
|
|
2156
|
-
if (expiryDate < /* @__PURE__ */ new Date()) {
|
|
2157
|
-
return false;
|
|
2158
|
-
}
|
|
2813
|
+
const BATCH_SIZE = 10;
|
|
2814
|
+
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
2815
|
+
const batch = files.slice(i, i + BATCH_SIZE);
|
|
2816
|
+
const results = await Promise.all(
|
|
2817
|
+
batch.map((file) => this.verifyFile(file, decisions, severityFilter, cwd))
|
|
2818
|
+
);
|
|
2819
|
+
for (const violations of results) {
|
|
2820
|
+
onFileVerified(violations);
|
|
2159
2821
|
}
|
|
2160
|
-
|
|
2161
|
-
});
|
|
2822
|
+
}
|
|
2162
2823
|
}
|
|
2163
2824
|
/**
|
|
2164
2825
|
* Get registry
|
|
@@ -2171,8 +2832,111 @@ function createVerificationEngine(registry) {
|
|
|
2171
2832
|
return new VerificationEngine(registry);
|
|
2172
2833
|
}
|
|
2173
2834
|
|
|
2835
|
+
// src/verification/incremental.ts
|
|
2836
|
+
import { execFile } from "child_process";
|
|
2837
|
+
import { promisify } from "util";
|
|
2838
|
+
import { resolve } from "path";
|
|
2839
|
+
var execFileAsync = promisify(execFile);
|
|
2840
|
+
async function getChangedFiles(cwd) {
|
|
2841
|
+
try {
|
|
2842
|
+
const { stdout: stdout2 } = await execFileAsync("git", ["diff", "--name-only", "--diff-filter=AM", "HEAD"], { cwd });
|
|
2843
|
+
const rel = stdout2.trim().split("\n").map((s) => s.trim()).filter(Boolean);
|
|
2844
|
+
const abs = [];
|
|
2845
|
+
for (const file of rel) {
|
|
2846
|
+
const full = resolve(cwd, file);
|
|
2847
|
+
if (await pathExists(full)) abs.push(full);
|
|
2848
|
+
}
|
|
2849
|
+
return abs;
|
|
2850
|
+
} catch {
|
|
2851
|
+
return [];
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
// src/verification/autofix/engine.ts
|
|
2856
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
2857
|
+
import readline from "readline/promises";
|
|
2858
|
+
import { stdin, stdout } from "process";
|
|
2859
|
+
function applyEdits(content, edits) {
|
|
2860
|
+
const sorted = [...edits].sort((a, b) => b.start - a.start);
|
|
2861
|
+
let next = content;
|
|
2862
|
+
const patches = [];
|
|
2863
|
+
let skippedEdits = 0;
|
|
2864
|
+
let lastStart = Number.POSITIVE_INFINITY;
|
|
2865
|
+
for (const edit of sorted) {
|
|
2866
|
+
if (edit.start < 0 || edit.end < edit.start || edit.end > next.length) {
|
|
2867
|
+
skippedEdits++;
|
|
2868
|
+
continue;
|
|
2869
|
+
}
|
|
2870
|
+
if (edit.end > lastStart) {
|
|
2871
|
+
skippedEdits++;
|
|
2872
|
+
continue;
|
|
2873
|
+
}
|
|
2874
|
+
lastStart = edit.start;
|
|
2875
|
+
const originalText = next.slice(edit.start, edit.end);
|
|
2876
|
+
next = next.slice(0, edit.start) + edit.text + next.slice(edit.end);
|
|
2877
|
+
patches.push({
|
|
2878
|
+
filePath: "",
|
|
2879
|
+
description: edit.description,
|
|
2880
|
+
start: edit.start,
|
|
2881
|
+
end: edit.end,
|
|
2882
|
+
originalText,
|
|
2883
|
+
fixedText: edit.text
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
return { next, patches, skippedEdits };
|
|
2887
|
+
}
|
|
2888
|
+
async function confirmFix(prompt) {
|
|
2889
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
2890
|
+
try {
|
|
2891
|
+
const answer = await rl.question(`${prompt} (y/N) `);
|
|
2892
|
+
return answer.trim().toLowerCase() === "y";
|
|
2893
|
+
} finally {
|
|
2894
|
+
rl.close();
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
var AutofixEngine = class {
|
|
2898
|
+
async applyFixes(violations, options = {}) {
|
|
2899
|
+
const fixable = violations.filter((v) => v.autofix && v.autofix.edits.length > 0);
|
|
2900
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
2901
|
+
for (const v of fixable) {
|
|
2902
|
+
const list = byFile.get(v.file) ?? [];
|
|
2903
|
+
list.push(v);
|
|
2904
|
+
byFile.set(v.file, list);
|
|
2905
|
+
}
|
|
2906
|
+
const applied = [];
|
|
2907
|
+
let skippedViolations = 0;
|
|
2908
|
+
for (const [filePath, fileViolations] of byFile) {
|
|
2909
|
+
const original = await readFile2(filePath, "utf-8");
|
|
2910
|
+
const edits = [];
|
|
2911
|
+
for (const violation of fileViolations) {
|
|
2912
|
+
const fix = violation.autofix;
|
|
2913
|
+
if (options.interactive) {
|
|
2914
|
+
const ok = await confirmFix(`Apply fix: ${fix.description} (${filePath}:${violation.line ?? 1})?`);
|
|
2915
|
+
if (!ok) {
|
|
2916
|
+
skippedViolations++;
|
|
2917
|
+
continue;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
for (const edit of fix.edits) {
|
|
2921
|
+
edits.push({ ...edit, description: fix.description });
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
if (edits.length === 0) continue;
|
|
2925
|
+
const { next, patches, skippedEdits } = applyEdits(original, edits);
|
|
2926
|
+
skippedViolations += skippedEdits;
|
|
2927
|
+
if (!options.dryRun) {
|
|
2928
|
+
await writeFile2(filePath, next, "utf-8");
|
|
2929
|
+
}
|
|
2930
|
+
for (const patch of patches) {
|
|
2931
|
+
applied.push({ ...patch, filePath });
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
return { applied, skipped: skippedViolations };
|
|
2935
|
+
}
|
|
2936
|
+
};
|
|
2937
|
+
|
|
2174
2938
|
// src/propagation/graph.ts
|
|
2175
|
-
async function
|
|
2939
|
+
async function buildDependencyGraph2(decisions, files) {
|
|
2176
2940
|
const nodes = /* @__PURE__ */ new Map();
|
|
2177
2941
|
const decisionToFiles = /* @__PURE__ */ new Map();
|
|
2178
2942
|
const fileToDecisions = /* @__PURE__ */ new Map();
|
|
@@ -2263,7 +3027,7 @@ var PropagationEngine = class {
|
|
|
2263
3027
|
absolute: true
|
|
2264
3028
|
});
|
|
2265
3029
|
const decisions = this.registry.getActive();
|
|
2266
|
-
this.graph = await
|
|
3030
|
+
this.graph = await buildDependencyGraph2(decisions, files);
|
|
2267
3031
|
}
|
|
2268
3032
|
/**
|
|
2269
3033
|
* Analyze impact of changing a decision
|
|
@@ -2289,10 +3053,10 @@ var PropagationEngine = class {
|
|
|
2289
3053
|
}
|
|
2290
3054
|
fileViolations.set(violation.file, existing);
|
|
2291
3055
|
}
|
|
2292
|
-
const affectedFiles = affectedFilePaths.map((
|
|
2293
|
-
path,
|
|
2294
|
-
violations: fileViolations.get(
|
|
2295
|
-
autoFixable: fileViolations.get(
|
|
3056
|
+
const affectedFiles = affectedFilePaths.map((path3) => ({
|
|
3057
|
+
path: path3,
|
|
3058
|
+
violations: fileViolations.get(path3)?.total || 0,
|
|
3059
|
+
autoFixable: fileViolations.get(path3)?.autoFixable || 0
|
|
2296
3060
|
}));
|
|
2297
3061
|
affectedFiles.sort((a, b) => b.violations - a.violations);
|
|
2298
3062
|
const totalViolations = result.violations.length;
|
|
@@ -2981,11 +3745,244 @@ ${decision.decision.summary}
|
|
|
2981
3745
|
);
|
|
2982
3746
|
}
|
|
2983
3747
|
};
|
|
3748
|
+
|
|
3749
|
+
// src/agent/templates.ts
|
|
3750
|
+
var templates = {
|
|
3751
|
+
"code-review": {
|
|
3752
|
+
name: "Code Review",
|
|
3753
|
+
description: "Review code for architectural compliance",
|
|
3754
|
+
generate: (context) => {
|
|
3755
|
+
return [
|
|
3756
|
+
"You are reviewing code for architectural compliance.",
|
|
3757
|
+
"",
|
|
3758
|
+
formatContextAsMarkdown(context),
|
|
3759
|
+
"",
|
|
3760
|
+
"Task:",
|
|
3761
|
+
"- Identify violations of the constraints above.",
|
|
3762
|
+
"- Suggest concrete changes to achieve compliance."
|
|
3763
|
+
].join("\n");
|
|
3764
|
+
}
|
|
3765
|
+
},
|
|
3766
|
+
refactoring: {
|
|
3767
|
+
name: "Refactoring Guidance",
|
|
3768
|
+
description: "Guide refactoring to meet constraints",
|
|
3769
|
+
generate: (context) => {
|
|
3770
|
+
return [
|
|
3771
|
+
"You are helping refactor code to meet architectural constraints.",
|
|
3772
|
+
"",
|
|
3773
|
+
formatContextAsMarkdown(context),
|
|
3774
|
+
"",
|
|
3775
|
+
"Task:",
|
|
3776
|
+
"- Propose a step-by-step refactoring plan to satisfy all invariants first, then conventions/guidelines.",
|
|
3777
|
+
"- Highlight risky changes and suggest safe incremental steps."
|
|
3778
|
+
].join("\n");
|
|
3779
|
+
}
|
|
3780
|
+
},
|
|
3781
|
+
migration: {
|
|
3782
|
+
name: "Migration Plan",
|
|
3783
|
+
description: "Generate a migration plan for a new/changed decision",
|
|
3784
|
+
generate: (context, options) => {
|
|
3785
|
+
const decisionId = String(options?.decisionId ?? "");
|
|
3786
|
+
return [
|
|
3787
|
+
`A new architectural decision has been introduced: ${decisionId || "<decision-id>"}`,
|
|
3788
|
+
"",
|
|
3789
|
+
formatContextAsMarkdown(context),
|
|
3790
|
+
"",
|
|
3791
|
+
"Task:",
|
|
3792
|
+
"- Provide an impact analysis.",
|
|
3793
|
+
"- Produce a step-by-step migration plan.",
|
|
3794
|
+
"- Include a checklist for completion."
|
|
3795
|
+
].join("\n");
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
};
|
|
3799
|
+
|
|
3800
|
+
// src/mcp/server.ts
|
|
3801
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3802
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3803
|
+
import { z as z3 } from "zod";
|
|
3804
|
+
var SpecBridgeMcpServer = class {
|
|
3805
|
+
server;
|
|
3806
|
+
cwd;
|
|
3807
|
+
config = null;
|
|
3808
|
+
registry = null;
|
|
3809
|
+
constructor(options) {
|
|
3810
|
+
this.cwd = options.cwd;
|
|
3811
|
+
this.server = new McpServer({ name: "specbridge", version: options.version });
|
|
3812
|
+
}
|
|
3813
|
+
async initialize() {
|
|
3814
|
+
this.config = await loadConfig(this.cwd);
|
|
3815
|
+
this.registry = createRegistry({ basePath: this.cwd });
|
|
3816
|
+
await this.registry.load();
|
|
3817
|
+
this.registerResources();
|
|
3818
|
+
this.registerTools();
|
|
3819
|
+
}
|
|
3820
|
+
async startStdio() {
|
|
3821
|
+
const transport = new StdioServerTransport();
|
|
3822
|
+
await this.server.connect(transport);
|
|
3823
|
+
}
|
|
3824
|
+
getReady() {
|
|
3825
|
+
if (!this.config || !this.registry) {
|
|
3826
|
+
throw new Error("SpecBridge MCP server not initialized. Call initialize() first.");
|
|
3827
|
+
}
|
|
3828
|
+
return { config: this.config, registry: this.registry };
|
|
3829
|
+
}
|
|
3830
|
+
registerResources() {
|
|
3831
|
+
const { config, registry } = this.getReady();
|
|
3832
|
+
this.server.registerResource(
|
|
3833
|
+
"decisions",
|
|
3834
|
+
"decision:///",
|
|
3835
|
+
{
|
|
3836
|
+
title: "Architectural Decisions",
|
|
3837
|
+
description: "List of all architectural decisions",
|
|
3838
|
+
mimeType: "application/json"
|
|
3839
|
+
},
|
|
3840
|
+
async (uri) => {
|
|
3841
|
+
const decisions = registry.getAll();
|
|
3842
|
+
return {
|
|
3843
|
+
contents: [
|
|
3844
|
+
{
|
|
3845
|
+
uri: uri.href,
|
|
3846
|
+
mimeType: "application/json",
|
|
3847
|
+
text: JSON.stringify(decisions, null, 2)
|
|
3848
|
+
}
|
|
3849
|
+
]
|
|
3850
|
+
};
|
|
3851
|
+
}
|
|
3852
|
+
);
|
|
3853
|
+
this.server.registerResource(
|
|
3854
|
+
"decision",
|
|
3855
|
+
new ResourceTemplate("decision://{id}", { list: void 0 }),
|
|
3856
|
+
{
|
|
3857
|
+
title: "Architectural Decision",
|
|
3858
|
+
description: "A specific architectural decision by id",
|
|
3859
|
+
mimeType: "application/json"
|
|
3860
|
+
},
|
|
3861
|
+
async (uri, variables) => {
|
|
3862
|
+
const raw = variables.id;
|
|
3863
|
+
const decisionId = Array.isArray(raw) ? raw[0] ?? "" : raw ?? "";
|
|
3864
|
+
const decision = registry.get(String(decisionId));
|
|
3865
|
+
return {
|
|
3866
|
+
contents: [
|
|
3867
|
+
{
|
|
3868
|
+
uri: uri.href,
|
|
3869
|
+
mimeType: "application/json",
|
|
3870
|
+
text: JSON.stringify(decision, null, 2)
|
|
3871
|
+
}
|
|
3872
|
+
]
|
|
3873
|
+
};
|
|
3874
|
+
}
|
|
3875
|
+
);
|
|
3876
|
+
this.server.registerResource(
|
|
3877
|
+
"latest_report",
|
|
3878
|
+
"report:///latest",
|
|
3879
|
+
{
|
|
3880
|
+
title: "Latest Compliance Report",
|
|
3881
|
+
description: "Most recent compliance report (generated on demand)",
|
|
3882
|
+
mimeType: "application/json"
|
|
3883
|
+
},
|
|
3884
|
+
async (uri) => {
|
|
3885
|
+
const report = await generateReport(config, { cwd: this.cwd });
|
|
3886
|
+
return {
|
|
3887
|
+
contents: [
|
|
3888
|
+
{
|
|
3889
|
+
uri: uri.href,
|
|
3890
|
+
mimeType: "application/json",
|
|
3891
|
+
text: JSON.stringify(report, null, 2)
|
|
3892
|
+
}
|
|
3893
|
+
]
|
|
3894
|
+
};
|
|
3895
|
+
}
|
|
3896
|
+
);
|
|
3897
|
+
}
|
|
3898
|
+
registerTools() {
|
|
3899
|
+
const { config } = this.getReady();
|
|
3900
|
+
this.server.registerTool(
|
|
3901
|
+
"generate_context",
|
|
3902
|
+
{
|
|
3903
|
+
title: "Generate architectural context",
|
|
3904
|
+
description: "Generate architectural context for a file from applicable decisions",
|
|
3905
|
+
inputSchema: {
|
|
3906
|
+
filePath: z3.string().describe("Path to the file to analyze"),
|
|
3907
|
+
includeRationale: z3.boolean().optional().default(true),
|
|
3908
|
+
format: z3.enum(["markdown", "json", "mcp"]).optional().default("markdown")
|
|
3909
|
+
}
|
|
3910
|
+
},
|
|
3911
|
+
async (args) => {
|
|
3912
|
+
const text = await generateFormattedContext(args.filePath, config, {
|
|
3913
|
+
includeRationale: args.includeRationale,
|
|
3914
|
+
format: args.format,
|
|
3915
|
+
cwd: this.cwd
|
|
3916
|
+
});
|
|
3917
|
+
return { content: [{ type: "text", text }] };
|
|
3918
|
+
}
|
|
3919
|
+
);
|
|
3920
|
+
this.server.registerTool(
|
|
3921
|
+
"verify_compliance",
|
|
3922
|
+
{
|
|
3923
|
+
title: "Verify compliance",
|
|
3924
|
+
description: "Verify code compliance against constraints",
|
|
3925
|
+
inputSchema: {
|
|
3926
|
+
level: z3.enum(["commit", "pr", "full"]).optional().default("full"),
|
|
3927
|
+
files: z3.array(z3.string()).optional(),
|
|
3928
|
+
decisions: z3.array(z3.string()).optional(),
|
|
3929
|
+
severity: z3.array(z3.enum(["critical", "high", "medium", "low"])).optional()
|
|
3930
|
+
}
|
|
3931
|
+
},
|
|
3932
|
+
async (args) => {
|
|
3933
|
+
const engine = createVerificationEngine();
|
|
3934
|
+
const result = await engine.verify(config, {
|
|
3935
|
+
level: args.level,
|
|
3936
|
+
files: args.files,
|
|
3937
|
+
decisions: args.decisions,
|
|
3938
|
+
severity: args.severity,
|
|
3939
|
+
cwd: this.cwd
|
|
3940
|
+
});
|
|
3941
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
3942
|
+
}
|
|
3943
|
+
);
|
|
3944
|
+
this.server.registerTool(
|
|
3945
|
+
"get_report",
|
|
3946
|
+
{
|
|
3947
|
+
title: "Get compliance report",
|
|
3948
|
+
description: "Generate a compliance report for the current workspace",
|
|
3949
|
+
inputSchema: {
|
|
3950
|
+
format: z3.enum(["summary", "detailed", "json", "markdown"]).optional().default("summary"),
|
|
3951
|
+
includeAll: z3.boolean().optional().default(false)
|
|
3952
|
+
}
|
|
3953
|
+
},
|
|
3954
|
+
async (args) => {
|
|
3955
|
+
const report = await generateReport(config, { cwd: this.cwd, includeAll: args.includeAll });
|
|
3956
|
+
if (args.format === "json") {
|
|
3957
|
+
return { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
3958
|
+
}
|
|
3959
|
+
if (args.format === "markdown") {
|
|
3960
|
+
return { content: [{ type: "text", text: formatMarkdownReport(report) }] };
|
|
3961
|
+
}
|
|
3962
|
+
if (args.format === "detailed") {
|
|
3963
|
+
return { content: [{ type: "text", text: formatConsoleReport(report) }] };
|
|
3964
|
+
}
|
|
3965
|
+
const summary = {
|
|
3966
|
+
timestamp: report.timestamp,
|
|
3967
|
+
project: report.project,
|
|
3968
|
+
compliance: report.summary.compliance,
|
|
3969
|
+
violations: report.summary.violations,
|
|
3970
|
+
decisions: report.summary.activeDecisions
|
|
3971
|
+
};
|
|
3972
|
+
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
|
3973
|
+
}
|
|
3974
|
+
);
|
|
3975
|
+
}
|
|
3976
|
+
};
|
|
2984
3977
|
export {
|
|
2985
3978
|
AgentContextGenerator,
|
|
2986
3979
|
AlreadyInitializedError,
|
|
2987
3980
|
AnalyzerNotFoundError,
|
|
3981
|
+
ApiVerifier,
|
|
3982
|
+
AstCache,
|
|
3983
|
+
AutofixEngine,
|
|
2988
3984
|
CodeScanner,
|
|
3985
|
+
ComplexityVerifier,
|
|
2989
3986
|
ConfigError,
|
|
2990
3987
|
ConstraintExceptionSchema,
|
|
2991
3988
|
ConstraintSchema,
|
|
@@ -2996,6 +3993,7 @@ export {
|
|
|
2996
3993
|
DecisionSchema,
|
|
2997
3994
|
DecisionStatusSchema,
|
|
2998
3995
|
DecisionValidationError,
|
|
3996
|
+
DependencyVerifier,
|
|
2999
3997
|
ErrorsAnalyzer,
|
|
3000
3998
|
ErrorsVerifier,
|
|
3001
3999
|
FileSystemError,
|
|
@@ -3013,16 +4011,18 @@ export {
|
|
|
3013
4011
|
Registry,
|
|
3014
4012
|
RegistryError,
|
|
3015
4013
|
Reporter,
|
|
4014
|
+
SecurityVerifier,
|
|
3016
4015
|
SeveritySchema,
|
|
3017
4016
|
SpecBridgeConfigSchema,
|
|
3018
4017
|
SpecBridgeError,
|
|
4018
|
+
SpecBridgeMcpServer,
|
|
3019
4019
|
StructureAnalyzer,
|
|
3020
4020
|
VerificationConfigSchema,
|
|
3021
4021
|
VerificationEngine,
|
|
3022
4022
|
VerificationError,
|
|
3023
4023
|
VerificationFrequencySchema,
|
|
3024
4024
|
VerifierNotFoundError,
|
|
3025
|
-
buildDependencyGraph,
|
|
4025
|
+
buildDependencyGraph2 as buildDependencyGraph,
|
|
3026
4026
|
builtinAnalyzers,
|
|
3027
4027
|
builtinVerifiers,
|
|
3028
4028
|
calculateConfidence,
|
|
@@ -3051,6 +4051,7 @@ export {
|
|
|
3051
4051
|
getAffectingDecisions,
|
|
3052
4052
|
getAnalyzer,
|
|
3053
4053
|
getAnalyzerIds,
|
|
4054
|
+
getChangedFiles,
|
|
3054
4055
|
getConfigPath,
|
|
3055
4056
|
getDecisionsDir,
|
|
3056
4057
|
getInferredDir,
|
|
@@ -3061,6 +4062,7 @@ export {
|
|
|
3061
4062
|
getVerifierIds,
|
|
3062
4063
|
getVerifiersDir,
|
|
3063
4064
|
glob,
|
|
4065
|
+
isConstraintExcepted,
|
|
3064
4066
|
isDirectory,
|
|
3065
4067
|
loadConfig,
|
|
3066
4068
|
loadDecisionFile,
|
|
@@ -3077,7 +4079,9 @@ export {
|
|
|
3077
4079
|
readTextFile,
|
|
3078
4080
|
runInference,
|
|
3079
4081
|
selectVerifierForConstraint,
|
|
4082
|
+
shouldApplyConstraintToFile,
|
|
3080
4083
|
stringifyYaml,
|
|
4084
|
+
templates,
|
|
3081
4085
|
updateYamlDocument,
|
|
3082
4086
|
validateConfig,
|
|
3083
4087
|
validateDecision,
|