@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/dist/index.js CHANGED
@@ -1626,6 +1626,308 @@ async function runInference(config, options) {
1626
1626
  // src/verification/engine.ts
1627
1627
  import { Project as Project2 } from "ts-morph";
1628
1628
 
1629
+ // src/verification/cache.ts
1630
+ import { stat as stat2, readFile as readFile2 } from "fs/promises";
1631
+ import { createHash } from "crypto";
1632
+ var AstCache = class {
1633
+ cache = /* @__PURE__ */ new Map();
1634
+ async get(filePath, project) {
1635
+ try {
1636
+ const stats = await stat2(filePath);
1637
+ const cached = this.cache.get(filePath);
1638
+ if (cached && cached.mtimeMs >= stats.mtimeMs) {
1639
+ return cached.sourceFile;
1640
+ }
1641
+ const content = await readFile2(filePath, "utf-8");
1642
+ const hash = createHash("sha256").update(content).digest("hex");
1643
+ if (cached && cached.hash === hash) {
1644
+ cached.mtimeMs = stats.mtimeMs;
1645
+ return cached.sourceFile;
1646
+ }
1647
+ let sourceFile = project.getSourceFile(filePath);
1648
+ if (!sourceFile) {
1649
+ sourceFile = project.addSourceFileAtPath(filePath);
1650
+ } else {
1651
+ sourceFile.refreshFromFileSystemSync();
1652
+ }
1653
+ this.cache.set(filePath, {
1654
+ sourceFile,
1655
+ hash,
1656
+ mtimeMs: stats.mtimeMs
1657
+ });
1658
+ return sourceFile;
1659
+ } catch {
1660
+ return null;
1661
+ }
1662
+ }
1663
+ clear() {
1664
+ this.cache.clear();
1665
+ }
1666
+ getStats() {
1667
+ return {
1668
+ entries: this.cache.size,
1669
+ memoryEstimate: this.cache.size * 5e4
1670
+ // Rough estimate: 50KB per AST
1671
+ };
1672
+ }
1673
+ };
1674
+
1675
+ // src/verification/file-verifier.ts
1676
+ import { createHash as createHash2 } from "crypto";
1677
+ import { readFile as readFile3 } from "fs/promises";
1678
+
1679
+ // src/verification/applicability.ts
1680
+ function isConstraintExcepted(filePath, constraint, cwd) {
1681
+ if (!constraint.exceptions) return false;
1682
+ return constraint.exceptions.some((exception) => {
1683
+ if (exception.expiresAt) {
1684
+ const expiryDate = new Date(exception.expiresAt);
1685
+ if (expiryDate < /* @__PURE__ */ new Date()) {
1686
+ return false;
1687
+ }
1688
+ }
1689
+ return matchesPattern(filePath, exception.pattern, { cwd });
1690
+ });
1691
+ }
1692
+ function shouldApplyConstraintToFile(params) {
1693
+ const { filePath, constraint, cwd, severityFilter } = params;
1694
+ if (!matchesPattern(filePath, constraint.scope, { cwd })) return false;
1695
+ if (severityFilter && !severityFilter.includes(constraint.severity)) return false;
1696
+ if (isConstraintExcepted(filePath, constraint, cwd)) return false;
1697
+ return true;
1698
+ }
1699
+
1700
+ // src/verification/plugins/loader.ts
1701
+ import { existsSync } from "fs";
1702
+ import { join as join3 } from "path";
1703
+ import { pathToFileURL } from "url";
1704
+ import fg2 from "fast-glob";
1705
+
1706
+ // src/utils/logger.ts
1707
+ import pino from "pino";
1708
+ var defaultOptions = {
1709
+ level: process.env.SPECBRIDGE_LOG_LEVEL || "info",
1710
+ timestamp: pino.stdTimeFunctions.isoTime,
1711
+ base: {
1712
+ service: "specbridge"
1713
+ }
1714
+ };
1715
+ var destination = pino.destination({
1716
+ fd: 2,
1717
+ // stderr
1718
+ sync: false
1719
+ });
1720
+ var rootLogger = pino(defaultOptions, destination);
1721
+ function getLogger(bindings) {
1722
+ if (!bindings) {
1723
+ return rootLogger;
1724
+ }
1725
+ return rootLogger.child(bindings);
1726
+ }
1727
+ var logger = getLogger();
1728
+
1729
+ // src/verification/plugins/loader.ts
1730
+ var PluginLoader = class {
1731
+ plugins = /* @__PURE__ */ new Map();
1732
+ loaded = false;
1733
+ loadErrors = [];
1734
+ logger = getLogger({ module: "verification.plugins.loader" });
1735
+ /**
1736
+ * Load all plugins from the specified base path
1737
+ *
1738
+ * @param basePath - Project root directory (usually cwd)
1739
+ */
1740
+ async loadPlugins(basePath) {
1741
+ const verifiersDir = join3(basePath, ".specbridge", "verifiers");
1742
+ if (!existsSync(verifiersDir)) {
1743
+ this.loaded = true;
1744
+ return;
1745
+ }
1746
+ const files = await fg2("**/*.{ts,js}", {
1747
+ cwd: verifiersDir,
1748
+ absolute: true,
1749
+ ignore: ["**/*.test.{ts,js}", "**/*.d.ts"]
1750
+ });
1751
+ for (const file of files) {
1752
+ try {
1753
+ await this.loadPlugin(file);
1754
+ } catch (error) {
1755
+ const message = error instanceof Error ? error.message : String(error);
1756
+ this.loadErrors.push({ file, error: message });
1757
+ this.logger.warn({ file, error: message }, "Failed to load plugin");
1758
+ }
1759
+ }
1760
+ this.loaded = true;
1761
+ if (this.plugins.size > 0) {
1762
+ this.logger.info({ count: this.plugins.size }, "Loaded custom verifier plugins");
1763
+ }
1764
+ if (this.loadErrors.length > 0) {
1765
+ this.logger.warn({ count: this.loadErrors.length }, "Plugin load failures");
1766
+ }
1767
+ }
1768
+ /**
1769
+ * Load a single plugin file
1770
+ */
1771
+ async loadPlugin(filePath) {
1772
+ const fileUrl = pathToFileURL(filePath).href;
1773
+ const module = await import(fileUrl);
1774
+ const plugin = module.default || module.plugin;
1775
+ if (!plugin) {
1776
+ throw new Error('Plugin must export a default or named "plugin" export');
1777
+ }
1778
+ this.validatePlugin(plugin, filePath);
1779
+ if (this.plugins.has(plugin.metadata.id)) {
1780
+ throw new Error(
1781
+ `Plugin ID "${plugin.metadata.id}" is already registered. Each plugin must have a unique ID.`
1782
+ );
1783
+ }
1784
+ this.plugins.set(plugin.metadata.id, plugin);
1785
+ }
1786
+ /**
1787
+ * Validate plugin structure and metadata
1788
+ */
1789
+ validatePlugin(plugin, _filePath) {
1790
+ if (!plugin.metadata) {
1791
+ throw new Error('Plugin must have a "metadata" property');
1792
+ }
1793
+ if (!plugin.metadata.id || typeof plugin.metadata.id !== "string") {
1794
+ throw new Error('Plugin metadata must have a string "id" property');
1795
+ }
1796
+ if (!plugin.metadata.version || typeof plugin.metadata.version !== "string") {
1797
+ throw new Error('Plugin metadata must have a string "version" property');
1798
+ }
1799
+ const idPattern = /^[a-z][a-z0-9-]*$/;
1800
+ if (!idPattern.test(plugin.metadata.id)) {
1801
+ throw new Error(
1802
+ `Plugin ID "${plugin.metadata.id}" is invalid. ID must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.`
1803
+ );
1804
+ }
1805
+ if (typeof plugin.createVerifier !== "function") {
1806
+ throw new Error('Plugin must have a "createVerifier" function');
1807
+ }
1808
+ let verifier;
1809
+ try {
1810
+ verifier = plugin.createVerifier();
1811
+ } catch (error) {
1812
+ const message = error instanceof Error ? error.message : String(error);
1813
+ throw new Error(`createVerifier() threw an error: ${message}`);
1814
+ }
1815
+ if (!verifier || typeof verifier !== "object") {
1816
+ throw new Error("createVerifier() must return an object");
1817
+ }
1818
+ if (!verifier.id || typeof verifier.id !== "string") {
1819
+ throw new Error('Verifier must have a string "id" property');
1820
+ }
1821
+ if (!verifier.name || typeof verifier.name !== "string") {
1822
+ throw new Error('Verifier must have a string "name" property');
1823
+ }
1824
+ if (!verifier.description || typeof verifier.description !== "string") {
1825
+ throw new Error('Verifier must have a string "description" property');
1826
+ }
1827
+ if (typeof verifier.verify !== "function") {
1828
+ throw new Error('Verifier must have a "verify" method');
1829
+ }
1830
+ if (verifier.id !== plugin.metadata.id) {
1831
+ throw new Error(
1832
+ `Verifier ID "${verifier.id}" does not match plugin metadata ID "${plugin.metadata.id}". These must be identical.`
1833
+ );
1834
+ }
1835
+ }
1836
+ /**
1837
+ * Get a verifier instance by ID
1838
+ *
1839
+ * @param id - Verifier ID
1840
+ * @returns Verifier instance or null if not found
1841
+ */
1842
+ getVerifier(id) {
1843
+ const plugin = this.plugins.get(id);
1844
+ return plugin ? plugin.createVerifier() : null;
1845
+ }
1846
+ /**
1847
+ * Get a plugin by ID (includes paramsSchema)
1848
+ *
1849
+ * @param id - Plugin ID
1850
+ * @returns Plugin or null if not found
1851
+ */
1852
+ getPlugin(id) {
1853
+ return this.plugins.get(id) || null;
1854
+ }
1855
+ /**
1856
+ * Validate params against a plugin's paramsSchema
1857
+ *
1858
+ * @param id - Plugin ID
1859
+ * @param params - Parameters to validate
1860
+ * @returns Validation result with success flag and error message if failed
1861
+ */
1862
+ validateParams(id, params) {
1863
+ const plugin = this.plugins.get(id);
1864
+ if (!plugin) {
1865
+ return { success: false, error: `Plugin ${id} not found` };
1866
+ }
1867
+ if (!plugin.paramsSchema) {
1868
+ return { success: true };
1869
+ }
1870
+ if (!params) {
1871
+ return { success: false, error: `Plugin ${id} requires params but none were provided` };
1872
+ }
1873
+ if (typeof plugin.paramsSchema !== "object" || !plugin.paramsSchema || !("parse" in plugin.paramsSchema)) {
1874
+ return {
1875
+ success: false,
1876
+ error: `Plugin ${id} has invalid paramsSchema (must be a Zod schema)`
1877
+ };
1878
+ }
1879
+ const schema = plugin.paramsSchema;
1880
+ if (schema.safeParse) {
1881
+ const result = schema.safeParse(params);
1882
+ if (!result.success) {
1883
+ const errors = result.error?.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(", ") || "Validation failed";
1884
+ return { success: false, error: `Invalid params for ${id}: ${errors}` };
1885
+ }
1886
+ } else {
1887
+ try {
1888
+ schema.parse(params);
1889
+ } catch (error) {
1890
+ const message = error instanceof Error ? error.message : String(error);
1891
+ return { success: false, error: `Invalid params for ${id}: ${message}` };
1892
+ }
1893
+ }
1894
+ return { success: true };
1895
+ }
1896
+ /**
1897
+ * Get all registered plugin IDs
1898
+ */
1899
+ getPluginIds() {
1900
+ return Array.from(this.plugins.keys());
1901
+ }
1902
+ /**
1903
+ * Check if plugins have been loaded
1904
+ */
1905
+ isLoaded() {
1906
+ return this.loaded;
1907
+ }
1908
+ /**
1909
+ * Get load errors (for diagnostics)
1910
+ */
1911
+ getLoadErrors() {
1912
+ return [...this.loadErrors];
1913
+ }
1914
+ /**
1915
+ * Reset the loader (useful for testing)
1916
+ */
1917
+ reset() {
1918
+ this.plugins.clear();
1919
+ this.loaded = false;
1920
+ this.loadErrors = [];
1921
+ }
1922
+ };
1923
+ var pluginLoader = null;
1924
+ function getPluginLoader() {
1925
+ if (!pluginLoader) {
1926
+ pluginLoader = new PluginLoader();
1927
+ }
1928
+ return pluginLoader;
1929
+ }
1930
+
1629
1931
  // src/verification/verifiers/base.ts
1630
1932
  function defineVerifierPlugin(plugin) {
1631
1933
  return plugin;
@@ -2464,467 +2766,236 @@ var ComplexityVerifier = class {
2464
2766
  createViolation({
2465
2767
  decisionId,
2466
2768
  constraintId: constraint.id,
2467
- type: constraint.type,
2468
- severity: constraint.severity,
2469
- message: `Function ${fnName} has nesting depth ${depth} which exceeds maximum ${maxNesting}`,
2470
- file: filePath,
2471
- line: fn.getStartLineNumber(),
2472
- suggestion: "Reduce nesting by using early returns or extracting functions"
2473
- })
2474
- );
2475
- }
2476
- }
2477
- }
2478
- return violations;
2479
- }
2480
- };
2481
-
2482
- // src/verification/verifiers/security.ts
2483
- import { SyntaxKind as SyntaxKind3 } from "ts-morph";
2484
- var SECRET_NAME_RE = /(api[_-]?key|password|secret|token)/i;
2485
- function isStringLiteralLike(node) {
2486
- const k = node.getKind();
2487
- return k === SyntaxKind3.StringLiteral || k === SyntaxKind3.NoSubstitutionTemplateLiteral;
2488
- }
2489
- var SecurityVerifier = class {
2490
- id = "security";
2491
- name = "Security Verifier";
2492
- description = "Detects common security footguns (secrets, eval, XSS/SQL injection heuristics)";
2493
- async verify(ctx) {
2494
- const violations = [];
2495
- const { sourceFile, constraint, decisionId, filePath } = ctx;
2496
- const rule = constraint.rule.toLowerCase();
2497
- const checkSecrets = rule.includes("secret") || rule.includes("password") || rule.includes("token") || rule.includes("api key") || rule.includes("hardcoded");
2498
- const checkEval = rule.includes("eval") || rule.includes("function constructor");
2499
- const checkXss = rule.includes("xss") || rule.includes("innerhtml") || rule.includes("dangerouslysetinnerhtml");
2500
- const checkSql = rule.includes("sql") || rule.includes("injection");
2501
- const checkProto = rule.includes("prototype pollution") || rule.includes("__proto__");
2502
- if (checkSecrets) {
2503
- for (const vd of sourceFile.getVariableDeclarations()) {
2504
- const name = vd.getName();
2505
- if (!SECRET_NAME_RE.test(name)) continue;
2506
- const init = vd.getInitializer();
2507
- if (!init || !isStringLiteralLike(init)) continue;
2508
- const value = init.getText().slice(1, -1);
2509
- if (value.length === 0) continue;
2510
- violations.push(
2511
- createViolation({
2512
- decisionId,
2513
- constraintId: constraint.id,
2514
- type: constraint.type,
2515
- severity: constraint.severity,
2516
- message: `Possible hardcoded secret in variable "${name}"`,
2517
- file: filePath,
2518
- line: vd.getStartLineNumber(),
2519
- suggestion: "Move secrets to environment variables or a secret manager"
2520
- })
2521
- );
2522
- }
2523
- for (const pa of sourceFile.getDescendantsOfKind(SyntaxKind3.PropertyAssignment)) {
2524
- const propName = pa.getNameNode().getText();
2525
- if (!SECRET_NAME_RE.test(propName)) continue;
2526
- const init = pa.getInitializer();
2527
- if (!init || !isStringLiteralLike(init)) continue;
2528
- violations.push(
2529
- createViolation({
2530
- decisionId,
2531
- constraintId: constraint.id,
2532
- type: constraint.type,
2533
- severity: constraint.severity,
2534
- message: `Possible hardcoded secret in object property ${propName}`,
2535
- file: filePath,
2536
- line: pa.getStartLineNumber(),
2537
- suggestion: "Move secrets to environment variables or a secret manager"
2538
- })
2539
- );
2540
- }
2541
- }
2542
- if (checkEval) {
2543
- for (const call of sourceFile.getDescendantsOfKind(SyntaxKind3.CallExpression)) {
2544
- const exprText = call.getExpression().getText();
2545
- if (exprText === "eval" || exprText === "Function") {
2546
- violations.push(
2547
- createViolation({
2548
- decisionId,
2549
- constraintId: constraint.id,
2550
- type: constraint.type,
2551
- severity: constraint.severity,
2552
- message: `Unsafe dynamic code execution via ${exprText}()`,
2553
- file: filePath,
2554
- line: call.getStartLineNumber(),
2555
- suggestion: "Avoid eval/Function; use safer alternatives"
2556
- })
2557
- );
2558
- }
2559
- }
2560
- }
2561
- if (checkXss) {
2562
- for (const bin of sourceFile.getDescendantsOfKind(SyntaxKind3.BinaryExpression)) {
2563
- const left = bin.getLeft();
2564
- const propertyAccess = left.asKind(SyntaxKind3.PropertyAccessExpression);
2565
- if (!propertyAccess) continue;
2566
- if (propertyAccess.getName() === "innerHTML") {
2567
- violations.push(
2568
- createViolation({
2569
- decisionId,
2570
- constraintId: constraint.id,
2571
- type: constraint.type,
2572
- severity: constraint.severity,
2573
- message: "Potential XSS: assignment to innerHTML",
2574
- file: filePath,
2575
- line: bin.getStartLineNumber(),
2576
- suggestion: "Prefer textContent or a safe templating/escaping strategy"
2577
- })
2578
- );
2579
- }
2580
- }
2581
- if (sourceFile.getFullText().includes("dangerouslySetInnerHTML")) {
2582
- violations.push(
2583
- createViolation({
2584
- decisionId,
2585
- constraintId: constraint.id,
2586
- type: constraint.type,
2587
- severity: constraint.severity,
2588
- message: "Potential XSS: usage of dangerouslySetInnerHTML",
2589
- file: filePath,
2590
- line: 1,
2591
- suggestion: "Avoid dangerouslySetInnerHTML or ensure content is sanitized"
2592
- })
2593
- );
2594
- }
2595
- }
2596
- if (checkSql) {
2597
- for (const call of sourceFile.getDescendantsOfKind(SyntaxKind3.CallExpression)) {
2598
- const expr = call.getExpression();
2599
- const propertyAccess = expr.asKind(SyntaxKind3.PropertyAccessExpression);
2600
- if (!propertyAccess) continue;
2601
- const name = propertyAccess.getName();
2602
- if (name !== "query" && name !== "execute") continue;
2603
- const arg = call.getArguments()[0];
2604
- if (!arg) continue;
2605
- const isTemplate = arg.getKind() === SyntaxKind3.TemplateExpression;
2606
- const isConcat = arg.getKind() === SyntaxKind3.BinaryExpression && arg.getText().includes("+");
2607
- if (!isTemplate && !isConcat) continue;
2608
- const text = arg.getText().toLowerCase();
2609
- if (!text.includes("select") && !text.includes("insert") && !text.includes("update") && !text.includes("delete")) {
2610
- continue;
2611
- }
2612
- violations.push(
2613
- createViolation({
2614
- decisionId,
2615
- constraintId: constraint.id,
2616
- type: constraint.type,
2617
- severity: constraint.severity,
2618
- message: "Potential SQL injection: dynamically constructed SQL query",
2619
- file: filePath,
2620
- line: call.getStartLineNumber(),
2621
- suggestion: "Use parameterized queries / prepared statements"
2622
- })
2623
- );
2624
- }
2625
- }
2626
- if (checkProto) {
2627
- const text = sourceFile.getFullText();
2628
- if (text.includes("__proto__") || text.includes("constructor.prototype")) {
2629
- violations.push(
2630
- createViolation({
2631
- decisionId,
2632
- constraintId: constraint.id,
2633
- type: constraint.type,
2634
- severity: constraint.severity,
2635
- message: "Potential prototype pollution pattern detected",
2636
- file: filePath,
2637
- line: 1,
2638
- suggestion: "Avoid writing to __proto__/prototype; validate object keys"
2639
- })
2640
- );
2769
+ type: constraint.type,
2770
+ severity: constraint.severity,
2771
+ message: `Function ${fnName} has nesting depth ${depth} which exceeds maximum ${maxNesting}`,
2772
+ file: filePath,
2773
+ line: fn.getStartLineNumber(),
2774
+ suggestion: "Reduce nesting by using early returns or extracting functions"
2775
+ })
2776
+ );
2777
+ }
2641
2778
  }
