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
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
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
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.
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
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
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 [];
},
});
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.