Skip to content

Latest commit

 

History

History
214 lines (169 loc) · 8.21 KB

File metadata and controls

214 lines (169 loc) · 8.21 KB

Plugin API RFC

Status: Shipped (v1) — the loader, pluginApiVersion validation, the DEBTLENS_DISABLE_PLUGINS escape hatch, plugin threshold defaults (#73), vocabulary merging (#74), and language-aware plugin routing are implemented.

Problem

Teams want custom DebtLens rules without forking the CLI. The original limitation was that all detectors were hardcoded in src/detectors/index.ts and configuration was JSON-only (SECURITY.md). Built-in detectors still ship from that registry, while explicit local plugins now extend the runtime through the shipped loader.

Goals

  • Third-party rules use the same Detector interface as built-ins.
  • Plugin loading is explicit in config — no implicit discovery.
  • Versioned API surface with fail-fast mismatch errors.
  • Local ESM modules only by default; no remote fetch at load time.

Non-goals (v1 of plugins)

  • npm package auto-discovery
  • JavaScript/TypeScript config files that execute on load
  • Sandboxed WASM rules

Detector contract

Plugins export detectors matching the built-in interface (src/core/types.ts):

export interface Detector {
  id: string;
  name: string;
  description: string;
  defaultSeverity: Severity;
  tags: string[];
  languages?: SourceLanguage[];
  detect: (context: DetectorContext) => Promise<DebtIssue[]> | DebtIssue[];
}

When languages is omitted, the detector receives TypeScript/JavaScript files. Plugins can declare any registered source language in SUPPORTED_SOURCE_LANGUAGES; DebtLens validates the ids during plugin load and uses them for file discovery and detector routing.

DetectorContext provides:

  • project — ts-morph Project
  • files — parsed SourceFileInfo[]
  • options — merged ScanOptions
  • getThreshold(key, fallback) — numeric threshold helper
  • addWarning(message) — non-fatal scan warnings

Issues must include message, severity, confidence, file, location, evidence, and suggestion per CONTRIBUTING.md.

Config shape (shipped)

{
  "$schema": "https://raw.githubusercontent.com/ColumbusLabs/DebtLens/main/schema/debtlens.config.schema.json",
  "pluginApiVersion": 1,
  "plugins": ["./debtlens-rules/custom-rule.mjs"],
  "rules": ["duplicate-logic", "custom-boundary-rule"]
}
Field Meaning
pluginApiVersion Must match the DebtLens runtime plugin API version
plugins Array of paths to ESM modules, resolved relative to the config file directory

Each plugin module default-exports either:

  • a single Detector, or
  • { rules: Detector[], thresholds?: Record<string, number>, vocabulary?: Record<string, string[]> }

thresholds supplies defaults for context.getThreshold keys; they merge after built-in defaults, so user config thresholds and the --threshold flag override them. vocabulary contributes naming-drift concept groups; user config groups with the same id override plugin groups. When multiple plugins set the same threshold key or concept id, the later plugin wins and a warning is emitted.

Loading model

  1. Parse JSON config (no require() of user JS).
  2. If plugins present, for each path:
    • Resolve to absolute path under config directory
    • import() the module (Node ESM)
    • Validate exported detectors (required fields, unique ids)
  3. Merge plugin detectors with built-in registry; explicit rules array selects which run.
  4. On pluginApiVersion mismatch → throw with clear upgrade message.

Built-in detectors always register first; plugin ids must not collide with built-in ids unless intentionally replacing (replacement deferred — collision is an error in v1).

Versioning

  • pluginApiVersion in config matches DEBTLENS_PLUGIN_API_VERSION in the CLI (integer, bumped on breaking detector-context changes).
  • Patch/minor DebtLens releases keep the same plugin API version when DetectorContext is unchanged.
  • Changelog documents API version bumps.

Security

Align with SECURITY.md:

  • No network during plugin load.
  • No arbitrary code from config values — only explicit plugins paths.
  • Paths must stay within the repository (reject .. traversal outside repo root).
  • CI environments may set DEBTLENS_DISABLE_PLUGINS=1 to skip loading entirely; built-in rules still run and a single stderr note is emitted when configured plugins are skipped.

Untrusted repos: treat plugins like any local code — only enable in trusted pipelines.

Minimal example plugin

// debtlens-rules/no-console.mjs
/** @type {import("debtlens/plugin").Detector} */
export const noConsoleDetector = {
  id: "no-console",
  name: "No console",
  description: "Flags console.log in production source.",
  defaultSeverity: "low",
  tags: ["hygiene"],
  detect(context) {
    const issues = [];
    for (const file of context.files) {
      if (file.content.includes("console.log")) {
        issues.push({
          id: `dl_nc_${file.relativePath}`,
          ruleId: "no-console",
          ruleName: "No console",
          severity: "low",
          confidence: 0.85,
          message: "console.log found in source.",
          file: file.relativePath,
          location: { startLine: 1 },
          tags: ["hygiene"],
          suggestion: "Remove debug logging or route through a logger.",
        });
      }
    }
    return issues;
  },
};

export default { rules: [noConsoleDetector] };

A runnable version of this plugin lives in examples/plugin/, and types ship from the published debtlens/plugin entry point (import type { Detector } from "debtlens/plugin").

Language-aware plugin example

// debtlens-rules/python-marker.mjs
import { SUPPORTED_SOURCE_LANGUAGES } from "debtlens/plugin";

if (!SUPPORTED_SOURCE_LANGUAGES.includes("python")) {
  throw new Error("This plugin requires Python support from DebtLens.");
}

/** @type {import("debtlens/plugin").Detector} */
export const pythonMarkerDetector = {
  id: "python-marker",
  name: "Python marker",
  description: "Flags a local marker in Python files.",
  defaultSeverity: "low",
  tags: ["python", "policy"],
  languages: ["python"],
  detect(context) {
    return context.files
      .filter((file) => file.content.includes("PY_MARKER"))
      .map((file) => ({
        id: `dl_py_marker_${file.relativePath}`,
        ruleId: "python-marker",
        ruleName: "Python marker",
        severity: "low",
        confidence: 0.9,
        message: "Python marker found.",
        file: file.relativePath,
        location: { startLine: 1 },
        tags: ["python", "policy"],
        suggestion: "Remove the marker or encode the policy as a real detector.",
      }));
  },
};

export default { rules: [pythonMarkerDetector] };

Config can run this beside a built-in pack:

{
  "pluginApiVersion": 1,
  "plugins": ["./debtlens-rules/python-marker.mjs"],
  "pack": "core"
}

Implementation phases

  1. RFC merged (this document) — no runtime change ✅
  2. Loader prototypeimport() + validation ✅ (#68)
  3. Schema + docsplugins / pluginApiVersion in JSON schema ✅ (#69)
  4. Example pluginexamples/plugin/ + integration test ✅ (#72)
  5. Stable exportdebtlens/plugin types entry point ✅ (#70)
  6. Language-aware plugins — public SourceLanguage, SUPPORTED_SOURCE_LANGUAGES, validation, and discovery/routing ✅

Open questions

  • Should plugins export threshold defaults? Shipped (#73): thresholds export merges after built-in defaults, before user config.
  • Allow vocabulary packs from plugins? Shipped (#74): vocabulary export merges below user config groups.
  • Per-plugin enable flags vs flat rules list?

Track decisions in issue #26.