2642
2779
  }
2643
2780
  return violations;
2644
2781
  }
2645
2782
  };
2646
2783
 
2647
- // src/verification/verifiers/api.ts
2648
- import { SyntaxKind as SyntaxKind4 } from "ts-morph";
2649
- var HTTP_METHODS = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "options", "head", "all"]);
2650
- function isKebabPath(pathValue) {
2651
- const parts = pathValue.split("/").filter(Boolean);
2652
- for (const part of parts) {
2653
- if (part.startsWith(":")) continue;
2654
- if (!/^[a-z0-9-]+$/.test(part)) return false;
2655
- }
2656
- return true;
2784
+ // src/verification/verifiers/security.ts
2785
+ import { SyntaxKind as SyntaxKind3 } from "ts-morph";
2786
+ var SECRET_NAME_RE = /(api[_-]?key|password|secret|token)/i;
2787
+ function isStringLiteralLike(node) {
2788
+ const k = node.getKind();
2789
+ return k === SyntaxKind3.StringLiteral || k === SyntaxKind3.NoSubstitutionTemplateLiteral;
2657
2790
  }
2658
- var ApiVerifier = class {
2659
- id = "api";
2660
- name = "API Consistency Verifier";
2661
- description = "Checks basic REST endpoint naming conventions in common frameworks";
2791
+ var SecurityVerifier = class {
2792
+ id = "security";
2793
+ name = "Security Verifier";
2794
+ description = "Detects common security footguns (secrets, eval, XSS/SQL injection heuristics)";
2662
2795
  async verify(ctx) {
2663
2796
  const violations = [];
2664
2797
  const { sourceFile, constraint, decisionId, filePath } = ctx;
2665
2798
  const rule = constraint.rule.toLowerCase();
2666
- const enforceKebab = rule.includes("kebab") || rule.includes("kebab-case");
2667
- if (!enforceKebab) return violations;
2668
- for (const call of sourceFile.getDescendantsOfKind(SyntaxKind4.CallExpression)) {
2669
- const expr = call.getExpression();
2670
- const propertyAccess = expr.asKind(SyntaxKind4.PropertyAccessExpression);
2671
- if (!propertyAccess) continue;
2672
- const method = propertyAccess.getName();
2673
- if (!method || !HTTP_METHODS.has(String(method))) continue;
2674
- const firstArg = call.getArguments()[0];
2675
- const stringLiteral = firstArg?.asKind(SyntaxKind4.StringLiteral);
2676
- if (!stringLiteral) continue;
2677
- const pathValue = stringLiteral.getLiteralValue();
2678
- if (typeof pathValue !== "string") continue;
2679
- if (!isKebabPath(pathValue)) {
2799
+ const checkSecrets = rule.includes("secret") || rule.includes("password") || rule.includes("token") || rule.includes("api key") || rule.includes("hardcoded");
2800
+ const checkEval = rule.includes("eval") || rule.includes("function constructor");
2801
+ const checkXss = rule.includes("xss") || rule.includes("innerhtml") || rule.includes("dangerouslysetinnerhtml");
2802
+ const checkSql = rule.includes("sql") || rule.includes("injection");
2803
+ const checkProto = rule.includes("prototype pollution") || rule.includes("__proto__");
2804
+ if (checkSecrets) {
2805
+ for (const vd of sourceFile.getVariableDeclarations()) {
2806
+ const name = vd.getName();
2807
+ if (!SECRET_NAME_RE.test(name)) continue;
2808
+ const init = vd.getInitializer();
2809
+ if (!init || !isStringLiteralLike(init)) continue;
2810
+ const value = init.getText().slice(1, -1);
2811
+ if (value.length === 0) continue;
2680
2812
  violations.push(
2681
2813
  createViolation({
2682
2814
  decisionId,
2683
2815
  constraintId: constraint.id,
2684
2816
  type: constraint.type,
2685
2817
  severity: constraint.severity,
2686
- message: `Endpoint path "${pathValue}" is not kebab-case`,
2818
+ message: `Possible hardcoded secret in variable "${name}"`,
2687
2819
  file: filePath,
2688
- line: call.getStartLineNumber(),
2689
- suggestion: "Use lowercase and hyphens in static path segments (e.g., /user-settings)"
2820
+ line: vd.getStartLineNumber(),
2821
+ suggestion: "Move secrets to environment variables or a secret manager"
2822
+ })
2823
+ );
2824
+ }
2825
+ for (const pa of sourceFile.getDescendantsOfKind(SyntaxKind3.PropertyAssignment)) {
2826
+ const propName = pa.getNameNode().getText();
2827
+ if (!SECRET_NAME_RE.test(propName)) continue;
2828
+ const init = pa.getInitializer();
2829
+ if (!init || !isStringLiteralLike(init)) continue;
2830
+ violations.push(
2831
+ createViolation({
2832
+ decisionId,
2833
+ constraintId: constraint.id,
2834
+ type: constraint.type,
2835
+ severity: constraint.severity,
2836
+ message: `Possible hardcoded secret in object property ${propName}`,
2837
+ file: filePath,
2838
+ line: pa.getStartLineNumber(),
2839
+ suggestion: "Move secrets to environment variables or a secret manager"
2690
2840
  })
2691
2841
  );
2692
2842
  }
2693
2843
  }
2694
- return violations;
2695
- }
2696
- };
2697
-
2698
- // src/verification/plugins/loader.ts
2699
- import { existsSync } from "fs";
2700
- import { join as join3 } from "path";
2701
- import { pathToFileURL } from "url";
2702
- import fg2 from "fast-glob";
2703
-
2704
- // src/utils/logger.ts
2705
- import pino from "pino";
2706
- var defaultOptions = {
2707
- level: process.env.SPECBRIDGE_LOG_LEVEL || "info",
2708
- timestamp: pino.stdTimeFunctions.isoTime,
2709
- base: {
2710
- service: "specbridge"
2711
- }
2712
- };
2713
- var destination = pino.destination({
2714
- fd: 2,
2715
- // stderr
2716
- sync: false
2717
- });
2718
- var rootLogger = pino(defaultOptions, destination);
2719
- function getLogger(bindings) {
2720
- if (!bindings) {
2721
- return rootLogger;
2722
- }
2723
- return rootLogger.child(bindings);
2724
- }
2725
- var logger = getLogger();
2726
-
2727
- // src/verification/plugins/loader.ts
2728
- var PluginLoader = class {
2729
- plugins = /* @__PURE__ */ new Map();
2730
- loaded = false;
2731
- loadErrors = [];
2732
- logger = getLogger({ module: "verification.plugins.loader" });
2733
- /**
2734
- * Load all plugins from the specified base path
2735
- *
2736
- * @param basePath - Project root directory (usually cwd)
2737
- */
2738
- async loadPlugins(basePath) {
2739
- const verifiersDir = join3(basePath, ".specbridge", "verifiers");
2740
- if (!existsSync(verifiersDir)) {
2741
- this.loaded = true;
2742
- return;
2743
- }
2744
- const files = await fg2("**/*.{ts,js}", {
2745
- cwd: verifiersDir,
2746
- absolute: true,
2747
- ignore: ["**/*.test.{ts,js}", "**/*.d.ts"]
2748
- });
2749
- for (const file of files) {
2750
- try {
2751
- await this.loadPlugin(file);
2752
- } catch (error) {
2753
- const message = error instanceof Error ? error.message : String(error);
2754
- this.loadErrors.push({ file, error: message });
2755
- this.logger.warn({ file, error: message }, "Failed to load plugin");
2756
- }
2757
- }
2758
- this.loaded = true;
2759
- if (this.plugins.size > 0) {
2760
- this.logger.info({ count: this.plugins.size }, "Loaded custom verifier plugins");
2761
- }
2762
- if (this.loadErrors.length > 0) {
2763
- this.logger.warn({ count: this.loadErrors.length }, "Plugin load failures");
2764
- }
2765
- }
2766
- /**
2767
- * Load a single plugin file
2768
- */
2769
- async loadPlugin(filePath) {
2770
- const fileUrl = pathToFileURL(filePath).href;
2771
- const module = await import(fileUrl);
2772
- const plugin = module.default || module.plugin;
2773
- if (!plugin) {
2774
- throw new Error('Plugin must export a default or named "plugin" export');
2775
- }
2776
- this.validatePlugin(plugin, filePath);
2777
- if (this.plugins.has(plugin.metadata.id)) {
2778
- throw new Error(
2779
- `Plugin ID "${plugin.metadata.id}" is already registered. Each plugin must have a unique ID.`
2780
- );
2781
- }
2782
- this.plugins.set(plugin.metadata.id, plugin);
2783
- }
2784
- /**
2785
- * Validate plugin structure and metadata
2786
- */
2787
- validatePlugin(plugin, _filePath) {
2788
- if (!plugin.metadata) {
2789
- throw new Error('Plugin must have a "metadata" property');
2790
- }
2791
- if (!plugin.metadata.id || typeof plugin.metadata.id !== "string") {
2792
- throw new Error('Plugin metadata must have a string "id" property');
2793
- }
2794
- if (!plugin.metadata.version || typeof plugin.metadata.version !== "string") {
2795
- throw new Error('Plugin metadata must have a string "version" property');
2796
- }
2797
- const idPattern = /^[a-z][a-z0-9-]*$/;
2798
- if (!idPattern.test(plugin.metadata.id)) {
2799
- throw new Error(
2800
- `Plugin ID "${plugin.metadata.id}" is invalid. ID must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.`
2801
- );
2802
- }
2803
- if (typeof plugin.createVerifier !== "function") {
2804
- throw new Error('Plugin must have a "createVerifier" function');
2805
- }
2806
- let verifier;
2807
- try {
2808
- verifier = plugin.createVerifier();
2809
- } catch (error) {
2810
- const message = error instanceof Error ? error.message : String(error);
2811
- throw new Error(`createVerifier() threw an error: ${message}`);
2812
- }
2813
- if (!verifier || typeof verifier !== "object") {
2814
- throw new Error("createVerifier() must return an object");
2815
- }
2816
- if (!verifier.id || typeof verifier.id !== "string") {
2817
- throw new Error('Verifier must have a string "id" property');
2818
- }
2819
- if (!verifier.name || typeof verifier.name !== "string") {
2820
- throw new Error('Verifier must have a string "name" property');
2821
- }
2822
- if (!verifier.description || typeof verifier.description !== "string") {
2823
- throw new Error('Verifier must have a string "description" property');
2824
- }
2825
- if (typeof verifier.verify !== "function") {
2826
- throw new Error('Verifier must have a "verify" method');
2827
- }
2828
- if (verifier.id !== plugin.metadata.id) {
2829
- throw new Error(
2830
- `Verifier ID "${verifier.id}" does not match plugin metadata ID "${plugin.metadata.id}". These must be identical.`
2831
- );
2832
- }
2833
- }
2834
- /**
2835
- * Get a verifier instance by ID
2836
- *
2837
- * @param id - Verifier ID
2838
- * @returns Verifier instance or null if not found
2839
- */
2840
- getVerifier(id) {
2841
- const plugin = this.plugins.get(id);
2842
- return plugin ? plugin.createVerifier() : null;
2843
- }
2844
- /**
2845
- * Get a plugin by ID (includes paramsSchema)
2846
- *
2847
- * @param id - Plugin ID
2848
- * @returns Plugin or null if not found
2849
- */
2850
- getPlugin(id) {
2851
- return this.plugins.get(id) || null;
2852
- }
2853
- /**
2854
- * Validate params against a plugin's paramsSchema
2855
- *
2856
- * @param id - Plugin ID
2857
- * @param params - Parameters to validate
2858
- * @returns Validation result with success flag and error message if failed
2859
- */
2860
- validateParams(id, params) {
2861
- const plugin = this.plugins.get(id);
2862
- if (!plugin) {
2863
- return { success: false, error: `Plugin ${id} not found` };
2864
- }
2865
- if (!plugin.paramsSchema) {
2866
- return { success: true };
2867
- }
2868
- if (!params) {
2869
- return { success: false, error: `Plugin ${id} requires params but none were provided` };
2844
+ if (checkEval) {
2845
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind3.CallExpression)) {
2846
+ const exprText = call.getExpression().getText();
2847
+ if (exprText === "eval" || exprText === "Function") {
2848
+ violations.push(
2849
+ createViolation({
2850
+ decisionId,
2851
+ constraintId: constraint.id,
2852
+ type: constraint.type,
2853
+ severity: constraint.severity,
2854
+ message: `Unsafe dynamic code execution via ${exprText}()`,
2855
+ file: filePath,
2856
+ line: call.getStartLineNumber(),
2857
+ suggestion: "Avoid eval/Function; use safer alternatives"
2858
+ })
2859
+ );
2860
+ }
2861
+ }
2870
2862
  }
2871
- if (typeof plugin.paramsSchema !== "object" || !plugin.paramsSchema || !("parse" in plugin.paramsSchema)) {
2872
- return {
2873
- success: false,
2874
- error: `Plugin ${id} has invalid paramsSchema (must be a Zod schema)`
2875
- };
2863
+ if (checkXss) {
2864
+ for (const bin of sourceFile.getDescendantsOfKind(SyntaxKind3.BinaryExpression)) {
2865
+ const left = bin.getLeft();
2866
+ const propertyAccess = left.asKind(SyntaxKind3.PropertyAccessExpression);
2867
+ if (!propertyAccess) continue;
2868
+ if (propertyAccess.getName() === "innerHTML") {
2869
+ violations.push(
2870
+ createViolation({
2871
+ decisionId,
2872
+ constraintId: constraint.id,
2873
+ type: constraint.type,
2874
+ severity: constraint.severity,
2875
+ message: "Potential XSS: assignment to innerHTML",
2876
+ file: filePath,
2877
+ line: bin.getStartLineNumber(),
2878
+ suggestion: "Prefer textContent or a safe templating/escaping strategy"
2879
+ })
2880
+ );
2881
+ }
2882
+ }
2883
+ if (sourceFile.getFullText().includes("dangerouslySetInnerHTML")) {
2884
+ violations.push(
2885
+ createViolation({
2886
+ decisionId,
2887
+ constraintId: constraint.id,
2888
+ type: constraint.type,
2889
+ severity: constraint.severity,
2890
+ message: "Potential XSS: usage of dangerouslySetInnerHTML",
2891
+ file: filePath,
2892
+ line: 1,
2893
+ suggestion: "Avoid dangerouslySetInnerHTML or ensure content is sanitized"
2894
+ })
2895
+ );
2896
+ }
2876
2897
  }
2877
- const schema = plugin.paramsSchema;
2878
- if (schema.safeParse) {
2879
- const result = schema.safeParse(params);
2880
- if (!result.success) {
2881
- const errors = result.error?.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(", ") || "Validation failed";
2882
- return { success: false, error: `Invalid params for ${id}: ${errors}` };
2898
+ if (checkSql) {
2899
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind3.CallExpression)) {
2900
+ const expr = call.getExpression();
2901
+ const propertyAccess = expr.asKind(SyntaxKind3.PropertyAccessExpression);
2902
+ if (!propertyAccess) continue;
2903
+ const name = propertyAccess.getName();
2904
+ if (name !== "query" && name !== "execute") continue;
2905
+ const arg = call.getArguments()[0];
2906
+ if (!arg) continue;
2907
+ const isTemplate = arg.getKind() === SyntaxKind3.TemplateExpression;
2908
+ const isConcat = arg.getKind() === SyntaxKind3.BinaryExpression && arg.getText().includes("+");
2909
+ if (!isTemplate && !isConcat) continue;
2910
+ const text = arg.getText().toLowerCase();
2911
+ if (!text.includes("select") && !text.includes("insert") && !text.includes("update") && !text.includes("delete")) {
2912
+ continue;
2913
+ }
2914
+ violations.push(
2915
+ createViolation({
2916
+ decisionId,
2917
+ constraintId: constraint.id,
2918
+ type: constraint.type,
2919
+ severity: constraint.severity,
2920
+ message: "Potential SQL injection: dynamically constructed SQL query",
2921
+ file: filePath,
2922
+ line: call.getStartLineNumber(),
2923
+ suggestion: "Use parameterized queries / prepared statements"
2924
+ })
2925
+ );
2883
2926
  }
2884
- } else {
2885
- try {
2886
- schema.parse(params);
2887
- } catch (error) {
2888
- const message = error instanceof Error ? error.message : String(error);
2889
- return { success: false, error: `Invalid params for ${id}: ${message}` };
2927
+ }
2928
+ if (checkProto) {
2929
+ const text = sourceFile.getFullText();
2930
+ if (text.includes("__proto__") || text.includes("constructor.prototype")) {
2931
+ violations.push(
2932
+ createViolation({
2933
+ decisionId,
2934
+ constraintId: constraint.id,
2935
+ type: constraint.type,
2936
+ severity: constraint.severity,
2937
+ message: "Potential prototype pollution pattern detected",
2938
+ file: filePath,
2939
+ line: 1,
2940
+ suggestion: "Avoid writing to __proto__/prototype; validate object keys"
2941
+ })
2942
+ );
2890
2943
  }
2891
2944
  }
2892
- return { success: true };
2893
- }
2894
- /**
2895
- * Get all registered plugin IDs
2896
- */
2897
- getPluginIds() {
2898
- return Array.from(this.plugins.keys());
2899
- }
2900
- /**
2901
- * Check if plugins have been loaded
2902
- */
2903
- isLoaded() {
2904
- return this.loaded;
2905
- }
2906
- /**
2907
- * Get load errors (for diagnostics)
2908
- */
2909
- getLoadErrors() {
2910
- return [...this.loadErrors];
2911
- }
2912
- /**
2913
- * Reset the loader (useful for testing)
2914
- */
2915
- reset() {
2916
- this.plugins.clear();
2917
- this.loaded = false;
2918
- this.loadErrors = [];
2945
+ return violations;
2919
2946
  }
2920
2947
  };
2921
- var pluginLoader = null;
2922
- function getPluginLoader() {
2923
- if (!pluginLoader) {
2924
- pluginLoader = new PluginLoader();
2948
+
2949
+ // src/verification/verifiers/api.ts
2950
+ import { SyntaxKind as SyntaxKind4 } from "ts-morph";
2951
+ var HTTP_METHODS = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "options", "head", "all"]);
2952
+ function isKebabPath(pathValue) {
2953
+ const parts = pathValue.split("/").filter(Boolean);
2954
+ for (const part of parts) {
2955
+ if (part.startsWith(":")) continue;
2956
+ if (!/^[a-z0-9-]+$/.test(part)) return false;
2925
2957
  }
2926
- return pluginLoader;
2958
+ return true;
2927
2959
  }
2960
+ var ApiVerifier = class {
2961
+ id = "api";
2962
+ name = "API Consistency Verifier";
2963
+ description = "Checks basic REST endpoint naming conventions in common frameworks";
2964
+ async verify(ctx) {
2965
+ const violations = [];
2966
+ const { sourceFile, constraint, decisionId, filePath } = ctx;
2967
+ const rule = constraint.rule.toLowerCase();
2968
+ const enforceKebab = rule.includes("kebab") || rule.includes("kebab-case");
2969
+ if (!enforceKebab) return violations;
2970
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind4.CallExpression)) {
2971
+ const expr = call.getExpression();
2972
+ const propertyAccess = expr.asKind(SyntaxKind4.PropertyAccessExpression);
2973
+ if (!propertyAccess) continue;
2974
+ const method = propertyAccess.getName();
2975
+ if (!method || !HTTP_METHODS.has(String(method))) continue;
2976
+ const firstArg = call.getArguments()[0];
2977
+ const stringLiteral = firstArg?.asKind(SyntaxKind4.StringLiteral);
2978
+ if (!stringLiteral) continue;
2979
+ const pathValue = stringLiteral.getLiteralValue();
2980
+ if (typeof pathValue !== "string") continue;
2981
+ if (!isKebabPath(pathValue)) {
2982
+ violations.push(
2983
+ createViolation({
2984
+ decisionId,
2985
+ constraintId: constraint.id,
2986
+ type: constraint.type,
2987
+ severity: constraint.severity,
2988
+ message: `Endpoint path "${pathValue}" is not kebab-case`,
2989
+ file: filePath,
2990
+ line: call.getStartLineNumber(),
2991
+ suggestion: "Use lowercase and hyphens in static path segments (e.g., /user-settings)"
2992
+ })
2993
+ );
2994
+ }
2995
+ }
2996
+ return violations;
2997
+ }
2998
+ };
2928
2999
 
2929
3000
  // src/verification/verifiers/index.ts
2930
3001
  var builtinVerifiers = {
@@ -3000,51 +3071,216 @@ function selectVerifierForConstraint(rule, specifiedVerifier, check) {
3000
3071
  return getVerifier("regex");
3001
3072
  }
3002
3073
 
3003
- // src/verification/cache.ts
3004
- import { stat as stat2, readFile as readFile2 } from "fs/promises";
3005
- import { createHash } from "crypto";
3006
- var AstCache = class {
3007
- cache = /* @__PURE__ */ new Map();
3008
- async get(filePath, project) {
3009
- try {
3010
- const stats = await stat2(filePath);
3011
- const cached = this.cache.get(filePath);
3012
- if (cached && cached.mtimeMs >= stats.mtimeMs) {
3013
- return cached.sourceFile;
3074
+ // src/verification/file-verifier.ts
3075
+ async function verifySingleFile(options, dependencies) {
3076
+ const { filePath, decisions, severityFilter, cwd, reporter, signal } = options;
3077
+ const { project, astCache, resultsCache, logger: logger2 } = dependencies;
3078
+ const violations = [];
3079
+ const warnings = [];
3080
+ const errors = [];
3081
+ if (signal?.aborted) {
3082
+ return { violations, warnings, errors };
3083
+ }
3084
+ const sourceFile = await astCache.get(filePath, project);
3085
+ if (!sourceFile) {
3086
+ return { violations, warnings, errors };
3087
+ }
3088
+ let fileHash = null;
3089
+ try {
3090
+ const content = await readFile3(filePath, "utf-8");
3091
+ fileHash = createHash2("sha256").update(content).digest("hex");
3092
+ } catch {
3093
+ fileHash = null;
3094
+ }
3095
+ for (const decision of decisions) {
3096
+ for (const constraint of decision.constraints) {
3097
+ if (!shouldApplyConstraintToFile({ filePath, constraint, cwd, severityFilter })) {
3098
+ if (reporter) {
3099
+ reporter.add({
3100
+ file: filePath,
3101
+ decision,
3102
+ constraint,
3103
+ applied: false,
3104
+ reason: "File does not match scope pattern or severity filter"
3105
+ });
3106
+ }
3107
+ continue;
3014
3108
  }
3015
- const content = await readFile2(filePath, "utf-8");
3016
- const hash = createHash("sha256").update(content).digest("hex");
3017
- if (cached && cached.hash === hash) {
3018
- cached.mtimeMs = stats.mtimeMs;
3019
- return cached.sourceFile;
3109
+ const verifier = selectVerifierForConstraint(
3110
+ constraint.rule,
3111
+ constraint.verifier,
3112
+ constraint.check
3113
+ );
3114
+ if (!verifier) {
3115
+ const requestedVerifier = constraint.check?.verifier || constraint.verifier || "auto-detected";
3116
+ logger2.warn(
3117
+ {
3118
+ decisionId: decision.metadata.id,
3119
+ constraintId: constraint.id,
3120
+ requestedVerifier,
3121
+ availableVerifiers: getVerifierIds()
3122
+ },
3123
+ "No verifier found for constraint"
3124
+ );
3125
+ warnings.push({
3126
+ type: "missing_verifier",
3127
+ message: `No verifier found for constraint (requested: ${requestedVerifier})`,
3128
+ decisionId: decision.metadata.id,
3129
+ constraintId: constraint.id,
3130
+ file: filePath
3131
+ });
3132
+ if (reporter) {
3133
+ reporter.add({
3134
+ file: filePath,
3135
+ decision,
3136
+ constraint,
3137
+ applied: false,
3138
+ reason: `No verifier found (requested: ${requestedVerifier})`
3139
+ });
3140
+ }
3141
+ continue;
3142
+ }
3143
+ let constraintViolations;
3144
+ if (fileHash) {
3145
+ const cacheKey = {
3146
+ filePath,
3147
+ decisionId: decision.metadata.id,
3148
+ constraintId: constraint.id,
3149
+ fileHash
3150
+ };
3151
+ const cached = resultsCache.get(cacheKey);
3152
+ if (cached) {
3153
+ constraintViolations = cached;
3154
+ violations.push(...constraintViolations);
3155
+ if (reporter) {
3156
+ reporter.add({
3157
+ file: filePath,
3158
+ decision,
3159
+ constraint,
3160
+ applied: true,
3161
+ reason: "Constraint matches file scope (cached)",
3162
+ selectedVerifier: verifier.id
3163
+ });
3164
+ }
3165
+ continue;
3166
+ }
3020
3167
  }
3021
- let sourceFile = project.getSourceFile(filePath);
3022
- if (!sourceFile) {
3023
- sourceFile = project.addSourceFileAtPath(filePath);
3024
- } else {
3025
- sourceFile.refreshFromFileSystemSync();
3168
+ if (constraint.check?.verifier && constraint.check?.params) {
3169
+ const validationResult = getPluginLoader().validateParams(
3170
+ constraint.check.verifier,
3171
+ constraint.check.params
3172
+ );
3173
+ if (!validationResult.success) {
3174
+ warnings.push({
3175
+ type: "invalid_params",
3176
+ message: validationResult.error,
3177
+ decisionId: decision.metadata.id,
3178
+ constraintId: constraint.id,
3179
+ file: filePath
3180
+ });
3181
+ if (reporter) {
3182
+ reporter.add({
3183
+ file: filePath,
3184
+ decision,
3185
+ constraint,
3186
+ applied: false,
3187
+ reason: `Params validation failed: ${validationResult.error}`
3188
+ });
3189
+ }
3190
+ continue;
3191
+ }
3026
3192
  }
3027
- this.cache.set(filePath, {
3193
+ const verificationContext = {
3194
+ filePath,
3028
3195
  sourceFile,
3029
- hash,
3030
- mtimeMs: stats.mtimeMs
3031
- });
3032
- return sourceFile;
3033
- } catch {
3034
- return null;
3196
+ constraint,
3197
+ decisionId: decision.metadata.id,
3198
+ signal
3199
+ };
3200
+ const verificationStart = Date.now();
3201
+ try {
3202
+ constraintViolations = await verifier.verify(verificationContext);
3203
+ violations.push(...constraintViolations);
3204
+ if (fileHash) {
3205
+ resultsCache.set(
3206
+ {
3207
+ filePath,
3208
+ decisionId: decision.metadata.id,
3209
+ constraintId: constraint.id,
3210
+ fileHash
3211
+ },
3212
+ constraintViolations
3213
+ );
3214
+ }
3215
+ if (reporter) {
3216
+ reporter.add({
3217
+ file: filePath,
3218
+ decision,
3219
+ constraint,
3220
+ applied: true,
3221
+ reason: "Constraint matches file scope",
3222
+ selectedVerifier: verifier.id,
3223
+ verifierOutput: {
3224
+ violations: constraintViolations.length,
3225
+ duration: Date.now() - verificationStart
3226
+ }
3227
+ });
3228
+ }
3229
+ } catch (error) {
3230
+ const errorMessage = error instanceof Error ? error.message : String(error);
3231
+ const errorStack = error instanceof Error ? error.stack : void 0;
3232
+ logger2.error(
3233
+ {
3234
+ verifierId: verifier.id,
3235
+ filePath,
3236
+ decisionId: decision.metadata.id,
3237
+ constraintId: constraint.id,
3238
+ error: errorMessage,
3239
+ stack: errorStack
3240
+ },
3241
+ "Verifier execution failed"
3242
+ );
3243
+ errors.push({
3244
+ type: "verifier_exception",
3245
+ message: `Verifier '${verifier.id}' failed: ${errorMessage}`,
3246
+ decisionId: decision.metadata.id,
3247
+ constraintId: constraint.id,
3248
+ file: filePath,
3249
+ stack: errorStack
3250
+ });
3251
+ if (reporter) {
3252
+ reporter.add({
3253
+ file: filePath,
3254
+ decision,
3255
+ constraint,
3256
+ applied: true,
3257
+ reason: "Constraint matches file scope",
3258
+ selectedVerifier: verifier.id,
3259
+ verifierOutput: {
3260
+ violations: 0,
3261
+ duration: Date.now() - verificationStart,
3262
+ error: errorMessage
3263
+ }
3264
+ });
3265
+ }
3266
+ }
3035
3267
  }
3036
3268
  }
3037
- clear() {
3038
- this.cache.clear();
3039
- }
3040
- getStats() {
3041
- return {
3042
- entries: this.cache.size,
3043
- memoryEstimate: this.cache.size * 5e4
3044
- // Rough estimate: 50KB per AST
3045
- };
3269
+ return { violations, warnings, errors };
3270
+ }
3271
+ async function verifyFilesInBatches(options) {
3272
+ const { files, signal, verifyFile, onFileVerified, batchSize = 50 } = options;
3273
+ for (let i = 0; i < files.length; i += batchSize) {
3274
+ if (signal.aborted) {
3275
+ break;
3276
+ }
3277
+ const batch = files.slice(i, i + batchSize);
3278
+ const results = await Promise.all(batch.map((filePath) => verifyFile(filePath)));
3279
+ for (const result of results) {
3280
+ onFileVerified(result);
3281
+ }
3046
3282
  }
3047
- };
3283
+ }
3048
3284
 
3049
3285
  // src/verification/results-cache.ts
3050
3286
  var ResultsCache = class {
@@ -3116,30 +3352,63 @@ var ResultsCache = class {
3116
3352
  }
3117
3353
  };
3118
3354
 
3119
- // src/verification/applicability.ts
3120
- function isConstraintExcepted(filePath, constraint, cwd) {
3121
- if (!constraint.exceptions) return false;
3122
- return constraint.exceptions.some((exception) => {
3123
- if (exception.expiresAt) {
3124
- const expiryDate = new Date(exception.expiresAt);
3125
- if (expiryDate < /* @__PURE__ */ new Date()) {
3126
- return false;
3127
- }
3128
- }
3129
- return matchesPattern(filePath, exception.pattern, { cwd });
3355
+ // src/verification/run-settings.ts
3356
+ function resolveVerificationRunOptions(config, options) {
3357
+ const level = options.level || "full";
3358
+ const levelConfig = config.verification?.levels?.[level];
3359
+ const severityFilter = options.severity || levelConfig?.severity;
3360
+ const timeout = options.timeout || levelConfig?.timeout || 6e4;
3361
+ return {
3362
+ level,
3363
+ specificFiles: options.files,
3364
+ decisionIds: options.decisions,
3365
+ severityFilter,
3366
+ timeout,
3367
+ cwd: options.cwd || process.cwd()
3368
+ };
3369
+ }
3370
+ function selectDecisionsForRun(decisions, decisionIds) {
3371
+ if (!decisionIds || decisionIds.length === 0) {
3372
+ return decisions;
3373
+ }
3374
+ return decisions.filter((decision) => decisionIds.includes(decision.metadata.id));
3375
+ }
3376
+ async function resolveFilesForRun(config, specificFiles, cwd) {
3377
+ if (specificFiles) {
3378
+ return specificFiles;
3379
+ }
3380
+ return glob(config.project.sourceRoots, {
3381
+ cwd,
3382
+ ignore: config.project.exclude,
3383
+ absolute: true
3130
3384
  });
3131
3385
  }
3132
- function shouldApplyConstraintToFile(params) {
3133
- const { filePath, constraint, cwd, severityFilter } = params;
3134
- if (!matchesPattern(filePath, constraint.scope, { cwd })) return false;
3135
- if (severityFilter && !severityFilter.includes(constraint.severity)) return false;
3136
- if (isConstraintExcepted(filePath, constraint, cwd)) return false;
3137
- return true;
3386
+ function createEmptyRunResult(startTime) {
3387
+ return {
3388
+ success: true,
3389
+ violations: [],
3390
+ checked: 0,
3391
+ passed: 0,
3392
+ failed: 0,
3393
+ skipped: 0,
3394
+ duration: Date.now() - startTime,
3395
+ warnings: [],
3396
+ errors: []
3397
+ };
3398
+ }
3399
+ function hasBlockingViolations(violations, level) {
3400
+ return violations.some((violation) => {
3401
+ if (level === "commit") {
3402
+ return violation.type === "invariant" || violation.severity === "critical";
3403
+ }
3404
+ if (level === "pr") {
3405
+ return violation.type === "invariant" || violation.severity === "critical" || violation.severity === "high";
3406
+ }
3407
+ return violation.type === "invariant";
3408
+ });
3138
3409
  }
3139
3410
 
3140
3411
  // src/verification/engine.ts
3141
- import { createHash as createHash2 } from "crypto";
3142
- import { readFile as readFile3 } from "fs/promises";
3143
3412
  var VerificationEngine = class {
3144
3413
  registry;
3145
3414
  project;
@@ -3166,333 +3435,90 @@ var VerificationEngine = class {
3166
3435
  */
3167
3436
  async verify(config, options = {}) {
3168
3437
  const startTime = Date.now();
3169
- const {
3170
- level = "full",
3171
- files: specificFiles,
3172
- decisions: decisionIds,
3173
- cwd = process.cwd()
3174
- } = options;
3175
- const levelConfig = config.verification?.levels?.[level];
3176
- const severityFilter = options.severity || levelConfig?.severity;
3177
- const timeout = options.timeout || levelConfig?.timeout || 6e4;
3178
- if (!this.pluginsLoaded) {
3179
- await getPluginLoader().loadPlugins(cwd);
3180
- this.pluginsLoaded = true;
3181
- }
3438
+ const settings = resolveVerificationRunOptions(config, options);
3439
+ await this.ensurePluginsLoaded(settings.cwd);
3182
3440
  await this.registry.load();
3183
- let decisions = this.registry.getActive();
3184
- if (decisionIds && decisionIds.length > 0) {
3185
- decisions = decisions.filter((d) => decisionIds.includes(d.metadata.id));
3186
- }
3187
- const filesToVerify = specificFiles ? specificFiles : await glob(config.project.sourceRoots, {
3188
- cwd,
3189
- ignore: config.project.exclude,
3190
- absolute: true
3191
- });
3441
+ const decisions = selectDecisionsForRun(this.registry.getActive(), settings.decisionIds);
3442
+ const filesToVerify = await resolveFilesForRun(config, settings.specificFiles, settings.cwd);
3192
3443
  if (filesToVerify.length === 0) {
3193
- return {
3194
- success: true,
3195
- violations: [],
3196
- checked: 0,
3197
- passed: 0,
3198
- failed: 0,
3199
- skipped: 0,
3200
- duration: Date.now() - startTime,
3201
- warnings: [],
3202
- errors: []
3203
- };
3444
+ return createEmptyRunResult(startTime);
3204
3445
  }
3205
- const allViolations = [];
3206
- const allWarnings = [];
3207
- const allErrors = [];
3208
- let checked = 0;
3209
- let passed = 0;
3210
- let failed = 0;
3211
- const skipped = 0;
3446
+ const accumulator = this.createAccumulator();
3212
3447
  const abortController = new AbortController();
3213
3448
  let timeoutHandle = null;
3214
3449
  const timeoutPromise = new Promise((resolve2) => {
3215
3450
  timeoutHandle = setTimeout(() => {
3216
3451
  abortController.abort();
3217
3452
  resolve2("timeout");
3218
- }, timeout);
3453
+ }, settings.timeout);
3219
3454
  timeoutHandle.unref();
3220
3455
  });
3221
- const verificationPromise = this.verifyFiles(
3222
- filesToVerify,
3223
- decisions,
3224
- severityFilter,
3225
- cwd,
3226
- options.reporter,
3227
- abortController.signal,
3228
- (violations, warnings, errors) => {
3229
- allViolations.push(...violations);
3230
- allWarnings.push(...warnings);
3231
- allErrors.push(...errors);
3232
- checked++;
3233
- if (violations.length > 0) {
3234
- failed++;
3235
- } else {
3236
- passed++;
3237
- }
3238
- }
3239
- );
3240
- let result;
3456
+ const verificationPromise = verifyFilesInBatches({
3457
+ files: filesToVerify,
3458
+ signal: abortController.signal,
3459
+ verifyFile: (filePath) => this.verifyFile(
3460
+ filePath,
3461
+ decisions,
3462
+ settings.severityFilter,
3463
+ settings.cwd,
3464
+ options.reporter,
3465
+ abortController.signal
3466
+ ),
3467
+ onFileVerified: (result) => this.addFileResult(accumulator, result)
3468
+ });
3469
+ let raceResult;
3241
3470
  try {
3242
- result = await Promise.race([verificationPromise, timeoutPromise]);
3243
- if (result === "timeout") {
3244
- return {
3245
- success: false,
3246
- violations: allViolations,
3247
- checked,
3248
- passed,
3249
- failed,
3250
- skipped: filesToVerify.length - checked,
3251
- duration: timeout,
3252
- warnings: allWarnings,
3253
- errors: allErrors
3254
- };
3255
- }
3471
+ raceResult = await Promise.race([verificationPromise, timeoutPromise]);
3256
3472
  } finally {
3257
3473
  if (timeoutHandle) {
3258
3474
  clearTimeout(timeoutHandle);
3259
- timeoutHandle = null;
3260
3475
  }
3261
3476
  }
3262
- const hasBlockingViolations = allViolations.some((v) => {
3263
- if (level === "commit") {
3264
- return v.type === "invariant" || v.severity === "critical";
3265
- }
3266
- if (level === "pr") {
3267
- return v.type === "invariant" || v.severity === "critical" || v.severity === "high";
3268
- }
3269
- return v.type === "invariant";
3270
- });
3477
+ if (raceResult === "timeout") {
3478
+ return {
3479
+ success: false,
3480
+ violations: accumulator.violations,
3481
+ checked: accumulator.checked,
3482
+ passed: accumulator.passed,
3483
+ failed: accumulator.failed,
3484
+ skipped: filesToVerify.length - accumulator.checked,
3485
+ duration: settings.timeout,
3486
+ warnings: accumulator.warnings,
3487
+ errors: accumulator.errors
3488
+ };
3489
+ }
3271
3490
  return {
3272
- success: !hasBlockingViolations,
3273
- violations: allViolations,
3274
- checked,
3275
- passed,
3276
- failed,
3277
- skipped,
3491
+ success: !hasBlockingViolations(accumulator.violations, settings.level),
3492
+ violations: accumulator.violations,
3493
+ checked: accumulator.checked,
3494
+ passed: accumulator.passed,
3495
+ failed: accumulator.failed,
3496
+ skipped: 0,
3278
3497
  duration: Date.now() - startTime,
3279
- warnings: allWarnings,
3280
- errors: allErrors
3498
+ warnings: accumulator.warnings,
3499
+ errors: accumulator.errors
3281
3500
  };
3282
3501
  }
3283
3502
  /**
3284
3503
  * Verify a single file
3285
3504
  */
3286
3505
  async verifyFile(filePath, decisions, severityFilter, cwd = process.cwd(), reporter, signal) {
3287
- const violations = [];
3288
- const warnings = [];
3289
- const errors = [];
3290
- if (signal?.aborted) {
3291
- return { violations, warnings, errors };
3292
- }
3293
- const sourceFile = await this.astCache.get(filePath, this.project);
3294
- if (!sourceFile) return { violations, warnings, errors };
3295
- let fileHash = null;
3296
- try {
3297
- const content = await readFile3(filePath, "utf-8");
3298
- fileHash = createHash2("sha256").update(content).digest("hex");
3299
- } catch {
3300
- fileHash = null;
3301
- }
3302
- for (const decision of decisions) {
3303
- for (const constraint of decision.constraints) {
3304
- if (!shouldApplyConstraintToFile({ filePath, constraint, cwd, severityFilter })) {
3305
- if (reporter) {
3306
- reporter.add({
3307
- file: filePath,
3308
- decision,
3309
- constraint,
3310
- applied: false,
3311
- reason: "File does not match scope pattern or severity filter"
3312
- });
3313
- }
3314
- continue;
3315
- }
3316
- const verifier = selectVerifierForConstraint(
3317
- constraint.rule,
3318
- constraint.verifier,
3319
- constraint.check
3320
- );
3321
- if (!verifier) {
3322
- const requestedVerifier = constraint.check?.verifier || constraint.verifier || "auto-detected";
3323
- this.logger.warn(
3324
- {
3325
- decisionId: decision.metadata.id,
3326
- constraintId: constraint.id,
3327
- requestedVerifier,
3328
- availableVerifiers: getVerifierIds()
3329
- },
3330
- "No verifier found for constraint"
3331
- );
3332
- warnings.push({
3333
- type: "missing_verifier",
3334
- message: `No verifier found for constraint (requested: ${requestedVerifier})`,
3335
- decisionId: decision.metadata.id,
3336
- constraintId: constraint.id,
3337
- file: filePath
3338
- });
3339
- if (reporter) {
3340
- reporter.add({
3341
- file: filePath,
3342
- decision,
3343
- constraint,
3344
- applied: false,
3345
- reason: `No verifier found (requested: ${requestedVerifier})`
3346
- });
3347
- }
3348
- continue;
3349
- }
3350
- let constraintViolations;
3351
- if (fileHash) {
3352
- const cacheKey = {
3353
- filePath,
3354
- decisionId: decision.metadata.id,
3355
- constraintId: constraint.id,
3356
- fileHash
3357
- };
3358
- const cached = this.resultsCache.get(cacheKey);
3359
- if (cached) {
3360
- constraintViolations = cached;
3361
- violations.push(...constraintViolations);
3362
- if (reporter) {
3363
- reporter.add({
3364
- file: filePath,
3365
- decision,
3366
- constraint,
3367
- applied: true,
3368
- reason: "Constraint matches file scope (cached)",
3369
- selectedVerifier: verifier.id
3370
- });
3371
- }
3372
- continue;
3373
- }
3374
- }
3375
- if (constraint.check?.verifier && constraint.check?.params) {
3376
- const pluginLoader2 = getPluginLoader();
3377
- const validationResult = pluginLoader2.validateParams(
3378
- constraint.check.verifier,
3379
- constraint.check.params
3380
- );
3381
- if (!validationResult.success) {
3382
- warnings.push({
3383
- type: "invalid_params",
3384
- message: validationResult.error,
3385
- decisionId: decision.metadata.id,
3386
- constraintId: constraint.id,
3387
- file: filePath
3388
- });
3389
- if (reporter) {
3390
- reporter.add({
3391
- file: filePath,
3392
- decision,
3393
- constraint,
3394
- applied: false,
3395
- reason: `Params validation failed: ${validationResult.error}`
3396
- });
3397
- }
3398
- continue;
3399
- }
3400
- }
3401
- const ctx = {
3402
- filePath,
3403
- sourceFile,
3404
- constraint,
3405
- decisionId: decision.metadata.id,
3406
- signal
3407
- };
3408
- const verificationStart = Date.now();
3409
- try {
3410
- constraintViolations = await verifier.verify(ctx);
3411
- violations.push(...constraintViolations);
3412
- if (fileHash) {
3413
- this.resultsCache.set(
3414
- {
3415
- filePath,
3416
- decisionId: decision.metadata.id,
3417
- constraintId: constraint.id,
3418
- fileHash
3419
- },
3420
- constraintViolations
3421
- );
3422
- }
3423
- if (reporter) {
3424
- reporter.add({
3425
- file: filePath,
3426
- decision,
3427
- constraint,
3428
- applied: true,
3429
- reason: "Constraint matches file scope",
3430
- selectedVerifier: verifier.id,
3431
- verifierOutput: {
3432
- violations: constraintViolations.length,
3433
- duration: Date.now() - verificationStart
3434
- }
3435
- });
3436
- }
3437
- } catch (error) {
3438
- const errorMessage = error instanceof Error ? error.message : String(error);
3439
- const errorStack = error instanceof Error ? error.stack : void 0;
3440
- this.logger.error(
3441
- {
3442
- verifierId: verifier.id,
3443
- filePath,
3444
- decisionId: decision.metadata.id,
3445
- constraintId: constraint.id,
3446
- error: errorMessage,
3447
- stack: errorStack
3448
- },
3449
- "Verifier execution failed"
3450
- );
3451
- errors.push({
3452
- type: "verifier_exception",
3453
- message: `Verifier '${verifier.id}' failed: ${errorMessage}`,
3454
- decisionId: decision.metadata.id,
3455
- constraintId: constraint.id,
3456
- file: filePath,
3457
- stack: errorStack
3458
- });
3459
- if (reporter) {
3460
- reporter.add({
3461
- file: filePath,
3462
- decision,
3463
- constraint,
3464
- applied: true,
3465
- reason: "Constraint matches file scope",
3466
- selectedVerifier: verifier.id,
3467
- verifierOutput: {
3468
- violations: 0,
3469
- duration: Date.now() - verificationStart,
3470
- error: errorMessage
3471
- }
3472
- });
3473
- }
3474
- }
3475
- }
3476
- }
3477
- return { violations, warnings, errors };
3478
- }
3479
- /**
3480
- * Verify multiple files
3481
- */
3482
- async verifyFiles(files, decisions, severityFilter, cwd, reporter, signal, onFileVerified) {
3483
- const BATCH_SIZE = 50;
3484
- for (let i = 0; i < files.length; i += BATCH_SIZE) {
3485
- if (signal.aborted) {
3486
- break;
3487
- }
3488
- const batch = files.slice(i, i + BATCH_SIZE);
3489
- const results = await Promise.all(
3490
- batch.map((file) => this.verifyFile(file, decisions, severityFilter, cwd, reporter, signal))
3491
- );
3492
- for (const result of results) {
3493
- onFileVerified(result.violations, result.warnings, result.errors);
3506
+ return verifySingleFile(
3507
+ {
3508
+ filePath,
3509
+ decisions,
3510
+ severityFilter,
3511
+ cwd,
3512
+ reporter,
3513
+ signal
3514
+ },
3515
+ {
3516
+ project: this.project,
3517
+ astCache: this.astCache,
3518
+ resultsCache: this.resultsCache,
3519
+ logger: this.logger
3494
3520
  }
3495
- }
3521
+ );
3496
3522
  }
3497
3523
  /**
3498
3524
  * Get registry
@@ -3500,6 +3526,34 @@ var VerificationEngine = class {
3500
3526
  getRegistry() {
3501
3527
  return this.registry;
3502
3528
  }
3529
+ async ensurePluginsLoaded(cwd) {
3530
+ if (this.pluginsLoaded) {
3531
+ return;
3532
+ }
3533
+ await getPluginLoader().loadPlugins(cwd);
3534
+ this.pluginsLoaded = true;
3535
+ }
3536
+ createAccumulator() {
3537
+ return {
3538
+ violations: [],
3539
+ warnings: [],
3540
+ errors: [],
3541
+ checked: 0,
3542
+ passed: 0,
3543
+ failed: 0
3544
+ };
3545
+ }
3546
+ addFileResult(accumulator, result) {
3547
+ accumulator.violations.push(...result.violations);
3548
+ accumulator.warnings.push(...result.warnings);
3549
+ accumulator.errors.push(...result.errors);
3550
+ accumulator.checked++;
3551
+ if (result.violations.length > 0) {
3552
+ accumulator.failed++;
3553
+ } else {
3554
+ accumulator.passed++;
3555
+ }
3556
+ }
3503
3557
  };
3504
3558
  function createVerificationEngine(registry) {
3505
3559
  return new VerificationEngine(registry);