Validators

Validators are TypeScript functions. They read repository facts and emit findings. Structured data from source files, such as imports, exports, comments, calls, and literals. There is no DSL.

Anatomy

service-no-db-client.ts
ts
import { defineValidator } from "../index.ts";
 
export default defineValidator({
  id: "service-no-db-client",
  topics: ["service", "data-access"],
  applies: ["src/services/**/*.ts"],
  severity: "error",
  scope: "import-edge",
  facts: ["imports"],
  decisionIds: ["service-db-boundary"],
  validate({ ctx }) {
    return ctx.facts.imports()
      .filter((edge) => edge.source.endsWith("/db/client"))
      .map((edge) => ({
        validatorId: "service-no-db-client",
        severity: "error",
        file: edge.from.path,
        line: edge.line,
        message: "Services must not import DB clients directly.",
      }));
  },
});

Facts, not source

Validators receive ctx.facts. Facts are extracted by the engine binary and cached by the daemon.

Graph-aware rules

graph-validator.ts
ts
validate({ ctx }) {
  return ctx.graph.callers("loadCompany").map((edge) =>
    edge.source.file.report({
      line: edge.source.line,
      message: "loadCompany callers must use the new service boundary.",
      fix: {
        safety: "manual",
        command: "opencanon graph callees loadCompany",
        description: "Inspect downstream side effects before changing this call.",
      },
    }),
  );
}

Validators can use ctx.graph for symbols, references, callers, callees, and impact edges. Command fixes are advisory: OpenCanon prints them for an agent but never executes them through --fix.

Curated factories

validators/index.ts
ts
import { defineValidator, migrationReferences, noUnusedExports, similarFunctionNames } from "../index.ts";
 
export default defineValidator({
  id: "project-validators",
  validators: [
    migrationReferences({
      id: "old-api-migration",
      topics: ["migration"],
      severity: "error",
      in: ["src/**/*.ts"],
      pattern: "\\boldApi\\(",
      replacement: "currentApi(",
      fixSafety: "suggested",
      message: "oldApi is replaced; use currentApi.",
    }),
    noUnusedExports({
      id: "no-unused-exports",
      topics: ["dead-code"],
      severity: "warning",
      in: ["src/**/*.ts"],
      publicSurfaces: ["src/api/**"],
      message: "Exported symbol has no known project caller.",
    }),
    similarFunctionNames({
      id: "similar-functions",
      topics: ["dry"],
      severity: "warning",
      in: ["src/**/*.ts"],
      requireSharedCallees: true,
      message: "Similar function surfaces may duplicate behavior.",
    }),
  ],
});

Curated factories are opt-in. migrationReferences links old API usage to the baseline so existing matches can warn while new matches fail, and can emit structured replacement fixes when a replacement is configured. noUnusedExports uses graph callers and respects configured entrypoints and public surfaces. similarFunctionNames uses symbol and callee facts to flag likely DRY overlaps inside a scope.

Fixtures

Every validator is paired with at least one fixture under .agents/skills/opencanon/fixtures/. Fixtures pin expected output. Run opencanon validate --check-fixtures locally or in CI.

fixtures/auth-session-ttl/invalid.ts
ts
import { defineFixture } from "@opencanon/core/testing";
 
export default defineFixture({
  directories: ["src/services", "src/db"],
  files: ({ file }) => [
    file.ts("src/services/company.service.ts", `
      import { db } from "../db/client";
 
      export function loadCompany(id: string) {
        return db.company.findUnique({ where: { id } });
      }
    `),
    file.json("package.json", {
      dependencies: {
        zod: "^4.0.0"
      }
    }),
  ],
});

Fixtures are virtual projects. Put flat modules named valid.ts, invalid.ts, and optional fixed.ts under the validator fixture directory. Use file.ts, file.tsx, file.py, file.rs, file.toml, file.md, and file.json for readable test projects. Generated fixture aliases let realistic imports type-check without local as any stubs.

Typed project constants

@opencanon/project
ts
import { defineValidator } from "@opencanon/core";
import { Npm, Packages, Python, Cargo } from "@opencanon/project";
 
export default defineValidator({
  id: "typed-dependency-policy",
  topics: ["dependencies"],
  applies: ["packages/api/src/**/*.ts"],
  severity: "error",
  scope: "import-edge",
  facts: ["imports"],
  validate({ ctx }) {
    const apiPackage = Packages.API;
    const zodVersion = Npm.ZOD.version;
 
    return ctx.facts.imports()
      .filter((edge) => edge.fromPackage === apiPackage)
      .filter((edge) => edge.source === Npm.ZOD.name)
      .map((edge) => edge.from.report({
        line: edge.line,
        message: `API imports ${Npm.ZOD.name} ${zodVersion}; Rust uses ${Cargo.SERDE.name} and Python uses ${Python.REQUESTS.name}.`,
      }));
  },
});

The daemon generates @opencanon/project from the indexed repository. Validators can use typed constants for workspace packages, package roots, import specifiers, npm dependencies, Rust crates, Cargo dependencies, and Python dependencies. Generated objects include JSDoc from package metadata where available and stay intentionally lightweight: OpenCanon does not generate huge symbol, literal, caller, or callee maps by default.

Commit gates

commit-gate-validator.ts
ts
export default defineValidator({
  id: "auth-session-ttl-approval",
  topics: ["security"],
  applies: ["src/auth/session.ts"],
  severity: "error",
  scope: "file",
  facts: ["literals"],
  validate({ ctx }) {
    for (const file of ctx.targetFiles) {
      if (!file.text.includes("sessionTtlDays")) continue;
 
      ctx.commitGate({
        id: "auth-session-ttl-change",
        title: "Auth session TTL changed",
        reason: "Session duration affects security and product behavior.",
        question: "Did the user approve this auth session TTL change?",
        file: file.path,
        evidence: [{ file: file.path }],
        approvalScope: "staged-diff",
      });
    }
 
    return [];
  },
});
approval flow
sh
opencanon validate --changed
opencanon gate pending --format json
opencanon gate approve auth-session-ttl-change \
  --summary "User approved changing sessionTtlDays from 365 to 730." \
  --via agent

Commit gates are for ambiguous changes that require user intent before commit but should not become normal findings. Approvals default to the exact staged staged diff for the gate evidence; use approvalScope: "file" only when the full current file content is the intended approval boundary. Agents should inspect the staged diff and pending gate, ask for explicit Approve or Reject, then record approval only after the user approves.

Decisions

Validators reference decisions by ID. A decision explains why the rule exists and when it has exceptions.