@ipation/specbridge 2.4.8 → 2.4.9
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 +22 -1
- package/dist/cli.js +810 -764
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +40 -48
- package/dist/index.js +842 -788
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -1774,6 +1774,283 @@ function createRegistry(options) {
|
|
|
1774
1774
|
return new Registry(options);
|
|
1775
1775
|
}
|
|
1776
1776
|
|
|
1777
|
+
// src/verification/cache.ts
|
|
1778
|
+
import { stat as stat2, readFile as readFile2 } from "fs/promises";
|
|
1779
|
+
import { createHash } from "crypto";
|
|
1780
|
+
var AstCache = class {
|
|
1781
|
+
cache = /* @__PURE__ */ new Map();
|
|
1782
|
+
async get(filePath, project) {
|
|
1783
|
+
try {
|
|
1784
|
+
const stats = await stat2(filePath);
|
|
1785
|
+
const cached = this.cache.get(filePath);
|
|
1786
|
+
if (cached && cached.mtimeMs >= stats.mtimeMs) {
|
|
1787
|
+
return cached.sourceFile;
|
|
1788
|
+
}
|
|
1789
|
+
const content = await readFile2(filePath, "utf-8");
|
|
1790
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
1791
|
+
if (cached && cached.hash === hash) {
|
|
1792
|
+
cached.mtimeMs = stats.mtimeMs;
|
|
1793
|
+
return cached.sourceFile;
|
|
1794
|
+
}
|
|
1795
|
+
let sourceFile = project.getSourceFile(filePath);
|
|
1796
|
+
if (!sourceFile) {
|
|
1797
|
+
sourceFile = project.addSourceFileAtPath(filePath);
|
|
1798
|
+
} else {
|
|
1799
|
+
sourceFile.refreshFromFileSystemSync();
|
|
1800
|
+
}
|
|
1801
|
+
this.cache.set(filePath, {
|
|
1802
|
+
sourceFile,
|
|
1803
|
+
hash,
|
|
1804
|
+
mtimeMs: stats.mtimeMs
|
|
1805
|
+
});
|
|
1806
|
+
return sourceFile;
|
|
1807
|
+
} catch {
|
|
1808
|
+
return null;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
clear() {
|
|
1812
|
+
this.cache.clear();
|
|
1813
|
+
}
|
|
1814
|
+
getStats() {
|
|
1815
|
+
return {
|
|
1816
|
+
entries: this.cache.size,
|
|
1817
|
+
memoryEstimate: this.cache.size * 5e4
|
|
1818
|
+
// Rough estimate: 50KB per AST
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
};
|
|
1822
|
+
|
|
1823
|
+
// src/verification/file-verifier.ts
|
|
1824
|
+
import { createHash as createHash2 } from "crypto";
|
|
1825
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
1826
|
+
|
|
1827
|
+
// src/verification/applicability.ts
|
|
1828
|
+
function isConstraintExcepted(filePath, constraint, cwd) {
|
|
1829
|
+
if (!constraint.exceptions) return false;
|
|
1830
|
+
return constraint.exceptions.some((exception) => {
|
|
1831
|
+
if (exception.expiresAt) {
|
|
1832
|
+
const expiryDate = new Date(exception.expiresAt);
|
|
1833
|
+
if (expiryDate < /* @__PURE__ */ new Date()) {
|
|
1834
|
+
return false;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
return matchesPattern(filePath, exception.pattern, { cwd });
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
function shouldApplyConstraintToFile(params) {
|
|
1841
|
+
const { filePath, constraint, cwd, severityFilter } = params;
|
|
1842
|
+
if (!matchesPattern(filePath, constraint.scope, { cwd })) return false;
|
|
1843
|
+
if (severityFilter && !severityFilter.includes(constraint.severity)) return false;
|
|
1844
|
+
if (isConstraintExcepted(filePath, constraint, cwd)) return false;
|
|
1845
|
+
return true;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// src/verification/plugins/loader.ts
|
|
1849
|
+
import { existsSync } from "fs";
|
|
1850
|
+
import { join as join5 } from "path";
|
|
1851
|
+
import { pathToFileURL } from "url";
|
|
1852
|
+
import fg2 from "fast-glob";
|
|
1853
|
+
var PluginLoader = class {
|
|
1854
|
+
plugins = /* @__PURE__ */ new Map();
|
|
1855
|
+
loaded = false;
|
|
1856
|
+
loadErrors = [];
|
|
1857
|
+
logger = getLogger({ module: "verification.plugins.loader" });
|
|
1858
|
+
/**
|
|
1859
|
+
* Load all plugins from the specified base path
|
|
1860
|
+
*
|
|
1861
|
+
* @param basePath - Project root directory (usually cwd)
|
|
1862
|
+
*/
|
|
1863
|
+
async loadPlugins(basePath) {
|
|
1864
|
+
const verifiersDir = join5(basePath, ".specbridge", "verifiers");
|
|
1865
|
+
if (!existsSync(verifiersDir)) {
|
|
1866
|
+
this.loaded = true;
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
const files = await fg2("**/*.{ts,js}", {
|
|
1870
|
+
cwd: verifiersDir,
|
|
1871
|
+
absolute: true,
|
|
1872
|
+
ignore: ["**/*.test.{ts,js}", "**/*.d.ts"]
|
|
1873
|
+
});
|
|
1874
|
+
for (const file of files) {
|
|
1875
|
+
try {
|
|
1876
|
+
await this.loadPlugin(file);
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1879
|
+
this.loadErrors.push({ file, error: message });
|
|
1880
|
+
this.logger.warn({ file, error: message }, "Failed to load plugin");
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
this.loaded = true;
|
|
1884
|
+
if (this.plugins.size > 0) {
|
|
1885
|
+
this.logger.info({ count: this.plugins.size }, "Loaded custom verifier plugins");
|
|
1886
|
+
}
|
|
1887
|
+
if (this.loadErrors.length > 0) {
|
|
1888
|
+
this.logger.warn({ count: this.loadErrors.length }, "Plugin load failures");
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Load a single plugin file
|
|
1893
|
+
*/
|
|
1894
|
+
async loadPlugin(filePath) {
|
|
1895
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
1896
|
+
const module = await import(fileUrl);
|
|
1897
|
+
const plugin = module.default || module.plugin;
|
|
1898
|
+
if (!plugin) {
|
|
1899
|
+
throw new Error('Plugin must export a default or named "plugin" export');
|
|
1900
|
+
}
|
|
1901
|
+
this.validatePlugin(plugin, filePath);
|
|
1902
|
+
if (this.plugins.has(plugin.metadata.id)) {
|
|
1903
|
+
throw new Error(
|
|
1904
|
+
`Plugin ID "${plugin.metadata.id}" is already registered. Each plugin must have a unique ID.`
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
this.plugins.set(plugin.metadata.id, plugin);
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Validate plugin structure and metadata
|
|
1911
|
+
*/
|
|
1912
|
+
validatePlugin(plugin, _filePath) {
|
|
1913
|
+
if (!plugin.metadata) {
|
|
1914
|
+
throw new Error('Plugin must have a "metadata" property');
|
|
1915
|
+
}
|
|
1916
|
+
if (!plugin.metadata.id || typeof plugin.metadata.id !== "string") {
|
|
1917
|
+
throw new Error('Plugin metadata must have a string "id" property');
|
|
1918
|
+
}
|
|
1919
|
+
if (!plugin.metadata.version || typeof plugin.metadata.version !== "string") {
|
|
1920
|
+
throw new Error('Plugin metadata must have a string "version" property');
|
|
1921
|
+
}
|
|
1922
|
+
const idPattern = /^[a-z][a-z0-9-]*$/;
|
|
1923
|
+
if (!idPattern.test(plugin.metadata.id)) {
|
|
1924
|
+
throw new Error(
|
|
1925
|
+
`Plugin ID "${plugin.metadata.id}" is invalid. ID must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.`
|
|
1926
|
+
);
|
|
1927
|
+
}
|
|
1928
|
+
if (typeof plugin.createVerifier !== "function") {
|
|
1929
|
+
throw new Error('Plugin must have a "createVerifier" function');
|
|
1930
|
+
}
|
|
1931
|
+
let verifier;
|
|
1932
|
+
try {
|
|
1933
|
+
verifier = plugin.createVerifier();
|
|
1934
|
+
} catch (error) {
|
|
1935
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1936
|
+
throw new Error(`createVerifier() threw an error: ${message}`);
|
|
1937
|
+
}
|
|
1938
|
+
if (!verifier || typeof verifier !== "object") {
|
|
1939
|
+
throw new Error("createVerifier() must return an object");
|
|
1940
|
+
}
|
|
1941
|
+
if (!verifier.id || typeof verifier.id !== "string") {
|
|
1942
|
+
throw new Error('Verifier must have a string "id" property');
|
|
1943
|
+
}
|
|
1944
|
+
if (!verifier.name || typeof verifier.name !== "string") {
|
|
1945
|
+
throw new Error('Verifier must have a string "name" property');
|
|
1946
|
+
}
|
|
1947
|
+
if (!verifier.description || typeof verifier.description !== "string") {
|
|
1948
|
+
throw new Error('Verifier must have a string "description" property');
|
|
1949
|
+
}
|
|
1950
|
+
if (typeof verifier.verify !== "function") {
|
|
1951
|
+
throw new Error('Verifier must have a "verify" method');
|
|
1952
|
+
}
|
|
1953
|
+
if (verifier.id !== plugin.metadata.id) {
|
|
1954
|
+
throw new Error(
|
|
1955
|
+
`Verifier ID "${verifier.id}" does not match plugin metadata ID "${plugin.metadata.id}". These must be identical.`
|
|
1956
|
+
);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* Get a verifier instance by ID
|
|
1961
|
+
*
|
|
1962
|
+
* @param id - Verifier ID
|
|
1963
|
+
* @returns Verifier instance or null if not found
|
|
1964
|
+
*/
|
|
1965
|
+
getVerifier(id) {
|
|
1966
|
+
const plugin = this.plugins.get(id);
|
|
1967
|
+
return plugin ? plugin.createVerifier() : null;
|
|
1968
|
+
}
|
|
1969
|
+
/**
|
|
1970
|
+
* Get a plugin by ID (includes paramsSchema)
|
|
1971
|
+
*
|
|
1972
|
+
* @param id - Plugin ID
|
|
1973
|
+
* @returns Plugin or null if not found
|
|
1974
|
+
*/
|
|
1975
|
+
getPlugin(id) {
|
|
1976
|
+
return this.plugins.get(id) || null;
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Validate params against a plugin's paramsSchema
|
|
1980
|
+
*
|
|
1981
|
+
* @param id - Plugin ID
|
|
1982
|
+
* @param params - Parameters to validate
|
|
1983
|
+
* @returns Validation result with success flag and error message if failed
|
|
1984
|
+
*/
|
|
1985
|
+
validateParams(id, params) {
|
|
1986
|
+
const plugin = this.plugins.get(id);
|
|
1987
|
+
if (!plugin) {
|
|
1988
|
+
return { success: false, error: `Plugin ${id} not found` };
|
|
1989
|
+
}
|
|
1990
|
+
if (!plugin.paramsSchema) {
|
|
1991
|
+
return { success: true };
|
|
1992
|
+
}
|
|
1993
|
+
if (!params) {
|
|
1994
|
+
return { success: false, error: `Plugin ${id} requires params but none were provided` };
|
|
1995
|
+
}
|
|
1996
|
+
if (typeof plugin.paramsSchema !== "object" || !plugin.paramsSchema || !("parse" in plugin.paramsSchema)) {
|
|
1997
|
+
return {
|
|
1998
|
+
success: false,
|
|
1999
|
+
error: `Plugin ${id} has invalid paramsSchema (must be a Zod schema)`
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
const schema = plugin.paramsSchema;
|
|
2003
|
+
if (schema.safeParse) {
|
|
2004
|
+
const result = schema.safeParse(params);
|
|
2005
|
+
if (!result.success) {
|
|
2006
|
+
const errors = result.error?.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(", ") || "Validation failed";
|
|
2007
|
+
return { success: false, error: `Invalid params for ${id}: ${errors}` };
|
|
2008
|
+
}
|
|
2009
|
+
} else {
|
|
2010
|
+
try {
|
|
2011
|
+
schema.parse(params);
|
|
2012
|
+
} catch (error) {
|
|
2013
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2014
|
+
return { success: false, error: `Invalid params for ${id}: ${message}` };
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
return { success: true };
|
|
2018
|
+
}
|
|
2019
|
+
/**
|
|
2020
|
+
* Get all registered plugin IDs
|
|
2021
|
+
*/
|
|
2022
|
+
getPluginIds() {
|
|
2023
|
+
return Array.from(this.plugins.keys());
|
|
2024
|
+
}
|
|
2025
|
+
/**
|
|
2026
|
+
* Check if plugins have been loaded
|
|
2027
|
+
*/
|
|
2028
|
+
isLoaded() {
|
|
2029
|
+
return this.loaded;
|
|
2030
|
+
}
|
|
2031
|
+
/**
|
|
2032
|
+
* Get load errors (for diagnostics)
|
|
2033
|
+
*/
|
|
2034
|
+
getLoadErrors() {
|
|
2035
|
+
return [...this.loadErrors];
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Reset the loader (useful for testing)
|
|
2039
|
+
*/
|
|
2040
|
+
reset() {
|
|
2041
|
+
this.plugins.clear();
|
|
2042
|
+
this.loaded = false;
|
|
2043
|
+
this.loadErrors = [];
|
|
2044
|
+
}
|
|
2045
|
+
};
|
|
2046
|
+
var pluginLoader = null;
|
|
2047
|
+
function getPluginLoader() {
|
|
2048
|
+
if (!pluginLoader) {
|
|
2049
|
+
pluginLoader = new PluginLoader();
|
|
2050
|
+
}
|
|
2051
|
+
return pluginLoader;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
1777
2054
|
// src/verification/verifiers/base.ts
|
|
1778
2055
|
function createViolation(params) {
|
|
1779
2056
|
return params;
|
|
@@ -2633,418 +2910,212 @@ function isStringLiteralLike(node) {
|
|
|
2633
2910
|
}
|
|
2634
2911
|
var SecurityVerifier = class {
|
|
2635
2912
|
id = "security";
|
|
2636
|
-
name = "Security Verifier";
|
|
2637
|
-
description = "Detects common security footguns (secrets, eval, XSS/SQL injection heuristics)";
|
|
2638
|
-
async verify(ctx) {
|
|
2639
|
-
const violations = [];
|
|
2640
|
-
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
2641
|
-
const rule = constraint.rule.toLowerCase();
|
|
2642
|
-
const checkSecrets = rule.includes("secret") || rule.includes("password") || rule.includes("token") || rule.includes("api key") || rule.includes("hardcoded");
|
|
2643
|
-
const checkEval = rule.includes("eval") || rule.includes("function constructor");
|
|
2644
|
-
const checkXss = rule.includes("xss") || rule.includes("innerhtml") || rule.includes("dangerouslysetinnerhtml");
|
|
2645
|
-
const checkSql = rule.includes("sql") || rule.includes("injection");
|
|
2646
|
-
const checkProto = rule.includes("prototype pollution") || rule.includes("__proto__");
|
|
2647
|
-
if (checkSecrets) {
|
|
2648
|
-
for (const vd of sourceFile.getVariableDeclarations()) {
|
|
2649
|
-
const name = vd.getName();
|
|
2650
|
-
if (!SECRET_NAME_RE.test(name)) continue;
|
|
2651
|
-
const init = vd.getInitializer();
|
|
2652
|
-
if (!init || !isStringLiteralLike(init)) continue;
|
|
2653
|
-
const value = init.getText().slice(1, -1);
|
|
2654
|
-
if (value.length === 0) continue;
|
|
2655
|
-
violations.push(
|
|
2656
|
-
createViolation({
|
|
2657
|
-
decisionId,
|
|
2658
|
-
constraintId: constraint.id,
|
|
2659
|
-
type: constraint.type,
|
|
2660
|
-
severity: constraint.severity,
|
|
2661
|
-
message: `Possible hardcoded secret in variable "${name}"`,
|
|
2662
|
-
file: filePath,
|
|
2663
|
-
line: vd.getStartLineNumber(),
|
|
2664
|
-
suggestion: "Move secrets to environment variables or a secret manager"
|
|
2665
|
-
})
|
|
2666
|
-
);
|
|
2667
|
-
}
|
|
2668
|
-
for (const pa of sourceFile.getDescendantsOfKind(SyntaxKind3.PropertyAssignment)) {
|
|
2669
|
-
const propName = pa.getNameNode().getText();
|
|
2670
|
-
if (!SECRET_NAME_RE.test(propName)) continue;
|
|
2671
|
-
const init = pa.getInitializer();
|
|
2672
|
-
if (!init || !isStringLiteralLike(init)) continue;
|
|
2673
|
-
violations.push(
|
|
2674
|
-
createViolation({
|
|
2675
|
-
decisionId,
|
|
2676
|
-
constraintId: constraint.id,
|
|
2677
|
-
type: constraint.type,
|
|
2678
|
-
severity: constraint.severity,
|
|
2679
|
-
message: `Possible hardcoded secret in object property ${propName}`,
|
|
2680
|
-
file: filePath,
|
|
2681
|
-
line: pa.getStartLineNumber(),
|
|
2682
|
-
suggestion: "Move secrets to environment variables or a secret manager"
|
|
2683
|
-
})
|
|
2684
|
-
);
|
|
2685
|
-
}
|
|
2686
|
-
}
|
|
2687
|
-
if (checkEval) {
|
|
2688
|
-
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind3.CallExpression)) {
|
|
2689
|
-
const exprText = call.getExpression().getText();
|
|
2690
|
-
if (exprText === "eval" || exprText === "Function") {
|
|
2691
|
-
violations.push(
|
|
2692
|
-
createViolation({
|
|
2693
|
-
decisionId,
|
|
2694
|
-
constraintId: constraint.id,
|
|
2695
|
-
type: constraint.type,
|
|
2696
|
-
severity: constraint.severity,
|
|
2697
|
-
message: `Unsafe dynamic code execution via ${exprText}()`,
|
|
2698
|
-
file: filePath,
|
|
2699
|
-
line: call.getStartLineNumber(),
|
|
2700
|
-
suggestion: "Avoid eval/Function; use safer alternatives"
|
|
2701
|
-
})
|
|
2702
|
-
);
|
|
2703
|
-
}
|
|
2704
|
-
}
|
|
2705
|
-
}
|
|
2706
|
-
if (checkXss) {
|
|
2707
|
-
for (const bin of sourceFile.getDescendantsOfKind(SyntaxKind3.BinaryExpression)) {
|
|
2708
|
-
const left = bin.getLeft();
|
|
2709
|
-
const propertyAccess = left.asKind(SyntaxKind3.PropertyAccessExpression);
|
|
2710
|
-
if (!propertyAccess) continue;
|
|
2711
|
-
if (propertyAccess.getName() === "innerHTML") {
|
|
2712
|
-
violations.push(
|
|
2713
|
-
createViolation({
|
|
2714
|
-
decisionId,
|
|
2715
|
-
constraintId: constraint.id,
|
|
2716
|
-
type: constraint.type,
|
|
2717
|
-
severity: constraint.severity,
|
|
2718
|
-
message: "Potential XSS: assignment to innerHTML",
|
|
2719
|
-
file: filePath,
|
|
2720
|
-
line: bin.getStartLineNumber(),
|
|
2721
|
-
suggestion: "Prefer textContent or a safe templating/escaping strategy"
|
|
2722
|
-
})
|
|
2723
|
-
);
|
|
2724
|
-
}
|
|
2725
|
-
}
|
|
2726
|
-
if (sourceFile.getFullText().includes("dangerouslySetInnerHTML")) {
|
|
2727
|
-
violations.push(
|
|
2728
|
-
createViolation({
|
|
2729
|
-
decisionId,
|
|
2730
|
-
constraintId: constraint.id,
|
|
2731
|
-
type: constraint.type,
|
|
2732
|
-
severity: constraint.severity,
|
|
2733
|
-
message: "Potential XSS: usage of dangerouslySetInnerHTML",
|
|
2734
|
-
file: filePath,
|
|
2735
|
-
line: 1,
|
|
2736
|
-
suggestion: "Avoid dangerouslySetInnerHTML or ensure content is sanitized"
|
|
2737
|
-
})
|
|
2738
|
-
);
|
|
2739
|
-
}
|
|
2740
|
-
}
|
|
2741
|
-
if (checkSql) {
|
|
2742
|
-
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind3.CallExpression)) {
|
|
2743
|
-
const expr = call.getExpression();
|
|
2744
|
-
const propertyAccess = expr.asKind(SyntaxKind3.PropertyAccessExpression);
|
|
2745
|
-
if (!propertyAccess) continue;
|
|
2746
|
-
const name = propertyAccess.getName();
|
|
2747
|
-
if (name !== "query" && name !== "execute") continue;
|
|
2748
|
-
const arg = call.getArguments()[0];
|
|
2749
|
-
if (!arg) continue;
|
|
2750
|
-
const isTemplate = arg.getKind() === SyntaxKind3.TemplateExpression;
|
|
2751
|
-
const isConcat = arg.getKind() === SyntaxKind3.BinaryExpression && arg.getText().includes("+");
|
|
2752
|
-
if (!isTemplate && !isConcat) continue;
|
|
2753
|
-
const text = arg.getText().toLowerCase();
|
|
2754
|
-
if (!text.includes("select") && !text.includes("insert") && !text.includes("update") && !text.includes("delete")) {
|
|
2755
|
-
continue;
|
|
2756
|
-
}
|
|
2757
|
-
violations.push(
|
|
2758
|
-
createViolation({
|
|
2759
|
-
decisionId,
|
|
2760
|
-
constraintId: constraint.id,
|
|
2761
|
-
type: constraint.type,
|
|
2762
|
-
severity: constraint.severity,
|
|
2763
|
-
message: "Potential SQL injection: dynamically constructed SQL query",
|
|
2764
|
-
file: filePath,
|
|
2765
|
-
line: call.getStartLineNumber(),
|
|
2766
|
-
suggestion: "Use parameterized queries / prepared statements"
|
|
2767
|
-
})
|
|
2768
|
-
);
|
|
2769
|
-
}
|
|
2770
|
-
}
|
|
2771
|
-
if (checkProto) {
|
|
2772
|
-
const text = sourceFile.getFullText();
|
|
2773
|
-
if (text.includes("__proto__") || text.includes("constructor.prototype")) {
|
|
2913
|
+
name = "Security Verifier";
|
|
2914
|
+
description = "Detects common security footguns (secrets, eval, XSS/SQL injection heuristics)";
|
|
2915
|
+
async verify(ctx) {
|
|
2916
|
+
const violations = [];
|
|
2917
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
2918
|
+
const rule = constraint.rule.toLowerCase();
|
|
2919
|
+
const checkSecrets = rule.includes("secret") || rule.includes("password") || rule.includes("token") || rule.includes("api key") || rule.includes("hardcoded");
|
|
2920
|
+
const checkEval = rule.includes("eval") || rule.includes("function constructor");
|
|
2921
|
+
const checkXss = rule.includes("xss") || rule.includes("innerhtml") || rule.includes("dangerouslysetinnerhtml");
|
|
2922
|
+
const checkSql = rule.includes("sql") || rule.includes("injection");
|
|
2923
|
+
const checkProto = rule.includes("prototype pollution") || rule.includes("__proto__");
|
|
2924
|
+
if (checkSecrets) {
|
|
2925
|
+
for (const vd of sourceFile.getVariableDeclarations()) {
|
|
2926
|
+
const name = vd.getName();
|
|
2927
|
+
if (!SECRET_NAME_RE.test(name)) continue;
|
|
2928
|
+
const init = vd.getInitializer();
|
|
2929
|
+
if (!init || !isStringLiteralLike(init)) continue;
|
|
2930
|
+
const value = init.getText().slice(1, -1);
|
|
2931
|
+
if (value.length === 0) continue;
|
|
2774
2932
|
violations.push(
|
|
2775
2933
|
createViolation({
|
|
2776
2934
|
decisionId,
|
|
2777
2935
|
constraintId: constraint.id,
|
|
2778
2936
|
type: constraint.type,
|
|
2779
2937
|
severity: constraint.severity,
|
|
2780
|
-
message:
|
|
2938
|
+
message: `Possible hardcoded secret in variable "${name}"`,
|
|
2781
2939
|
file: filePath,
|
|
2782
|
-
line:
|
|
2783
|
-
suggestion: "
|
|
2940
|
+
line: vd.getStartLineNumber(),
|
|
2941
|
+
suggestion: "Move secrets to environment variables or a secret manager"
|
|
2784
2942
|
})
|
|
2785
2943
|
);
|
|
2786
2944
|
}
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
// src/verification/verifiers/api.ts
|
|
2793
|
-
import { SyntaxKind as SyntaxKind4 } from "ts-morph";
|
|
2794
|
-
var HTTP_METHODS = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "options", "head", "all"]);
|
|
2795
|
-
function isKebabPath(pathValue) {
|
|
2796
|
-
const parts = pathValue.split("/").filter(Boolean);
|
|
2797
|
-
for (const part of parts) {
|
|
2798
|
-
if (part.startsWith(":")) continue;
|
|
2799
|
-
if (!/^[a-z0-9-]+$/.test(part)) return false;
|
|
2800
|
-
}
|
|
2801
|
-
return true;
|
|
2802
|
-
}
|
|
2803
|
-
var ApiVerifier = class {
|
|
2804
|
-
id = "api";
|
|
2805
|
-
name = "API Consistency Verifier";
|
|
2806
|
-
description = "Checks basic REST endpoint naming conventions in common frameworks";
|
|
2807
|
-
async verify(ctx) {
|
|
2808
|
-
const violations = [];
|
|
2809
|
-
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
2810
|
-
const rule = constraint.rule.toLowerCase();
|
|
2811
|
-
const enforceKebab = rule.includes("kebab") || rule.includes("kebab-case");
|
|
2812
|
-
if (!enforceKebab) return violations;
|
|
2813
|
-
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind4.CallExpression)) {
|
|
2814
|
-
const expr = call.getExpression();
|
|
2815
|
-
const propertyAccess = expr.asKind(SyntaxKind4.PropertyAccessExpression);
|
|
2816
|
-
if (!propertyAccess) continue;
|
|
2817
|
-
const method = propertyAccess.getName();
|
|
2818
|
-
if (!method || !HTTP_METHODS.has(String(method))) continue;
|
|
2819
|
-
const firstArg = call.getArguments()[0];
|
|
2820
|
-
const stringLiteral = firstArg?.asKind(SyntaxKind4.StringLiteral);
|
|
2821
|
-
if (!stringLiteral) continue;
|
|
2822
|
-
const pathValue = stringLiteral.getLiteralValue();
|
|
2823
|
-
if (typeof pathValue !== "string") continue;
|
|
2824
|
-
if (!isKebabPath(pathValue)) {
|
|
2945
|
+
for (const pa of sourceFile.getDescendantsOfKind(SyntaxKind3.PropertyAssignment)) {
|
|
2946
|
+
const propName = pa.getNameNode().getText();
|
|
2947
|
+
if (!SECRET_NAME_RE.test(propName)) continue;
|
|
2948
|
+
const init = pa.getInitializer();
|
|
2949
|
+
if (!init || !isStringLiteralLike(init)) continue;
|
|
2825
2950
|
violations.push(
|
|
2826
2951
|
createViolation({
|
|
2827
2952
|
decisionId,
|
|
2828
2953
|
constraintId: constraint.id,
|
|
2829
2954
|
type: constraint.type,
|
|
2830
2955
|
severity: constraint.severity,
|
|
2831
|
-
message: `
|
|
2956
|
+
message: `Possible hardcoded secret in object property ${propName}`,
|
|
2832
2957
|
file: filePath,
|
|
2833
|
-
line:
|
|
2834
|
-
suggestion: "
|
|
2958
|
+
line: pa.getStartLineNumber(),
|
|
2959
|
+
suggestion: "Move secrets to environment variables or a secret manager"
|
|
2835
2960
|
})
|
|
2836
2961
|
);
|
|
2837
2962
|
}
|
|
2838
2963
|
}
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
* @param basePath - Project root directory (usually cwd)
|
|
2857
|
-
*/
|
|
2858
|
-
async loadPlugins(basePath) {
|
|
2859
|
-
const verifiersDir = join5(basePath, ".specbridge", "verifiers");
|
|
2860
|
-
if (!existsSync(verifiersDir)) {
|
|
2861
|
-
this.loaded = true;
|
|
2862
|
-
return;
|
|
2863
|
-
}
|
|
2864
|
-
const files = await fg2("**/*.{ts,js}", {
|
|
2865
|
-
cwd: verifiersDir,
|
|
2866
|
-
absolute: true,
|
|
2867
|
-
ignore: ["**/*.test.{ts,js}", "**/*.d.ts"]
|
|
2868
|
-
});
|
|
2869
|
-
for (const file of files) {
|
|
2870
|
-
try {
|
|
2871
|
-
await this.loadPlugin(file);
|
|
2872
|
-
} catch (error) {
|
|
2873
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2874
|
-
this.loadErrors.push({ file, error: message });
|
|
2875
|
-
this.logger.warn({ file, error: message }, "Failed to load plugin");
|
|
2964
|
+
if (checkEval) {
|
|
2965
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind3.CallExpression)) {
|
|
2966
|
+
const exprText = call.getExpression().getText();
|
|
2967
|
+
if (exprText === "eval" || exprText === "Function") {
|
|
2968
|
+
violations.push(
|
|
2969
|
+
createViolation({
|
|
2970
|
+
decisionId,
|
|
2971
|
+
constraintId: constraint.id,
|
|
2972
|
+
type: constraint.type,
|
|
2973
|
+
severity: constraint.severity,
|
|
2974
|
+
message: `Unsafe dynamic code execution via ${exprText}()`,
|
|
2975
|
+
file: filePath,
|
|
2976
|
+
line: call.getStartLineNumber(),
|
|
2977
|
+
suggestion: "Avoid eval/Function; use safer alternatives"
|
|
2978
|
+
})
|
|
2979
|
+
);
|
|
2980
|
+
}
|
|
2876
2981
|
}
|
|
2877
2982
|
}
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
throw new Error('Plugin metadata must have a string "id" property');
|
|
2913
|
-
}
|
|
2914
|
-
if (!plugin.metadata.version || typeof plugin.metadata.version !== "string") {
|
|
2915
|
-
throw new Error('Plugin metadata must have a string "version" property');
|
|
2916
|
-
}
|
|
2917
|
-
const idPattern = /^[a-z][a-z0-9-]*$/;
|
|
2918
|
-
if (!idPattern.test(plugin.metadata.id)) {
|
|
2919
|
-
throw new Error(
|
|
2920
|
-
`Plugin ID "${plugin.metadata.id}" is invalid. ID must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.`
|
|
2921
|
-
);
|
|
2922
|
-
}
|
|
2923
|
-
if (typeof plugin.createVerifier !== "function") {
|
|
2924
|
-
throw new Error('Plugin must have a "createVerifier" function');
|
|
2925
|
-
}
|
|
2926
|
-
let verifier;
|
|
2927
|
-
try {
|
|
2928
|
-
verifier = plugin.createVerifier();
|
|
2929
|
-
} catch (error) {
|
|
2930
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2931
|
-
throw new Error(`createVerifier() threw an error: ${message}`);
|
|
2932
|
-
}
|
|
2933
|
-
if (!verifier || typeof verifier !== "object") {
|
|
2934
|
-
throw new Error("createVerifier() must return an object");
|
|
2935
|
-
}
|
|
2936
|
-
if (!verifier.id || typeof verifier.id !== "string") {
|
|
2937
|
-
throw new Error('Verifier must have a string "id" property');
|
|
2938
|
-
}
|
|
2939
|
-
if (!verifier.name || typeof verifier.name !== "string") {
|
|
2940
|
-
throw new Error('Verifier must have a string "name" property');
|
|
2941
|
-
}
|
|
2942
|
-
if (!verifier.description || typeof verifier.description !== "string") {
|
|
2943
|
-
throw new Error('Verifier must have a string "description" property');
|
|
2944
|
-
}
|
|
2945
|
-
if (typeof verifier.verify !== "function") {
|
|
2946
|
-
throw new Error('Verifier must have a "verify" method');
|
|
2947
|
-
}
|
|
2948
|
-
if (verifier.id !== plugin.metadata.id) {
|
|
2949
|
-
throw new Error(
|
|
2950
|
-
`Verifier ID "${verifier.id}" does not match plugin metadata ID "${plugin.metadata.id}". These must be identical.`
|
|
2951
|
-
);
|
|
2952
|
-
}
|
|
2953
|
-
}
|
|
2954
|
-
/**
|
|
2955
|
-
* Get a verifier instance by ID
|
|
2956
|
-
*
|
|
2957
|
-
* @param id - Verifier ID
|
|
2958
|
-
* @returns Verifier instance or null if not found
|
|
2959
|
-
*/
|
|
2960
|
-
getVerifier(id) {
|
|
2961
|
-
const plugin = this.plugins.get(id);
|
|
2962
|
-
return plugin ? plugin.createVerifier() : null;
|
|
2963
|
-
}
|
|
2964
|
-
/**
|
|
2965
|
-
* Get a plugin by ID (includes paramsSchema)
|
|
2966
|
-
*
|
|
2967
|
-
* @param id - Plugin ID
|
|
2968
|
-
* @returns Plugin or null if not found
|
|
2969
|
-
*/
|
|
2970
|
-
getPlugin(id) {
|
|
2971
|
-
return this.plugins.get(id) || null;
|
|
2972
|
-
}
|
|
2973
|
-
/**
|
|
2974
|
-
* Validate params against a plugin's paramsSchema
|
|
2975
|
-
*
|
|
2976
|
-
* @param id - Plugin ID
|
|
2977
|
-
* @param params - Parameters to validate
|
|
2978
|
-
* @returns Validation result with success flag and error message if failed
|
|
2979
|
-
*/
|
|
2980
|
-
validateParams(id, params) {
|
|
2981
|
-
const plugin = this.plugins.get(id);
|
|
2982
|
-
if (!plugin) {
|
|
2983
|
-
return { success: false, error: `Plugin ${id} not found` };
|
|
2984
|
-
}
|
|
2985
|
-
if (!plugin.paramsSchema) {
|
|
2986
|
-
return { success: true };
|
|
2987
|
-
}
|
|
2988
|
-
if (!params) {
|
|
2989
|
-
return { success: false, error: `Plugin ${id} requires params but none were provided` };
|
|
2990
|
-
}
|
|
2991
|
-
if (typeof plugin.paramsSchema !== "object" || !plugin.paramsSchema || !("parse" in plugin.paramsSchema)) {
|
|
2992
|
-
return {
|
|
2993
|
-
success: false,
|
|
2994
|
-
error: `Plugin ${id} has invalid paramsSchema (must be a Zod schema)`
|
|
2995
|
-
};
|
|
2983
|
+
if (checkXss) {
|
|
2984
|
+
for (const bin of sourceFile.getDescendantsOfKind(SyntaxKind3.BinaryExpression)) {
|
|
2985
|
+
const left = bin.getLeft();
|
|
2986
|
+
const propertyAccess = left.asKind(SyntaxKind3.PropertyAccessExpression);
|
|
2987
|
+
if (!propertyAccess) continue;
|
|
2988
|
+
if (propertyAccess.getName() === "innerHTML") {
|
|
2989
|
+
violations.push(
|
|
2990
|
+
createViolation({
|
|
2991
|
+
decisionId,
|
|
2992
|
+
constraintId: constraint.id,
|
|
2993
|
+
type: constraint.type,
|
|
2994
|
+
severity: constraint.severity,
|
|
2995
|
+
message: "Potential XSS: assignment to innerHTML",
|
|
2996
|
+
file: filePath,
|
|
2997
|
+
line: bin.getStartLineNumber(),
|
|
2998
|
+
suggestion: "Prefer textContent or a safe templating/escaping strategy"
|
|
2999
|
+
})
|
|
3000
|
+
);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
if (sourceFile.getFullText().includes("dangerouslySetInnerHTML")) {
|
|
3004
|
+
violations.push(
|
|
3005
|
+
createViolation({
|
|
3006
|
+
decisionId,
|
|
3007
|
+
constraintId: constraint.id,
|
|
3008
|
+
type: constraint.type,
|
|
3009
|
+
severity: constraint.severity,
|
|
3010
|
+
message: "Potential XSS: usage of dangerouslySetInnerHTML",
|
|
3011
|
+
file: filePath,
|
|
3012
|
+
line: 1,
|
|
3013
|
+
suggestion: "Avoid dangerouslySetInnerHTML or ensure content is sanitized"
|
|
3014
|
+
})
|
|
3015
|
+
);
|
|
3016
|
+
}
|
|
2996
3017
|
}
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3018
|
+
if (checkSql) {
|
|
3019
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind3.CallExpression)) {
|
|
3020
|
+
const expr = call.getExpression();
|
|
3021
|
+
const propertyAccess = expr.asKind(SyntaxKind3.PropertyAccessExpression);
|
|
3022
|
+
if (!propertyAccess) continue;
|
|
3023
|
+
const name = propertyAccess.getName();
|
|
3024
|
+
if (name !== "query" && name !== "execute") continue;
|
|
3025
|
+
const arg = call.getArguments()[0];
|
|
3026
|
+
if (!arg) continue;
|
|
3027
|
+
const isTemplate = arg.getKind() === SyntaxKind3.TemplateExpression;
|
|
3028
|
+
const isConcat = arg.getKind() === SyntaxKind3.BinaryExpression && arg.getText().includes("+");
|
|
3029
|
+
if (!isTemplate && !isConcat) continue;
|
|
3030
|
+
const text = arg.getText().toLowerCase();
|
|
3031
|
+
if (!text.includes("select") && !text.includes("insert") && !text.includes("update") && !text.includes("delete")) {
|
|
3032
|
+
continue;
|
|
3033
|
+
}
|
|
3034
|
+
violations.push(
|
|
3035
|
+
createViolation({
|
|
3036
|
+
decisionId,
|
|
3037
|
+
constraintId: constraint.id,
|
|
3038
|
+
type: constraint.type,
|
|
3039
|
+
severity: constraint.severity,
|
|
3040
|
+
message: "Potential SQL injection: dynamically constructed SQL query",
|
|
3041
|
+
file: filePath,
|
|
3042
|
+
line: call.getStartLineNumber(),
|
|
3043
|
+
suggestion: "Use parameterized queries / prepared statements"
|
|
3044
|
+
})
|
|
3045
|
+
);
|
|
3003
3046
|
}
|
|
3004
|
-
}
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3047
|
+
}
|
|
3048
|
+
if (checkProto) {
|
|
3049
|
+
const text = sourceFile.getFullText();
|
|
3050
|
+
if (text.includes("__proto__") || text.includes("constructor.prototype")) {
|
|
3051
|
+
violations.push(
|
|
3052
|
+
createViolation({
|
|
3053
|
+
decisionId,
|
|
3054
|
+
constraintId: constraint.id,
|
|
3055
|
+
type: constraint.type,
|
|
3056
|
+
severity: constraint.severity,
|
|
3057
|
+
message: "Potential prototype pollution pattern detected",
|
|
3058
|
+
file: filePath,
|
|
3059
|
+
line: 1,
|
|
3060
|
+
suggestion: "Avoid writing to __proto__/prototype; validate object keys"
|
|
3061
|
+
})
|
|
3062
|
+
);
|
|
3010
3063
|
}
|
|
3011
3064
|
}
|
|
3012
|
-
return
|
|
3013
|
-
}
|
|
3014
|
-
/**
|
|
3015
|
-
* Get all registered plugin IDs
|
|
3016
|
-
*/
|
|
3017
|
-
getPluginIds() {
|
|
3018
|
-
return Array.from(this.plugins.keys());
|
|
3019
|
-
}
|
|
3020
|
-
/**
|
|
3021
|
-
* Check if plugins have been loaded
|
|
3022
|
-
*/
|
|
3023
|
-
isLoaded() {
|
|
3024
|
-
return this.loaded;
|
|
3025
|
-
}
|
|
3026
|
-
/**
|
|
3027
|
-
* Get load errors (for diagnostics)
|
|
3028
|
-
*/
|
|
3029
|
-
getLoadErrors() {
|
|
3030
|
-
return [...this.loadErrors];
|
|
3031
|
-
}
|
|
3032
|
-
/**
|
|
3033
|
-
* Reset the loader (useful for testing)
|
|
3034
|
-
*/
|
|
3035
|
-
reset() {
|
|
3036
|
-
this.plugins.clear();
|
|
3037
|
-
this.loaded = false;
|
|
3038
|
-
this.loadErrors = [];
|
|
3065
|
+
return violations;
|
|
3039
3066
|
}
|
|
3040
3067
|
};
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3068
|
+
|
|
3069
|
+
// src/verification/verifiers/api.ts
|
|
3070
|
+
import { SyntaxKind as SyntaxKind4 } from "ts-morph";
|
|
3071
|
+
var HTTP_METHODS = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "options", "head", "all"]);
|
|
3072
|
+
function isKebabPath(pathValue) {
|
|
3073
|
+
const parts = pathValue.split("/").filter(Boolean);
|
|
3074
|
+
for (const part of parts) {
|
|
3075
|
+
if (part.startsWith(":")) continue;
|
|
3076
|
+
if (!/^[a-z0-9-]+$/.test(part)) return false;
|
|
3045
3077
|
}
|
|
3046
|
-
return
|
|
3078
|
+
return true;
|
|
3047
3079
|
}
|
|
3080
|
+
var ApiVerifier = class {
|
|
3081
|
+
id = "api";
|
|
3082
|
+
name = "API Consistency Verifier";
|
|
3083
|
+
description = "Checks basic REST endpoint naming conventions in common frameworks";
|
|
3084
|
+
async verify(ctx) {
|
|
3085
|
+
const violations = [];
|
|
3086
|
+
const { sourceFile, constraint, decisionId, filePath } = ctx;
|
|
3087
|
+
const rule = constraint.rule.toLowerCase();
|
|
3088
|
+
const enforceKebab = rule.includes("kebab") || rule.includes("kebab-case");
|
|
3089
|
+
if (!enforceKebab) return violations;
|
|
3090
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind4.CallExpression)) {
|
|
3091
|
+
const expr = call.getExpression();
|
|
3092
|
+
const propertyAccess = expr.asKind(SyntaxKind4.PropertyAccessExpression);
|
|
3093
|
+
if (!propertyAccess) continue;
|
|
3094
|
+
const method = propertyAccess.getName();
|
|
3095
|
+
if (!method || !HTTP_METHODS.has(String(method))) continue;
|
|
3096
|
+
const firstArg = call.getArguments()[0];
|
|
3097
|
+
const stringLiteral = firstArg?.asKind(SyntaxKind4.StringLiteral);
|
|
3098
|
+
if (!stringLiteral) continue;
|
|
3099
|
+
const pathValue = stringLiteral.getLiteralValue();
|
|
3100
|
+
if (typeof pathValue !== "string") continue;
|
|
3101
|
+
if (!isKebabPath(pathValue)) {
|
|
3102
|
+
violations.push(
|
|
3103
|
+
createViolation({
|
|
3104
|
+
decisionId,
|
|
3105
|
+
constraintId: constraint.id,
|
|
3106
|
+
type: constraint.type,
|
|
3107
|
+
severity: constraint.severity,
|
|
3108
|
+
message: `Endpoint path "${pathValue}" is not kebab-case`,
|
|
3109
|
+
file: filePath,
|
|
3110
|
+
line: call.getStartLineNumber(),
|
|
3111
|
+
suggestion: "Use lowercase and hyphens in static path segments (e.g., /user-settings)"
|
|
3112
|
+
})
|
|
3113
|
+
);
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
return violations;
|
|
3117
|
+
}
|
|
3118
|
+
};
|
|
3048
3119
|
|
|
3049
3120
|
// src/verification/verifiers/index.ts
|
|
3050
3121
|
var builtinVerifiers = {
|
|
@@ -3117,51 +3188,216 @@ function selectVerifierForConstraint(rule, specifiedVerifier, check) {
|
|
|
3117
3188
|
return getVerifier("regex");
|
|
3118
3189
|
}
|
|
3119
3190
|
|
|
3120
|
-
// src/verification/
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3191
|
+
// src/verification/file-verifier.ts
|
|
3192
|
+
async function verifySingleFile(options, dependencies) {
|
|
3193
|
+
const { filePath, decisions, severityFilter, cwd, reporter, signal } = options;
|
|
3194
|
+
const { project, astCache, resultsCache, logger: logger2 } = dependencies;
|
|
3195
|
+
const violations = [];
|
|
3196
|
+
const warnings = [];
|
|
3197
|
+
const errors = [];
|
|
3198
|
+
if (signal?.aborted) {
|
|
3199
|
+
return { violations, warnings, errors };
|
|
3200
|
+
}
|
|
3201
|
+
const sourceFile = await astCache.get(filePath, project);
|
|
3202
|
+
if (!sourceFile) {
|
|
3203
|
+
return { violations, warnings, errors };
|
|
3204
|
+
}
|
|
3205
|
+
let fileHash = null;
|
|
3206
|
+
try {
|
|
3207
|
+
const content = await readFile3(filePath, "utf-8");
|
|
3208
|
+
fileHash = createHash2("sha256").update(content).digest("hex");
|
|
3209
|
+
} catch {
|
|
3210
|
+
fileHash = null;
|
|
3211
|
+
}
|
|
3212
|
+
for (const decision of decisions) {
|
|
3213
|
+
for (const constraint of decision.constraints) {
|
|
3214
|
+
if (!shouldApplyConstraintToFile({ filePath, constraint, cwd, severityFilter })) {
|
|
3215
|
+
if (reporter) {
|
|
3216
|
+
reporter.add({
|
|
3217
|
+
file: filePath,
|
|
3218
|
+
decision,
|
|
3219
|
+
constraint,
|
|
3220
|
+
applied: false,
|
|
3221
|
+
reason: "File does not match scope pattern or severity filter"
|
|
3222
|
+
});
|
|
3223
|
+
}
|
|
3224
|
+
continue;
|
|
3131
3225
|
}
|
|
3132
|
-
const
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3226
|
+
const verifier = selectVerifierForConstraint(
|
|
3227
|
+
constraint.rule,
|
|
3228
|
+
constraint.verifier,
|
|
3229
|
+
constraint.check
|
|
3230
|
+
);
|
|
3231
|
+
if (!verifier) {
|
|
3232
|
+
const requestedVerifier = constraint.check?.verifier || constraint.verifier || "auto-detected";
|
|
3233
|
+
logger2.warn(
|
|
3234
|
+
{
|
|
3235
|
+
decisionId: decision.metadata.id,
|
|
3236
|
+
constraintId: constraint.id,
|
|
3237
|
+
requestedVerifier,
|
|
3238
|
+
availableVerifiers: getVerifierIds()
|
|
3239
|
+
},
|
|
3240
|
+
"No verifier found for constraint"
|
|
3241
|
+
);
|
|
3242
|
+
warnings.push({
|
|
3243
|
+
type: "missing_verifier",
|
|
3244
|
+
message: `No verifier found for constraint (requested: ${requestedVerifier})`,
|
|
3245
|
+
decisionId: decision.metadata.id,
|
|
3246
|
+
constraintId: constraint.id,
|
|
3247
|
+
file: filePath
|
|
3248
|
+
});
|
|
3249
|
+
if (reporter) {
|
|
3250
|
+
reporter.add({
|
|
3251
|
+
file: filePath,
|
|
3252
|
+
decision,
|
|
3253
|
+
constraint,
|
|
3254
|
+
applied: false,
|
|
3255
|
+
reason: `No verifier found (requested: ${requestedVerifier})`
|
|
3256
|
+
});
|
|
3257
|
+
}
|
|
3258
|
+
continue;
|
|
3137
3259
|
}
|
|
3138
|
-
let
|
|
3139
|
-
if (
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3260
|
+
let constraintViolations;
|
|
3261
|
+
if (fileHash) {
|
|
3262
|
+
const cacheKey = {
|
|
3263
|
+
filePath,
|
|
3264
|
+
decisionId: decision.metadata.id,
|
|
3265
|
+
constraintId: constraint.id,
|
|
3266
|
+
fileHash
|
|
3267
|
+
};
|
|
3268
|
+
const cached = resultsCache.get(cacheKey);
|
|
3269
|
+
if (cached) {
|
|
3270
|
+
constraintViolations = cached;
|
|
3271
|
+
violations.push(...constraintViolations);
|
|
3272
|
+
if (reporter) {
|
|
3273
|
+
reporter.add({
|
|
3274
|
+
file: filePath,
|
|
3275
|
+
decision,
|
|
3276
|
+
constraint,
|
|
3277
|
+
applied: true,
|
|
3278
|
+
reason: "Constraint matches file scope (cached)",
|
|
3279
|
+
selectedVerifier: verifier.id
|
|
3280
|
+
});
|
|
3281
|
+
}
|
|
3282
|
+
continue;
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
if (constraint.check?.verifier && constraint.check?.params) {
|
|
3286
|
+
const validationResult = getPluginLoader().validateParams(
|
|
3287
|
+
constraint.check.verifier,
|
|
3288
|
+
constraint.check.params
|
|
3289
|
+
);
|
|
3290
|
+
if (!validationResult.success) {
|
|
3291
|
+
warnings.push({
|
|
3292
|
+
type: "invalid_params",
|
|
3293
|
+
message: validationResult.error,
|
|
3294
|
+
decisionId: decision.metadata.id,
|
|
3295
|
+
constraintId: constraint.id,
|
|
3296
|
+
file: filePath
|
|
3297
|
+
});
|
|
3298
|
+
if (reporter) {
|
|
3299
|
+
reporter.add({
|
|
3300
|
+
file: filePath,
|
|
3301
|
+
decision,
|
|
3302
|
+
constraint,
|
|
3303
|
+
applied: false,
|
|
3304
|
+
reason: `Params validation failed: ${validationResult.error}`
|
|
3305
|
+
});
|
|
3306
|
+
}
|
|
3307
|
+
continue;
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
const verificationContext = {
|
|
3311
|
+
filePath,
|
|
3312
|
+
sourceFile,
|
|
3313
|
+
constraint,
|
|
3314
|
+
decisionId: decision.metadata.id,
|
|
3315
|
+
signal
|
|
3316
|
+
};
|
|
3317
|
+
const verificationStart = Date.now();
|
|
3318
|
+
try {
|
|
3319
|
+
constraintViolations = await verifier.verify(verificationContext);
|
|
3320
|
+
violations.push(...constraintViolations);
|
|
3321
|
+
if (fileHash) {
|
|
3322
|
+
resultsCache.set(
|
|
3323
|
+
{
|
|
3324
|
+
filePath,
|
|
3325
|
+
decisionId: decision.metadata.id,
|
|
3326
|
+
constraintId: constraint.id,
|
|
3327
|
+
fileHash
|
|
3328
|
+
},
|
|
3329
|
+
constraintViolations
|
|
3330
|
+
);
|
|
3331
|
+
}
|
|
3332
|
+
if (reporter) {
|
|
3333
|
+
reporter.add({
|
|
3334
|
+
file: filePath,
|
|
3335
|
+
decision,
|
|
3336
|
+
constraint,
|
|
3337
|
+
applied: true,
|
|
3338
|
+
reason: "Constraint matches file scope",
|
|
3339
|
+
selectedVerifier: verifier.id,
|
|
3340
|
+
verifierOutput: {
|
|
3341
|
+
violations: constraintViolations.length,
|
|
3342
|
+
duration: Date.now() - verificationStart
|
|
3343
|
+
}
|
|
3344
|
+
});
|
|
3345
|
+
}
|
|
3346
|
+
} catch (error) {
|
|
3347
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
3348
|
+
const errorStack = error instanceof Error ? error.stack : void 0;
|
|
3349
|
+
logger2.error(
|
|
3350
|
+
{
|
|
3351
|
+
verifierId: verifier.id,
|
|
3352
|
+
filePath,
|
|
3353
|
+
decisionId: decision.metadata.id,
|
|
3354
|
+
constraintId: constraint.id,
|
|
3355
|
+
error: errorMessage,
|
|
3356
|
+
stack: errorStack
|
|
3357
|
+
},
|
|
3358
|
+
"Verifier execution failed"
|
|
3359
|
+
);
|
|
3360
|
+
errors.push({
|
|
3361
|
+
type: "verifier_exception",
|
|
3362
|
+
message: `Verifier '${verifier.id}' failed: ${errorMessage}`,
|
|
3363
|
+
decisionId: decision.metadata.id,
|
|
3364
|
+
constraintId: constraint.id,
|
|
3365
|
+
file: filePath,
|
|
3366
|
+
stack: errorStack
|
|
3367
|
+
});
|
|
3368
|
+
if (reporter) {
|
|
3369
|
+
reporter.add({
|
|
3370
|
+
file: filePath,
|
|
3371
|
+
decision,
|
|
3372
|
+
constraint,
|
|
3373
|
+
applied: true,
|
|
3374
|
+
reason: "Constraint matches file scope",
|
|
3375
|
+
selectedVerifier: verifier.id,
|
|
3376
|
+
verifierOutput: {
|
|
3377
|
+
violations: 0,
|
|
3378
|
+
duration: Date.now() - verificationStart,
|
|
3379
|
+
error: errorMessage
|
|
3380
|
+
}
|
|
3381
|
+
});
|
|
3382
|
+
}
|
|
3143
3383
|
}
|
|
3144
|
-
this.cache.set(filePath, {
|
|
3145
|
-
sourceFile,
|
|
3146
|
-
hash,
|
|
3147
|
-
mtimeMs: stats.mtimeMs
|
|
3148
|
-
});
|
|
3149
|
-
return sourceFile;
|
|
3150
|
-
} catch {
|
|
3151
|
-
return null;
|
|
3152
3384
|
}
|
|
3153
3385
|
}
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3386
|
+
return { violations, warnings, errors };
|
|
3387
|
+
}
|
|
3388
|
+
async function verifyFilesInBatches(options) {
|
|
3389
|
+
const { files, signal, verifyFile, onFileVerified, batchSize = 50 } = options;
|
|
3390
|
+
for (let i = 0; i < files.length; i += batchSize) {
|
|
3391
|
+
if (signal.aborted) {
|
|
3392
|
+
break;
|
|
3393
|
+
}
|
|
3394
|
+
const batch = files.slice(i, i + batchSize);
|
|
3395
|
+
const results = await Promise.all(batch.map((filePath) => verifyFile(filePath)));
|
|
3396
|
+
for (const result of results) {
|
|
3397
|
+
onFileVerified(result);
|
|
3398
|
+
}
|
|
3163
3399
|
}
|
|
3164
|
-
}
|
|
3400
|
+
}
|
|
3165
3401
|
|
|
3166
3402
|
// src/verification/results-cache.ts
|
|
3167
3403
|
var ResultsCache = class {
|
|
@@ -3233,30 +3469,63 @@ var ResultsCache = class {
|
|
|
3233
3469
|
}
|
|
3234
3470
|
};
|
|
3235
3471
|
|
|
3236
|
-
// src/verification/
|
|
3237
|
-
function
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3472
|
+
// src/verification/run-settings.ts
|
|
3473
|
+
function resolveVerificationRunOptions(config, options) {
|
|
3474
|
+
const level = options.level || "full";
|
|
3475
|
+
const levelConfig = config.verification?.levels?.[level];
|
|
3476
|
+
const severityFilter = options.severity || levelConfig?.severity;
|
|
3477
|
+
const timeout = options.timeout || levelConfig?.timeout || 6e4;
|
|
3478
|
+
return {
|
|
3479
|
+
level,
|
|
3480
|
+
specificFiles: options.files,
|
|
3481
|
+
decisionIds: options.decisions,
|
|
3482
|
+
severityFilter,
|
|
3483
|
+
timeout,
|
|
3484
|
+
cwd: options.cwd || process.cwd()
|
|
3485
|
+
};
|
|
3486
|
+
}
|
|
3487
|
+
function selectDecisionsForRun(decisions, decisionIds) {
|
|
3488
|
+
if (!decisionIds || decisionIds.length === 0) {
|
|
3489
|
+
return decisions;
|
|
3490
|
+
}
|
|
3491
|
+
return decisions.filter((decision) => decisionIds.includes(decision.metadata.id));
|
|
3492
|
+
}
|
|
3493
|
+
async function resolveFilesForRun(config, specificFiles, cwd) {
|
|
3494
|
+
if (specificFiles) {
|
|
3495
|
+
return specificFiles;
|
|
3496
|
+
}
|
|
3497
|
+
return glob(config.project.sourceRoots, {
|
|
3498
|
+
cwd,
|
|
3499
|
+
ignore: config.project.exclude,
|
|
3500
|
+
absolute: true
|
|
3247
3501
|
});
|
|
3248
3502
|
}
|
|
3249
|
-
function
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3503
|
+
function createEmptyRunResult(startTime) {
|
|
3504
|
+
return {
|
|
3505
|
+
success: true,
|
|
3506
|
+
violations: [],
|
|
3507
|
+
checked: 0,
|
|
3508
|
+
passed: 0,
|
|
3509
|
+
failed: 0,
|
|
3510
|
+
skipped: 0,
|
|
3511
|
+
duration: Date.now() - startTime,
|
|
3512
|
+
warnings: [],
|
|
3513
|
+
errors: []
|
|
3514
|
+
};
|
|
3515
|
+
}
|
|
3516
|
+
function hasBlockingViolations(violations, level) {
|
|
3517
|
+
return violations.some((violation) => {
|
|
3518
|
+
if (level === "commit") {
|
|
3519
|
+
return violation.type === "invariant" || violation.severity === "critical";
|
|
3520
|
+
}
|
|
3521
|
+
if (level === "pr") {
|
|
3522
|
+
return violation.type === "invariant" || violation.severity === "critical" || violation.severity === "high";
|
|
3523
|
+
}
|
|
3524
|
+
return violation.type === "invariant";
|
|
3525
|
+
});
|
|
3255
3526
|
}
|
|
3256
3527
|
|
|
3257
3528
|
// src/verification/engine.ts
|
|
3258
|
-
import { createHash as createHash2 } from "crypto";
|
|
3259
|
-
import { readFile as readFile3 } from "fs/promises";
|
|
3260
3529
|
var VerificationEngine = class {
|
|
3261
3530
|
registry;
|
|
3262
3531
|
project;
|
|
@@ -3283,333 +3552,90 @@ var VerificationEngine = class {
|
|
|
3283
3552
|
*/
|
|
3284
3553
|
async verify(config, options = {}) {
|
|
3285
3554
|
const startTime = Date.now();
|
|
3286
|
-
const
|
|
3287
|
-
|
|
3288
|
-
files: specificFiles,
|
|
3289
|
-
decisions: decisionIds,
|
|
3290
|
-
cwd = process.cwd()
|
|
3291
|
-
} = options;
|
|
3292
|
-
const levelConfig = config.verification?.levels?.[level];
|
|
3293
|
-
const severityFilter = options.severity || levelConfig?.severity;
|
|
3294
|
-
const timeout = options.timeout || levelConfig?.timeout || 6e4;
|
|
3295
|
-
if (!this.pluginsLoaded) {
|
|
3296
|
-
await getPluginLoader().loadPlugins(cwd);
|
|
3297
|
-
this.pluginsLoaded = true;
|
|
3298
|
-
}
|
|
3555
|
+
const settings = resolveVerificationRunOptions(config, options);
|
|
3556
|
+
await this.ensurePluginsLoaded(settings.cwd);
|
|
3299
3557
|
await this.registry.load();
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
decisions = decisions.filter((d) => decisionIds.includes(d.metadata.id));
|
|
3303
|
-
}
|
|
3304
|
-
const filesToVerify = specificFiles ? specificFiles : await glob(config.project.sourceRoots, {
|
|
3305
|
-
cwd,
|
|
3306
|
-
ignore: config.project.exclude,
|
|
3307
|
-
absolute: true
|
|
3308
|
-
});
|
|
3558
|
+
const decisions = selectDecisionsForRun(this.registry.getActive(), settings.decisionIds);
|
|
3559
|
+
const filesToVerify = await resolveFilesForRun(config, settings.specificFiles, settings.cwd);
|
|
3309
3560
|
if (filesToVerify.length === 0) {
|
|
3310
|
-
return
|
|
3311
|
-
success: true,
|
|
3312
|
-
violations: [],
|
|
3313
|
-
checked: 0,
|
|
3314
|
-
passed: 0,
|
|
3315
|
-
failed: 0,
|
|
3316
|
-
skipped: 0,
|
|
3317
|
-
duration: Date.now() - startTime,
|
|
3318
|
-
warnings: [],
|
|
3319
|
-
errors: []
|
|
3320
|
-
};
|
|
3561
|
+
return createEmptyRunResult(startTime);
|
|
3321
3562
|
}
|
|
3322
|
-
const
|
|
3323
|
-
const allWarnings = [];
|
|
3324
|
-
const allErrors = [];
|
|
3325
|
-
let checked = 0;
|
|
3326
|
-
let passed = 0;
|
|
3327
|
-
let failed = 0;
|
|
3328
|
-
const skipped = 0;
|
|
3563
|
+
const accumulator = this.createAccumulator();
|
|
3329
3564
|
const abortController = new AbortController();
|
|
3330
3565
|
let timeoutHandle = null;
|
|
3331
3566
|
const timeoutPromise = new Promise((resolve2) => {
|
|
3332
3567
|
timeoutHandle = setTimeout(() => {
|
|
3333
3568
|
abortController.abort();
|
|
3334
3569
|
resolve2("timeout");
|
|
3335
|
-
}, timeout);
|
|
3570
|
+
}, settings.timeout);
|
|
3336
3571
|
timeoutHandle.unref();
|
|
3337
3572
|
});
|
|
3338
|
-
const verificationPromise =
|
|
3339
|
-
filesToVerify,
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
} else {
|
|
3353
|
-
passed++;
|
|
3354
|
-
}
|
|
3355
|
-
}
|
|
3356
|
-
);
|
|
3357
|
-
let result;
|
|
3573
|
+
const verificationPromise = verifyFilesInBatches({
|
|
3574
|
+
files: filesToVerify,
|
|
3575
|
+
signal: abortController.signal,
|
|
3576
|
+
verifyFile: (filePath) => this.verifyFile(
|
|
3577
|
+
filePath,
|
|
3578
|
+
decisions,
|
|
3579
|
+
settings.severityFilter,
|
|
3580
|
+
settings.cwd,
|
|
3581
|
+
options.reporter,
|
|
3582
|
+
abortController.signal
|
|
3583
|
+
),
|
|
3584
|
+
onFileVerified: (result) => this.addFileResult(accumulator, result)
|
|
3585
|
+
});
|
|
3586
|
+
let raceResult;
|
|
3358
3587
|
try {
|
|
3359
|
-
|
|
3360
|
-
if (result === "timeout") {
|
|
3361
|
-
return {
|
|
3362
|
-
success: false,
|
|
3363
|
-
violations: allViolations,
|
|
3364
|
-
checked,
|
|
3365
|
-
passed,
|
|
3366
|
-
failed,
|
|
3367
|
-
skipped: filesToVerify.length - checked,
|
|
3368
|
-
duration: timeout,
|
|
3369
|
-
warnings: allWarnings,
|
|
3370
|
-
errors: allErrors
|
|
3371
|
-
};
|
|
3372
|
-
}
|
|
3588
|
+
raceResult = await Promise.race([verificationPromise, timeoutPromise]);
|
|
3373
3589
|
} finally {
|
|
3374
3590
|
if (timeoutHandle) {
|
|
3375
3591
|
clearTimeout(timeoutHandle);
|
|
3376
|
-
timeoutHandle = null;
|
|
3377
3592
|
}
|
|
3378
3593
|
}
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3594
|
+
if (raceResult === "timeout") {
|
|
3595
|
+
return {
|
|
3596
|
+
success: false,
|
|
3597
|
+
violations: accumulator.violations,
|
|
3598
|
+
checked: accumulator.checked,
|
|
3599
|
+
passed: accumulator.passed,
|
|
3600
|
+
failed: accumulator.failed,
|
|
3601
|
+
skipped: filesToVerify.length - accumulator.checked,
|
|
3602
|
+
duration: settings.timeout,
|
|
3603
|
+
warnings: accumulator.warnings,
|
|
3604
|
+
errors: accumulator.errors
|
|
3605
|
+
};
|
|
3606
|
+
}
|
|
3388
3607
|
return {
|
|
3389
|
-
success: !hasBlockingViolations,
|
|
3390
|
-
violations:
|
|
3391
|
-
checked,
|
|
3392
|
-
passed,
|
|
3393
|
-
failed,
|
|
3394
|
-
skipped,
|
|
3608
|
+
success: !hasBlockingViolations(accumulator.violations, settings.level),
|
|
3609
|
+
violations: accumulator.violations,
|
|
3610
|
+
checked: accumulator.checked,
|
|
3611
|
+
passed: accumulator.passed,
|
|
3612
|
+
failed: accumulator.failed,
|
|
3613
|
+
skipped: 0,
|
|
3395
3614
|
duration: Date.now() - startTime,
|
|
3396
|
-
warnings:
|
|
3397
|
-
errors:
|
|
3615
|
+
warnings: accumulator.warnings,
|
|
3616
|
+
errors: accumulator.errors
|
|
3398
3617
|
};
|
|
3399
3618
|
}
|
|
3400
3619
|
/**
|
|
3401
3620
|
* Verify a single file
|
|
3402
3621
|
*/
|
|
3403
3622
|
async verifyFile(filePath, decisions, severityFilter, cwd = process.cwd(), reporter, signal) {
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
}
|
|
3419
|
-
for (const decision of decisions) {
|
|
3420
|
-
for (const constraint of decision.constraints) {
|
|
3421
|
-
if (!shouldApplyConstraintToFile({ filePath, constraint, cwd, severityFilter })) {
|
|
3422
|
-
if (reporter) {
|
|
3423
|
-
reporter.add({
|
|
3424
|
-
file: filePath,
|
|
3425
|
-
decision,
|
|
3426
|
-
constraint,
|
|
3427
|
-
applied: false,
|
|
3428
|
-
reason: "File does not match scope pattern or severity filter"
|
|
3429
|
-
});
|
|
3430
|
-
}
|
|
3431
|
-
continue;
|
|
3432
|
-
}
|
|
3433
|
-
const verifier = selectVerifierForConstraint(
|
|
3434
|
-
constraint.rule,
|
|
3435
|
-
constraint.verifier,
|
|
3436
|
-
constraint.check
|
|
3437
|
-
);
|
|
3438
|
-
if (!verifier) {
|
|
3439
|
-
const requestedVerifier = constraint.check?.verifier || constraint.verifier || "auto-detected";
|
|
3440
|
-
this.logger.warn(
|
|
3441
|
-
{
|
|
3442
|
-
decisionId: decision.metadata.id,
|
|
3443
|
-
constraintId: constraint.id,
|
|
3444
|
-
requestedVerifier,
|
|
3445
|
-
availableVerifiers: getVerifierIds()
|
|
3446
|
-
},
|
|
3447
|
-
"No verifier found for constraint"
|
|
3448
|
-
);
|
|
3449
|
-
warnings.push({
|
|
3450
|
-
type: "missing_verifier",
|
|
3451
|
-
message: `No verifier found for constraint (requested: ${requestedVerifier})`,
|
|
3452
|
-
decisionId: decision.metadata.id,
|
|
3453
|
-
constraintId: constraint.id,
|
|
3454
|
-
file: filePath
|
|
3455
|
-
});
|
|
3456
|
-
if (reporter) {
|
|
3457
|
-
reporter.add({
|
|
3458
|
-
file: filePath,
|
|
3459
|
-
decision,
|
|
3460
|
-
constraint,
|
|
3461
|
-
applied: false,
|
|
3462
|
-
reason: `No verifier found (requested: ${requestedVerifier})`
|
|
3463
|
-
});
|
|
3464
|
-
}
|
|
3465
|
-
continue;
|
|
3466
|
-
}
|
|
3467
|
-
let constraintViolations;
|
|
3468
|
-
if (fileHash) {
|
|
3469
|
-
const cacheKey = {
|
|
3470
|
-
filePath,
|
|
3471
|
-
decisionId: decision.metadata.id,
|
|
3472
|
-
constraintId: constraint.id,
|
|
3473
|
-
fileHash
|
|
3474
|
-
};
|
|
3475
|
-
const cached = this.resultsCache.get(cacheKey);
|
|
3476
|
-
if (cached) {
|
|
3477
|
-
constraintViolations = cached;
|
|
3478
|
-
violations.push(...constraintViolations);
|
|
3479
|
-
if (reporter) {
|
|
3480
|
-
reporter.add({
|
|
3481
|
-
file: filePath,
|
|
3482
|
-
decision,
|
|
3483
|
-
constraint,
|
|
3484
|
-
applied: true,
|
|
3485
|
-
reason: "Constraint matches file scope (cached)",
|
|
3486
|
-
selectedVerifier: verifier.id
|
|
3487
|
-
});
|
|
3488
|
-
}
|
|
3489
|
-
continue;
|
|
3490
|
-
}
|
|
3491
|
-
}
|
|
3492
|
-
if (constraint.check?.verifier && constraint.check?.params) {
|
|
3493
|
-
const pluginLoader2 = getPluginLoader();
|
|
3494
|
-
const validationResult = pluginLoader2.validateParams(
|
|
3495
|
-
constraint.check.verifier,
|
|
3496
|
-
constraint.check.params
|
|
3497
|
-
);
|
|
3498
|
-
if (!validationResult.success) {
|
|
3499
|
-
warnings.push({
|
|
3500
|
-
type: "invalid_params",
|
|
3501
|
-
message: validationResult.error,
|
|
3502
|
-
decisionId: decision.metadata.id,
|
|
3503
|
-
constraintId: constraint.id,
|
|
3504
|
-
file: filePath
|
|
3505
|
-
});
|
|
3506
|
-
if (reporter) {
|
|
3507
|
-
reporter.add({
|
|
3508
|
-
file: filePath,
|
|
3509
|
-
decision,
|
|
3510
|
-
constraint,
|
|
3511
|
-
applied: false,
|
|
3512
|
-
reason: `Params validation failed: ${validationResult.error}`
|
|
3513
|
-
});
|
|
3514
|
-
}
|
|
3515
|
-
continue;
|
|
3516
|
-
}
|
|
3517
|
-
}
|
|
3518
|
-
const ctx = {
|
|
3519
|
-
filePath,
|
|
3520
|
-
sourceFile,
|
|
3521
|
-
constraint,
|
|
3522
|
-
decisionId: decision.metadata.id,
|
|
3523
|
-
signal
|
|
3524
|
-
};
|
|
3525
|
-
const verificationStart = Date.now();
|
|
3526
|
-
try {
|
|
3527
|
-
constraintViolations = await verifier.verify(ctx);
|
|
3528
|
-
violations.push(...constraintViolations);
|
|
3529
|
-
if (fileHash) {
|
|
3530
|
-
this.resultsCache.set(
|
|
3531
|
-
{
|
|
3532
|
-
filePath,
|
|
3533
|
-
decisionId: decision.metadata.id,
|
|
3534
|
-
constraintId: constraint.id,
|
|
3535
|
-
fileHash
|
|
3536
|
-
},
|
|
3537
|
-
constraintViolations
|
|
3538
|
-
);
|
|
3539
|
-
}
|
|
3540
|
-
if (reporter) {
|
|
3541
|
-
reporter.add({
|
|
3542
|
-
file: filePath,
|
|
3543
|
-
decision,
|
|
3544
|
-
constraint,
|
|
3545
|
-
applied: true,
|
|
3546
|
-
reason: "Constraint matches file scope",
|
|
3547
|
-
selectedVerifier: verifier.id,
|
|
3548
|
-
verifierOutput: {
|
|
3549
|
-
violations: constraintViolations.length,
|
|
3550
|
-
duration: Date.now() - verificationStart
|
|
3551
|
-
}
|
|
3552
|
-
});
|
|
3553
|
-
}
|
|
3554
|
-
} catch (error) {
|
|
3555
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
3556
|
-
const errorStack = error instanceof Error ? error.stack : void 0;
|
|
3557
|
-
this.logger.error(
|
|
3558
|
-
{
|
|
3559
|
-
verifierId: verifier.id,
|
|
3560
|
-
filePath,
|
|
3561
|
-
decisionId: decision.metadata.id,
|
|
3562
|
-
constraintId: constraint.id,
|
|
3563
|
-
error: errorMessage,
|
|
3564
|
-
stack: errorStack
|
|
3565
|
-
},
|
|
3566
|
-
"Verifier execution failed"
|
|
3567
|
-
);
|
|
3568
|
-
errors.push({
|
|
3569
|
-
type: "verifier_exception",
|
|
3570
|
-
message: `Verifier '${verifier.id}' failed: ${errorMessage}`,
|
|
3571
|
-
decisionId: decision.metadata.id,
|
|
3572
|
-
constraintId: constraint.id,
|
|
3573
|
-
file: filePath,
|
|
3574
|
-
stack: errorStack
|
|
3575
|
-
});
|
|
3576
|
-
if (reporter) {
|
|
3577
|
-
reporter.add({
|
|
3578
|
-
file: filePath,
|
|
3579
|
-
decision,
|
|
3580
|
-
constraint,
|
|
3581
|
-
applied: true,
|
|
3582
|
-
reason: "Constraint matches file scope",
|
|
3583
|
-
selectedVerifier: verifier.id,
|
|
3584
|
-
verifierOutput: {
|
|
3585
|
-
violations: 0,
|
|
3586
|
-
duration: Date.now() - verificationStart,
|
|
3587
|
-
error: errorMessage
|
|
3588
|
-
}
|
|
3589
|
-
});
|
|
3590
|
-
}
|
|
3591
|
-
}
|
|
3592
|
-
}
|
|
3593
|
-
}
|
|
3594
|
-
return { violations, warnings, errors };
|
|
3595
|
-
}
|
|
3596
|
-
/**
|
|
3597
|
-
* Verify multiple files
|
|
3598
|
-
*/
|
|
3599
|
-
async verifyFiles(files, decisions, severityFilter, cwd, reporter, signal, onFileVerified) {
|
|
3600
|
-
const BATCH_SIZE = 50;
|
|
3601
|
-
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
3602
|
-
if (signal.aborted) {
|
|
3603
|
-
break;
|
|
3604
|
-
}
|
|
3605
|
-
const batch = files.slice(i, i + BATCH_SIZE);
|
|
3606
|
-
const results = await Promise.all(
|
|
3607
|
-
batch.map((file) => this.verifyFile(file, decisions, severityFilter, cwd, reporter, signal))
|
|
3608
|
-
);
|
|
3609
|
-
for (const result of results) {
|
|
3610
|
-
onFileVerified(result.violations, result.warnings, result.errors);
|
|
3623
|
+
return verifySingleFile(
|
|
3624
|
+
{
|
|
3625
|
+
filePath,
|
|
3626
|
+
decisions,
|
|
3627
|
+
severityFilter,
|
|
3628
|
+
cwd,
|
|
3629
|
+
reporter,
|
|
3630
|
+
signal
|
|
3631
|
+
},
|
|
3632
|
+
{
|
|
3633
|
+
project: this.project,
|
|
3634
|
+
astCache: this.astCache,
|
|
3635
|
+
resultsCache: this.resultsCache,
|
|
3636
|
+
logger: this.logger
|
|
3611
3637
|
}
|
|
3612
|
-
|
|
3638
|
+
);
|
|
3613
3639
|
}
|
|
3614
3640
|
/**
|
|
3615
3641
|
* Get registry
|
|
@@ -3617,6 +3643,34 @@ var VerificationEngine = class {
|
|
|
3617
3643
|
getRegistry() {
|
|
3618
3644
|
return this.registry;
|
|
3619
3645
|
}
|
|
3646
|
+
async ensurePluginsLoaded(cwd) {
|
|
3647
|
+
if (this.pluginsLoaded) {
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3650
|
+
await getPluginLoader().loadPlugins(cwd);
|
|
3651
|
+
this.pluginsLoaded = true;
|
|
3652
|
+
}
|
|
3653
|
+
createAccumulator() {
|
|
3654
|
+
return {
|
|
3655
|
+
violations: [],
|
|
3656
|
+
warnings: [],
|
|
3657
|
+
errors: [],
|
|
3658
|
+
checked: 0,
|
|
3659
|
+
passed: 0,
|
|
3660
|
+
failed: 0
|
|
3661
|
+
};
|
|
3662
|
+
}
|
|
3663
|
+
addFileResult(accumulator, result) {
|
|
3664
|
+
accumulator.violations.push(...result.violations);
|
|
3665
|
+
accumulator.warnings.push(...result.warnings);
|
|
3666
|
+
accumulator.errors.push(...result.errors);
|
|
3667
|
+
accumulator.checked++;
|
|
3668
|
+
if (result.violations.length > 0) {
|
|
3669
|
+
accumulator.failed++;
|
|
3670
|
+
} else {
|
|
3671
|
+
accumulator.passed++;
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3620
3674
|
};
|
|
3621
3675
|
function createVerificationEngine(registry) {
|
|
3622
3676
|
return new VerificationEngine(registry);
|
|
@@ -5382,12 +5436,11 @@ async function generateFormattedContext(filePath, config, options = {}) {
|
|
|
5382
5436
|
|
|
5383
5437
|
// src/cli/commands/context.ts
|
|
5384
5438
|
var contextCommand = new Command11("context").description("Generate architectural context for a file (for AI agents)").argument("<file>", "File path to generate context for").option("-f, --format <format>", "Output format (markdown, json, mcp)", "markdown").option("-o, --output <file>", "Output file path").option("--no-rationale", "Exclude rationale/summary from output").action(async (file, options) => {
|
|
5385
|
-
const cwd = process.cwd();
|
|
5386
|
-
if (!await pathExists(getSpecBridgeDir(cwd))) {
|
|
5387
|
-
throw new NotInitializedError();
|
|
5388
|
-
}
|
|
5389
5439
|
try {
|
|
5390
|
-
const config = await
|
|
5440
|
+
const { context, config } = await createConfiguredCommandContext({
|
|
5441
|
+
outputFormat: options.format === "json" ? "json" : "markdown"
|
|
5442
|
+
});
|
|
5443
|
+
const { cwd } = context;
|
|
5391
5444
|
const output = await generateFormattedContext(file, config, {
|
|
5392
5445
|
format: options.format,
|
|
5393
5446
|
includeRationale: options.rationale !== false,
|
|
@@ -5641,11 +5694,8 @@ import chalk14 from "chalk";
|
|
|
5641
5694
|
import chokidar from "chokidar";
|
|
5642
5695
|
import path4 from "path";
|
|
5643
5696
|
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) => {
|
|
5644
|
-
const
|
|
5645
|
-
|
|
5646
|
-
throw new NotInitializedError();
|
|
5647
|
-
}
|
|
5648
|
-
const config = await loadConfig(cwd);
|
|
5697
|
+
const { context, config } = await createConfiguredCommandContext();
|
|
5698
|
+
const { cwd } = context;
|
|
5649
5699
|
const engine = createVerificationEngine();
|
|
5650
5700
|
const level = options.level || "full";
|
|
5651
5701
|
const debounceMs = Number.parseInt(options.debounce || "150", 10);
|
|
@@ -6183,10 +6233,10 @@ var AnalyticsEngine = class {
|
|
|
6183
6233
|
|
|
6184
6234
|
// src/cli/commands/analytics.ts
|
|
6185
6235
|
var analyticsCommand = new Command16("analytics").description("Analyze compliance trends and decision impact").argument("[decision-id]", "Specific decision to analyze").option("--insights", "Show AI-generated insights").option("--days <n>", "Number of days of history to analyze", "90").option("-f, --format <format>", "Output format (console, json)", "console").action(async (decisionId, options) => {
|
|
6186
|
-
const
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
}
|
|
6236
|
+
const { context } = await createConfiguredCommandContext({
|
|
6237
|
+
outputFormat: options.format === "json" ? "json" : "console"
|
|
6238
|
+
});
|
|
6239
|
+
const { cwd } = context;
|
|
6190
6240
|
const spinner = ora7("Analyzing compliance data...").start();
|
|
6191
6241
|
try {
|
|
6192
6242
|
const storage = new ReportStorage(cwd);
|
|
@@ -6639,13 +6689,10 @@ function createDashboardServer(options) {
|
|
|
6639
6689
|
|
|
6640
6690
|
// src/cli/commands/dashboard.ts
|
|
6641
6691
|
var dashboardCommand = new Command17("dashboard").description("Start compliance dashboard web server").option("-p, --port <port>", "Port to listen on", "3000").option("-h, --host <host>", "Host to bind to", "localhost").action(async (options) => {
|
|
6642
|
-
const cwd = process.cwd();
|
|
6643
|
-
if (!await pathExists(getSpecBridgeDir(cwd))) {
|
|
6644
|
-
throw new NotInitializedError();
|
|
6645
|
-
}
|
|
6646
6692
|
console.log(chalk16.blue("Starting SpecBridge dashboard..."));
|
|
6647
6693
|
try {
|
|
6648
|
-
const config = await
|
|
6694
|
+
const { context, config } = await createConfiguredCommandContext();
|
|
6695
|
+
const { cwd } = context;
|
|
6649
6696
|
const server = createDashboardServer({ cwd, config });
|
|
6650
6697
|
await server.start();
|
|
6651
6698
|
const port = parseInt(options.port || "3000", 10);
|
|
@@ -6891,13 +6938,12 @@ function createPropagationEngine(registry) {
|
|
|
6891
6938
|
|
|
6892
6939
|
// src/cli/commands/impact.ts
|
|
6893
6940
|
var impactCommand = new Command18("impact").description("Analyze impact of decision changes").argument("<decision-id>", "Decision ID to analyze").option("-c, --change <type>", "Type of change (created, modified, deprecated)", "modified").option("--json", "Output as JSON").option("--show-steps", "Show detailed migration steps", true).action(async (decisionId, options) => {
|
|
6894
|
-
const cwd = process.cwd();
|
|
6895
|
-
if (!await pathExists(getSpecBridgeDir(cwd))) {
|
|
6896
|
-
throw new NotInitializedError();
|
|
6897
|
-
}
|
|
6898
6941
|
const spinner = ora8("Loading configuration...").start();
|
|
6899
6942
|
try {
|
|
6900
|
-
const config = await
|
|
6943
|
+
const { context, config } = await createConfiguredCommandContext({
|
|
6944
|
+
outputFormat: options.json ? "json" : "console"
|
|
6945
|
+
});
|
|
6946
|
+
const { cwd } = context;
|
|
6901
6947
|
const changeType = options.change || "modified";
|
|
6902
6948
|
if (!["created", "modified", "deprecated"].includes(changeType)) {
|
|
6903
6949
|
spinner.fail();
|