Files
vercel__eve/scripts/guard-invariants.mjs
T
2026-09-03 16:48:41 +00:00

1756 lines
63 KiB
JavaScript

#!/usr/bin/env node
/**
* Mechanical enforcement of framework code invariants.
*
* Several framework invariants can be checked mechanically. Each one gets a
* dedicated guard here. Every guard prints an error message that explains
* *why* the invariant exists and how to fix the violation, so contributors
* can self-correct without needing a reviewer to re-explain the rule.
*
* The numeric rule IDs below are stable identifiers for these lints, tied to
* the underlying invariant.
*
* rule 9 — No symlinks anywhere in the repo. (Rationale: symlinks are
* too unpredictable for a framework to rely on; replace with a
* real file or a small loader.)
* rule 13 — No spread-ternary object composition
* (`...(c ? {} : { k: v })`). (Rationale: hard to read, easy
* to mistype; declare the object then assign optional keys.)
* rule 15 — No `@workflow/*` imports inside `src/channel/**`,
* `src/harness/**`, or `src/tracing/**`. Channels, harnesses,
* and tracing must stay workflow-agnostic — only
* runtime/execution code touches workflow primitives.
* rule 19 — No `new AsyncLocalStorage()` outside the two allowlisted
* files. All ambient runtime state flows through a single
* `EveContext`.
* rule 21 — No authored `name:` (or `id:`) field on
* `defineMcpClientConnection`, `defineTool`, `defineSkill`,
* `defineSandbox`, `defineSchedule`, `defineAgent`, or
* `defineEval` calls inside authored
* agent trees (`apps/<name>/agent/**`,
* `apps/<category>/<name>/agent/**`,
* `apps/<name>/evals/**`,
* `apps/<category>/<name>/evals/**`, or a top-level `agent/**` /
* `evals/**` directory). Identity is derived from the file
* path (or, for the root agent, from the package name); an
* authored field creates a redundant source of truth that
* can drift. Evals also reject `id:` because eval
* identity comes from the path under `evals/`.
* rule 23 — No new `as unknown as T` double casts (ratcheted via
* baseline). Double casts hide real type errors.
* rule 25 — No new direct calls to `installBundledCompiledArtifacts`,
* `resetBundledCompiledArtifacts`, or
* `clearProcessDefaultRuntimeSession` from test bodies.
* Tests must scope runtime state through
* `createTestRuntime().run(fn)` / `withRuntimeSession(...)`.
* rule 26 — No `loadContext() as ContextContainer` casts. Thread a
* `ContextContainer` parameter through instead.
* rule 27 — No `state:` field on hook lifecycle result types in
* `packages/eve/src/public/definitions/hook.ts`. Hook
* return shapes must carry only what the harness consumes;
* durable state belongs on `ctx.eve`.
* rule 28 — Imports under `packages/eve/src/setup/scaffold/**` stay within
* their layer: node:* builtins, relative siblings, and eve's
* vendored integration catalog. The scaffold stays free of
* framework runtime, compiler, terminal UI, and provider SDK
* dependencies.
* rule 29 — Changeset package keys must match workspace package names.
* Release metadata is consumed before `pnpm release`, so bad
* changeset package names must fail in PR CI rather than in the
* post-merge release workflow.
* rule 30 — The compiled-vendor pipeline (`scripts/vendor-compiled/**`)
* must not write a per-package `package.json` into a vendored
* output directory. Such a file creates a package scope that
* shadows eve's `#compiled/*` imports map, so a cross-package
* `#compiled/<pkg>` reference inside one vendored `.d.ts`
* (e.g. `@workflow/core` → `@workflow/world` → `zod`) silently
* degrades to `any` under `skipLibCheck`. The bundled ESM
* inherits `"type": "module"` from eve's root package.json, so
* no per-package file is needed. See `prepareCompiledModule`.
* rule 31 — Active source and docs must not reference the removed
* `create-eve` package or `eve setup` command. Use `eve init`
* for project creation and the dedicated current commands
* (`eve link`, `eve channels add`, `eve deploy`) afterward.
* Changelogs and changesets are historical records and excluded.
* rule 32 — Every Markdown file under `research/` must have valid YAML
* frontmatter with non-empty `issue` and `status` fields plus an
* ISO `last_updated` date. Research documents are implementation
* plans attached to tracked GitHub work, not an unowned parallel
* backlog.
* rule 33 — Workflow runtime imports and queue-namespace environment writes
* must go through the `src/internal/workflow/runtime.ts` facade and
* `queue-namespace.ts`. The generated agent bootstrap installs the
* agent-scoped namespace before queue-producing APIs can run.
* rule 34 — `phase` stays a runtime-only dependency. No file under the Eve\n * logo renderer's GPU/runtime boundary (render/, shaders/, or the\n * offline render harness) may import the `phase` package. This keeps\n * the mechanical separation between the lifecycle layer and the GPU\n * renderer enforceable.
* rule 35 — No direct `#compiled/gray-matter` imports outside the
* `internal/helpers/gray-matter.ts` wrapper. gray-matter's default
* engines `eval()` a `---js` frontmatter fence, so every call must
* route through `parseFrontmatter`, which is safe by default. A
* direct import lets untrusted input reach an evaluating engine.
* rule 36 — Extension capability epochs have immutable hashed API metadata
* and explicit support history. The current hash must match the
* authoring roots, every historical epoch must be supported or
* dropped, every retained epoch needs a compiling fixture, and
* every public authoring value must belong to a capability.
* rule 37 — Instrumentation ownership stays provider-neutral and outside the
* harness. The lifecycle contract must not import from `ai`, harness
* code may import only runtime facade types, and execution may use
* only runtime entrypoints and cancellation-state preservation.
* rule 38 — Workspace build scripts must not launch a nested
* `pnpm --filter eve build`. Turbo owns workspace dependency
* ordering; nested builds race on eve's clean-and-publish dist
* directory and let consumers observe a partial package.
* rule 40 — Wire schemas and version-bound encoders are immutable protocol
* data. Pure `*.vN.migration.ts` transforms are immutable data too;
* version selection, chain assembly, and realm normalization remain
* editable policy. Every data module must carry a colocated test.
* The session-inbox registry must be contiguous, name every schema
* module, and identify its highest version as current. Wire versions
* are append-only protocol history: change the contract by adding a
* version and migration, never by updating historical data and its
* snapshot together. The workflow-safe decoder must not import
* schemas or validation libraries at runtime.
* rule 42 — The shared subagent workflow body is framework-authored
* userspace. It must not import task, harness, or context
* internals or recover private state through `Symbol.for`.
* Privileged dispatch belongs in ordinary step-backed APIs that
* the workflow body consumes through a public contract.
* rule 43 — Reusable session plumbing stays independent of the subagent
* executor. The generic inbox and state cursor must not
* import subagent modules; session/turn composition roots may
* compose built-in executors directly.
*
* Baselines for rules with pre-existing violations live in
* `guard-invariants-baseline.json`. Counts and allowlists in that file
* may only shrink (as offenders are removed) — they may never grow.
*/
import { execFileSync } from "node:child_process";
import { glob, readFile, readdir, lstat } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import matter from "gray-matter";
import { checkExtensionCapabilityContracts } from "./extension-capability-contracts.mjs";
const require = createRequire(import.meta.url);
const extractorRequire = createRequire(require.resolve("@microsoft/api-extractor/package.json"));
const ts = extractorRequire("typescript");
const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "../..");
const BASELINE_PATH = join(REPO_ROOT, "scripts/guard-invariants-baseline.json");
const SKIP_DIRS = new Set([
"node_modules",
".git",
".turbo",
".eve",
".next",
".nitro",
".output",
"dist",
"build",
"coverage",
".vercel",
]);
/** @typedef {{ rule: number; file: string; line?: number; message: string }} Violation */
/**
* Recursively walk the workspace, yielding regular files.
* Skips well-known build/dependency directories.
*
* @param {string} root
* @returns {AsyncGenerator<{ absPath: string; relPath: string; stat: import("node:fs").Stats }>}
*/
async function* walkFiles(root) {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
const absPath = join(root, entry.name);
if (entry.isSymbolicLink()) {
const stat = await lstat(absPath);
yield { absPath, relPath: relative(REPO_ROOT, absPath), stat };
continue;
}
if (entry.isDirectory()) {
yield* walkFiles(absPath);
} else if (entry.isFile()) {
const stat = await lstat(absPath);
yield { absPath, relPath: relative(REPO_ROOT, absPath), stat };
}
}
}
/**
* Normalize a relative path to forward slashes so baselines stay
* portable across Windows and POSIX.
*
* @param {string} relPath
*/
function toPosix(relPath) {
return sep === "/" ? relPath : relPath.split(sep).join("/");
}
/**
* @param {string} relPath
*/
function isTsLike(relPath) {
return /\.(ts|tsx|mts|cts)$/.test(relPath) && !relPath.endsWith(".d.ts");
}
/**
* Walks the working copy once and feeds each TypeScript file through the
* per-file rule checks. Rule 9 (symlinks) and rule 23 (file-count baseline)
* also run during the walk.
*
* @param {{
* rule13: { baseline: Record<string, number>; current: Map<string, number> };
* rule15: Violation[];
* rule19: { allowlist: Set<string>; current: Set<string>; lines: Map<string, number> };
* rule21: { allowlist: Set<string>; violations: Violation[] };
* rule23: { baseline: Record<string, number>; current: Map<string, number> };
* rule25: { allowlist: Set<string>; new: Map<string, number> };
* rule26: Violation[];
* rule27: Violation[];
* rule28: Violation[];
* rule33: Violation[];
* rule35: Violation[];
* rule37: Violation[];
* rule42: Violation[];
* rule43: Violation[];
* symlinks: string[];
* }} state
*/
async function scanRepo(state) {
for await (const { absPath, relPath, stat } of walkFiles(REPO_ROOT)) {
const posix = toPosix(relPath);
if (stat.isSymbolicLink()) {
state.symlinks.push(posix);
continue;
}
if (!isTsLike(posix)) continue;
const content = await readFile(absPath, "utf8");
const lines = content.split(/\r?\n/);
checkRule13(posix, lines, state.rule13);
checkRule15(posix, lines, state.rule15);
checkRule19(posix, lines, state.rule19);
checkRule21(posix, lines, state.rule21.allowlist, state.rule21.violations);
checkRule23(posix, lines, state.rule23);
checkRule25(posix, lines, state.rule25);
checkRule26(posix, lines, state.rule26);
checkRule27(posix, lines, state.rule27);
checkRule28(posix, lines, state.rule28);
checkRule33(posix, lines, state.rule33);
checkRule35(posix, lines, state.rule35);
checkRule37(posix, content, state.rule37);
checkRule42(posix, lines, state.rule42);
checkRule43(posix, lines, state.rule43);
}
}
// ---------- Rule 42: userspace subagent workflow ----------
const SUBAGENT_WORKFLOW_PATH = "packages/eve/src/runtime/subagents/workflow.ts";
const SUBAGENT_WORKFLOW_PRIVATE_IMPORT_RE =
/["']#(?:tasks|execution|harness|context|shared)(?:\/|\.js)/;
// The shared body owns its invocation id, so it consumes the framework-internal
// entry rather than the public `agent()`; that import is the one exception.
const SUBAGENT_WORKFLOW_ALLOWED_IMPORT = '"#execution/tools/subagent/invoke-agent.js"';
/**
* @param {string} posix
* @param {string[]} lines
* @param {Violation[]} violations
*/
function checkRule42(posix, lines, violations) {
if (posix !== SUBAGENT_WORKFLOW_PATH) return;
lines.forEach((line, idx) => {
if (line.includes(SUBAGENT_WORKFLOW_ALLOWED_IMPORT)) return;
if (!SUBAGENT_WORKFLOW_PRIVATE_IMPORT_RE.test(line) && !line.includes("Symbol.for(")) return;
violations.push({
rule: 42,
file: posix,
line: idx + 1,
message:
"the shared subagent workflow reaches into task, harness, or context internals. Keep the body userspace-shaped and call a public workflow-safe agent API instead.",
});
});
}
// ---------- Rule 43: executor-neutral session plumbing ----------
// Matches both `#` alias specifiers and relative paths into the executor trees.
const SUBAGENT_IMPORT_RE =
/from ["'](?:#|(?:\.\.?\/)+(?:[\w-]+\/)*)(?:subagents|execution\/tools\/subagent|tools\/subagent)(?:\/|\.js|["'])/;
const RULE43_GENERIC_SESSION_FILES = new Set([
"packages/eve/src/execution/session-command-inbox.ts",
"packages/eve/src/execution/session-state-cursor.ts",
]);
/**
* @param {string} posix
* @param {string[]} lines
* @param {Violation[]} violations
*/
function checkRule43(posix, lines, violations) {
if (!RULE43_GENERIC_SESSION_FILES.has(posix)) return;
lines.forEach((line, idx) => {
if (!SUBAGENT_IMPORT_RE.test(line)) return;
violations.push({
rule: 43,
file: posix,
line: idx + 1,
message:
"generic session plumbing imports the subagent executor. Move executor-specific behavior to composition roots or subagent-owned modules.",
});
});
}
// ---------- Rule 13: spread-ternary object composition ----------
/** Matches `...(<expr> ? {} : { ... })` or the mirrored form. */
const SPREAD_TERNARY_RE = /\.\.\.\([^()\n]*\?[^()\n]*:\s*\{/;
/**
* @param {string} posix
* @param {string[]} lines
* @param {{ baseline: Record<string, number>; current: Map<string, number> }} state
*/
function checkRule13(posix, lines, state) {
let count = 0;
for (const line of lines) {
if (SPREAD_TERNARY_RE.test(line)) count++;
}
if (count > 0) state.current.set(posix, count);
}
// ---------- Rule 15: workflow primitives outside runtime/execution ----------
const WORKFLOW_IMPORT_RE = /from ["']@workflow\b/;
/**
* @param {string} posix
*/
function isChannelOrHarness(posix) {
return (
posix.startsWith("packages/eve/src/channel/") ||
posix.startsWith("packages/eve/src/harness/") ||
posix.startsWith("packages/eve/src/tracing/")
);
}
/**
* @param {string} posix
* @param {string[]} lines
* @param {Violation[]} violations
*/
function checkRule15(posix, lines, violations) {
if (!isChannelOrHarness(posix)) return;
lines.forEach((line, idx) => {
if (WORKFLOW_IMPORT_RE.test(line)) {
violations.push({
rule: 15,
file: posix,
line: idx + 1,
message: `imports from "@workflow/*". Channel, harness, and tracing code must stay workflow-agnostic. Move the workflow primitive call into src/runtime/ or src/execution/ and have the caller use a thin runtime helper instead.`,
});
}
});
}
// ---------- Rule 33: namespaced Workflow runtime boundary ----------
const RAW_WORKFLOW_RUNTIME_SPECIFIER_RE =
/["'](?:#compiled\/@workflow\/core\/runtime(?:\.js|\/[^"']+\.js)|@workflow\/core\/runtime(?:\/[^"']+)?|workflow\/(?:api|runtime))["']/;
const WORKFLOW_QUEUE_NAMESPACE_WRITE_RE =
/process\.env(?:\.WORKFLOW_QUEUE_NAMESPACE|\[\s*(?:WORKFLOW_QUEUE_NAMESPACE_ENV|["']WORKFLOW_QUEUE_NAMESPACE["'])\s*\])\s*=/;
const WORKFLOW_RUNTIME_FACADES = new Set(["packages/eve/src/internal/workflow/runtime.ts"]);
const WORKFLOW_QUEUE_NAMESPACE_MODULE = "packages/eve/src/internal/workflow/queue-namespace.ts";
/**
* @param {string} posix
* @param {string[]} lines
* @param {Violation[]} violations
*/
function checkRule33(posix, lines, violations) {
// The single-runtime-identity boundary is eve's own source. Application code
// (fixtures, templates) legitimately imports the public `workflow/api`
// surface, which eve's bundler resolves to its own runtime.
if (!posix.startsWith("packages/eve/src/")) return;
lines.forEach((line, idx) => {
const isTypeOnlyImport = /^\s*(?:import|export)\s+type\b/.test(line);
const isRuntimeImport =
/^(?:import|export)\b|^}\s*from\b|\b(?:import|require)\s*\(/.test(line.trimStart()) &&
RAW_WORKFLOW_RUNTIME_SPECIFIER_RE.test(line);
if (!WORKFLOW_RUNTIME_FACADES.has(posix) && !isTypeOnlyImport && isRuntimeImport) {
violations.push({
rule: 33,
file: posix,
line: idx + 1,
message: `imports the raw Workflow runtime. Import from "#internal/workflow/runtime.js" to preserve eve's single Workflow runtime package identity.`,
});
}
if (posix !== WORKFLOW_QUEUE_NAMESPACE_MODULE && WORKFLOW_QUEUE_NAMESPACE_WRITE_RE.test(line)) {
violations.push({
rule: 33,
file: posix,
line: idx + 1,
message: `writes WORKFLOW_QUEUE_NAMESPACE outside the canonical namespace module. Use installEveWorkflowQueueNamespace() so every queue surface derives the same agent-scoped value.`,
});
}
});
}
// ---------- Rule 35: direct gray-matter imports ----------
const GRAY_MATTER_SPECIFIER_RE = /["']#compiled\/gray-matter(?:\/[^"']+)?["']/;
const GRAY_MATTER_FACADE = "packages/eve/src/internal/helpers/gray-matter.ts";
/**
* @param {string} posix
* @param {string[]} lines
* @param {Violation[]} violations
*/
function checkRule35(posix, lines, violations) {
if (posix === GRAY_MATTER_FACADE) return;
lines.forEach((line, idx) => {
const isImport =
/^(?:import|export)\b|^}\s*from\b|\b(?:import|require)\s*\(/.test(line.trimStart()) &&
GRAY_MATTER_SPECIFIER_RE.test(line);
if (isImport) {
violations.push({
rule: 35,
file: posix,
line: idx + 1,
message: `imports "#compiled/gray-matter" directly. gray-matter's default engines eval() a \`---js\` frontmatter fence, so parse through parseFrontmatter() from "#internal/helpers/gray-matter.js" instead — it is safe by default and takes an explicit { allowCodeEngines: true } opt-in for trusted input.`,
});
}
});
}
// ---------- Rule 37: instrumentation lifecycle provider boundary ----------
const INSTRUMENTATION_LIFECYCLE_CONTRACT = "packages/eve/src/instrumentation/lifecycle.ts";
const HARNESS_RUNTIME_IMPORTS = new Map([
["InstrumentationAttempt", "type"],
["InstrumentationStepScope", "type"],
["SessionInstrumentation", "type"],
]);
const EXECUTION_INSTRUMENTATION_IMPORTS = new Map([
[
"#instrumentation/runtime.js",
new Map([
["bindSessionInstrumentation", "value"],
["ExecutionInstrumentation", "type"],
["initializeSessionInstrumentation", "value"],
]),
],
["#instrumentation/state.js", new Map([["preserveSerializedInstrumentationState", "value"]])],
]);
/**
* @param {string} posix
* @param {string} source
* @param {Violation[]} violations
*/
function checkRule37(posix, source, violations) {
const productionHarness =
posix.startsWith("packages/eve/src/harness/") &&
!/\.(?:test|integration\.test|scenario\.test)\.ts$/.test(posix);
const productionExecution =
posix.startsWith("packages/eve/src/execution/") &&
!/\.(?:test|integration\.test|scenario\.test)\.ts$/.test(posix);
if (posix !== INSTRUMENTATION_LIFECYCLE_CONTRACT && !productionHarness && !productionExecution) {
return;
}
const sourceFile = ts.createSourceFile(
posix,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const visit = (node) => {
const specifier = importSpecifier(node);
if (
posix === INSTRUMENTATION_LIFECYCLE_CONTRACT &&
specifier !== undefined &&
(specifier.text === "ai" || specifier.text.startsWith("ai/"))
) {
violations.push({
rule: 37,
file: posix,
line: sourceFile.getLineAndCharacterOfPosition(specifier.getStart(sourceFile)).line + 1,
message: `imports from "ai". Lifecycle event payloads are eve's own shape, so an AI SDK type reaching them makes an SDK upgrade a breaking change for every provider. Add an eve type here and map to it in ai-sdk-hook-bridge.ts.`,
});
}
if (
productionHarness &&
specifier?.text.startsWith("#instrumentation/") === true &&
specifier.text !== "#instrumentation/runtime.js"
) {
violations.push({
rule: 37,
file: posix,
line: sourceFile.getLineAndCharacterOfPosition(specifier.getStart(sourceFile)).line + 1,
message: `imports "${specifier.text}" directly. Harness code may consume instrumentation only through the bound SessionInstrumentation facade from "#instrumentation/runtime.js".`,
});
}
if (productionHarness && specifier?.text.startsWith("#tracing/") === true) {
violations.push({
rule: 37,
file: posix,
line: sourceFile.getLineAndCharacterOfPosition(specifier.getStart(sourceFile)).line + 1,
message: `imports "${specifier.text}" directly. Tracing implementation belongs behind the bound instrumentation facade.`,
});
}
if (
productionHarness &&
specifier?.text === "#instrumentation/runtime.js" &&
!hasOnlyAllowedNamedImports(node, HARNESS_RUNTIME_IMPORTS)
) {
violations.push({
rule: 37,
file: posix,
line: sourceFile.getLineAndCharacterOfPosition(specifier.getStart(sourceFile)).line + 1,
message: `imports unsupported instrumentation runtime bindings. Harness code may use only the SessionInstrumentation, InstrumentationStepScope, and InstrumentationAttempt types.`,
});
}
if (productionHarness && specifier?.text.startsWith("#compiled/@opentelemetry/") === true) {
violations.push({
rule: 37,
file: posix,
line: sourceFile.getLineAndCharacterOfPosition(specifier.getStart(sourceFile)).line + 1,
message: `imports OpenTelemetry directly. OTel implementation belongs behind the bound instrumentation facade.`,
});
}
if (
productionExecution &&
specifier?.text.startsWith("#instrumentation/") === true &&
!hasOnlyAllowedNamedImports(
node,
EXECUTION_INSTRUMENTATION_IMPORTS.get(specifier.text) ?? new Map(),
)
) {
violations.push({
rule: 37,
file: posix,
line: sourceFile.getLineAndCharacterOfPosition(specifier.getStart(sourceFile)).line + 1,
message: `imports unsupported instrumentation bindings from "${specifier.text}". Execution may use only session binding/initialization, the ExecutionInstrumentation type, and cancellation-state preservation.`,
});
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
}
function hasOnlyAllowedNamedImports(node, allowed) {
if (!ts.isImportDeclaration(node)) return false;
const clause = node.importClause;
if (
clause === undefined ||
clause.name !== undefined ||
clause.namedBindings === undefined ||
!ts.isNamedImports(clause.namedBindings) ||
clause.namedBindings.elements.length === 0
) {
return false;
}
return clause.namedBindings.elements.every((element) => {
const imported = element.propertyName?.text ?? element.name.text;
const expectedKind = allowed.get(imported);
const actualKind = clause.isTypeOnly || element.isTypeOnly ? "type" : "value";
return expectedKind === actualKind;
});
}
function importSpecifier(node) {
if (
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
node.moduleSpecifier !== undefined &&
ts.isStringLiteralLike(node.moduleSpecifier)
) {
return node.moduleSpecifier;
}
if (
ts.isImportEqualsDeclaration(node) &&
ts.isExternalModuleReference(node.moduleReference) &&
node.moduleReference.expression !== undefined &&
ts.isStringLiteralLike(node.moduleReference.expression)
) {
return node.moduleReference.expression;
}
if (
ts.isImportTypeNode(node) &&
ts.isLiteralTypeNode(node.argument) &&
ts.isStringLiteralLike(node.argument.literal)
) {
return node.argument.literal;
}
if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === "require")) &&
node.arguments[0] !== undefined &&
ts.isStringLiteralLike(node.arguments[0])
) {
return node.arguments[0];
}
return undefined;
}
// ---------- Rule 40: wire versions carry colocated contract tests ----------
const WIRE_FAMILY_DIR = "packages/eve/src/execution/wire";
const SESSION_INBOX_WIRE_CONTRACT = `${WIRE_FAMILY_DIR}/session-inbox-contract.ts`;
const SESSION_INBOX_WIRE_DECODER = `${WIRE_FAMILY_DIR}/session-inbox-wire.ts`;
const VERSIONED_WIRE_HISTORY_RE = new RegExp(
`^${WIRE_FAMILY_DIR}/(?:__snapshots__/)?[a-z0-9-]+-wire\\.v\\d+(?:\\.migration)?(?:\\.test\\.ts(?:\\.snap)?|\\.ts)$`,
);
const RULE_40_ALLOWED_REWRITES = new Map([
[
`${WIRE_FAMILY_DIR}/session-inbox-wire.v1.ts`,
{
from: "5f110be5d7b488216c574a1aef9d2074d670efd2",
to: "7f5864f20e6bbb9f430c23918320ca6319c4cb14",
},
],
]);
const PURE_MIGRATION_IMPORTS = new Map([
["#execution/durable-session-migrations/chain.js", new Map([["VersionMigration", "type"]])],
["#shared/guards.js", new Map([["isObject", "value"]])],
]);
const WORKFLOW_DECODER_RUNTIME_IMPORTS = new Set([
"#execution/durable-session-migrations/chain.js",
"#execution/wire/session-inbox-contract.js",
"#execution/wire/session-inbox-wire.v0.js",
"#execution/wire/session-inbox-wire.v2-migration.js",
"#execution/wire/session-inbox-wire.v2.migration.js",
"#execution/wire/session-inbox-wire.v3.migration.js",
"#execution/wire/session-inbox-wire.v4.migration.js",
"#execution/wire/session-inbox-wire.v5.migration.js",
"#shared/guards.js",
]);
function gitOutput(args) {
try {
return execFileSync("git", args, {
cwd: REPO_ROOT,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
} catch {
return undefined;
}
}
function checkRule40ImmutableWireHistory() {
const hasBase = gitOutput(["rev-parse", "--verify", "origin/main"]) !== undefined;
const comparisons = [
{ args: ["diff", "--name-status", "--", WIRE_FAMILY_DIR], state: "worktree" },
{
args: ["diff", "--cached", "--name-status", "--", WIRE_FAMILY_DIR],
state: "index",
},
];
if (hasBase)
comparisons.push({
args: ["diff", "--name-status", "origin/main...HEAD", "--", WIRE_FAMILY_DIR],
state: "head",
});
const changes = new Set();
for (const { args, state } of comparisons) {
for (const line of (gitOutput(args) ?? "").trim().split("\n")) {
if (line !== "") changes.add(`${state}\t${line}`);
}
}
/** @type {Violation[]} */
const violations = [];
for (const change of changes) {
const [state, status, ...paths] = change.split("\t");
const protectedPaths = paths.filter((path) => VERSIONED_WIRE_HISTORY_RE.test(path));
if (protectedPaths.length === 0 || status === "A") continue;
if (protectedPaths.every((path) => isAllowedRule40Rewrite(path, state))) continue;
if (
hasBase &&
protectedPaths.every(
(path) => gitOutput(["cat-file", "-e", `origin/main:${path}`]) === undefined,
)
) {
continue;
}
violations.push({
rule: 40,
file: protectedPaths.at(-1),
line: 1,
message: `shipped wire-version history is immutable (git status ${status}). Add the next wire version and migration instead of changing or deleting an existing version module, contract test, or snapshot.`,
});
}
return violations;
}
function isAllowedRule40Rewrite(path, state) {
const rewrite = RULE_40_ALLOWED_REWRITES.get(path);
if (rewrite === undefined) return false;
const baseHash = gitOutput(["rev-parse", `origin/main:${path}`])?.trim();
const currentHash =
state === "head"
? gitOutput(["rev-parse", `HEAD:${path}`])?.trim()
: state === "index"
? gitOutput(["rev-parse", `:${path}`])?.trim()
: gitOutput(["hash-object", path])?.trim();
return baseHash === rewrite.from && currentHash === rewrite.to;
}
function checkRule40MigrationPurity(path, source) {
const sourceFile = ts.createSourceFile(
path,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
/** @type {Violation[]} */
const violations = [];
const visit = (node) => {
const specifier = importSpecifier(node);
if (specifier !== undefined) {
const allowed = PURE_MIGRATION_IMPORTS.get(specifier.text);
if (allowed === undefined || !hasOnlyAllowedNamedImports(node, allowed)) {
violations.push({
rule: 40,
file: path,
line: sourceFile.getLineAndCharacterOfPosition(specifier.getStart(sourceFile)).line + 1,
message: `imports "${specifier.text}". Versioned wire migrations are immutable data transforms, so they may import only the VersionMigration type or dependency-free shared guards. Move normalization and version-selection policy to the wire facade.`,
});
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
function checkRule40WorkflowDecoderImports(source) {
const sourceFile = ts.createSourceFile(
SESSION_INBOX_WIRE_DECODER,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
/** @type {Violation[]} */
const violations = [];
const visit = (node) => {
const specifier = importSpecifier(node);
if (
specifier !== undefined &&
isRuntimeImportReference(node) &&
!WORKFLOW_DECODER_RUNTIME_IMPORTS.has(specifier.text)
) {
violations.push({
rule: 40,
file: SESSION_INBOX_WIRE_DECODER,
line: sourceFile.getLineAndCharacterOfPosition(specifier.getStart(sourceFile)).line + 1,
message: `imports "${specifier.text}" at runtime. The session-inbox decoder is embedded with inline sources in every workflow driver; keep schema and validation dependencies in the encoder and import only the inferred wire type here.`,
});
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
function isRuntimeImportReference(node) {
if (ts.isImportTypeNode(node)) return false;
if (ts.isImportDeclaration(node)) {
const clause = node.importClause;
if (clause?.isTypeOnly === true) return false;
if (clause?.namedBindings !== undefined && ts.isNamedImports(clause.namedBindings)) {
return clause.namedBindings.elements.some((element) => !element.isTypeOnly);
}
return true;
}
if (ts.isExportDeclaration(node)) {
if (node.isTypeOnly) return false;
if (node.exportClause !== undefined && ts.isNamedExports(node.exportClause)) {
return node.exportClause.elements.some((element) => !element.isTypeOnly);
}
return true;
}
return true;
}
async function checkRule40WireContracts() {
const violations = checkRule40ImmutableWireHistory();
let entries;
try {
entries = await readdir(join(REPO_ROOT, WIRE_FAMILY_DIR));
} catch {
return violations;
}
for (const name of entries) {
const match = name.match(/^([a-z0-9-]+)-wire\.v(\d+)(\.migration)?\.ts$/);
if (match === null) continue;
const [, family, version, kind = ""] = match;
const testName = `${family}-wire.v${version}${kind}.test.ts`;
if (!entries.includes(testName)) {
violations.push({
rule: 40,
file: `${WIRE_FAMILY_DIR}/${name}`,
line: 1,
message: `wire family "${family}" version ${version} has no colocated contract test (${testName}). Pin this version's schema/encoder or migration/fixtures before shipping it.`,
});
}
if (kind === ".migration") {
const path = `${WIRE_FAMILY_DIR}/${name}`;
violations.push(
...checkRule40MigrationPurity(path, await readFile(join(REPO_ROOT, path), "utf8")),
);
}
}
const contractSource = await readFile(join(REPO_ROOT, SESSION_INBOX_WIRE_CONTRACT), "utf8");
const decoderSource = await readFile(join(REPO_ROOT, SESSION_INBOX_WIRE_DECODER), "utf8");
violations.push(...checkRule40WorkflowDecoderImports(decoderSource));
const registryMatch = contractSource.match(
/SESSION_INBOX_WIRE_VERSIONS\s*=\s*\[([^\]]*)\]\s*as const/,
);
const tokens = registryMatch?.[1]
.split(",")
.map((token) => token.trim())
.filter(Boolean);
if (tokens === undefined || tokens.length === 0 || tokens.some((token) => !/^\d+$/.test(token))) {
violations.push({
rule: 40,
file: SESSION_INBOX_WIRE_CONTRACT,
line: 1,
message:
"SESSION_INBOX_WIRE_VERSIONS must be an explicit numeric tuple so CI can compare the declared protocol history with shipped version modules.",
});
return violations;
}
const line = contractSource.slice(0, registryMatch.index).split("\n").length;
const versions = tokens.map(Number);
const expectedVersions = versions.map((_, index) => index + 1);
if (JSON.stringify(versions) !== JSON.stringify(expectedVersions)) {
violations.push({
rule: 40,
file: SESSION_INBOX_WIRE_CONTRACT,
line,
message: `SESSION_INBOX_WIRE_VERSIONS must be contiguous and ascending from 1; found [${versions.join(", ")}]. Add new versions without renumbering or removing protocol history.`,
});
}
const shippedVersions = entries
.flatMap((name) => {
const match = name.match(/^session-inbox-wire\.v(\d+)\.ts$/);
return match === null ? [] : [Number(match[1])];
})
.sort((left, right) => left - right);
const registeredModules = [0, ...versions];
if (JSON.stringify(shippedVersions) !== JSON.stringify(registeredModules)) {
violations.push({
rule: 40,
file: SESSION_INBOX_WIRE_CONTRACT,
line,
message: `session-inbox wire modules [${shippedVersions.join(", ")}] must exactly match legacy v0 plus registered versions [${registeredModules.join(", ")}].`,
});
}
return violations;
}
// ---------- Rule 19: AsyncLocalStorage instances ----------
const NEW_ALS_RE = /new\s+AsyncLocalStorage\s*[<(]/;
/**
* @param {string} posix
* @param {string[]} lines
* @param {{ allowlist: Set<string>; current: Set<string>; lines: Map<string, number> }} state
*/
function checkRule19(posix, lines, state) {
lines.forEach((line, idx) => {
if (NEW_ALS_RE.test(line)) {
state.current.add(posix);
if (!state.lines.has(posix)) state.lines.set(posix, idx + 1);
}
});
}
// ---------- Rule 21: authored `name:` (or `id:`) on define* calls ----------
const DEFINE_FNS = [
"defineAgent",
"defineEval",
"defineMcpClientConnection",
"defineSandbox",
"defineSchedule",
"defineSkill",
"defineTool",
];
/**
* `defineEval` rejects both `name:` and `id:`. Every other
* primitive only forbids `name:`.
*/
const FORBIDDEN_KEYS_BY_FN = {
defineEval: ["name", "id"],
};
const DEFAULT_FORBIDDEN_KEYS = ["name"];
const AUTHORED_PATH_RE = /(^|\/)(apps\/(?:[^/]+\/)?[^/]+\/(agent|evals)|agent|evals)\//;
/**
* @param {string} posix
* @param {string[]} lines
* @param {Set<string>} allowlist
* @param {Violation[]} violations
*/
function checkRule21(posix, lines, allowlist, violations) {
if (!AUTHORED_PATH_RE.test(posix)) return;
if (allowlist.has(posix)) return;
// Find each `defineXxx(` call and inspect the next ~80 lines for a
// top-level `name:` (or `id:`, for `defineEval`) property. We bail
// out at the first balanced `)` to avoid crossing into unrelated calls.
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const fn = DEFINE_FNS.find((candidate) => line.includes(`${candidate}(`));
if (fn === undefined) continue;
const forbiddenKeys = FORBIDDEN_KEYS_BY_FN[fn] ?? DEFAULT_FORBIDDEN_KEYS;
const forbiddenKeyRe = new RegExp(`^\\s*(?:${forbiddenKeys.join("|")})\\s*:`);
let depth = 0;
let started = false;
for (let j = i; j < Math.min(lines.length, i + 80); j++) {
const inner = lines[j];
for (const ch of inner) {
if (ch === "(" || ch === "{") {
depth++;
started = true;
} else if (ch === ")" || ch === "}") {
depth--;
}
}
// The forbidden key is only authored identity at the TOP level of the
// call's object literal — exactly depth 2 (the call paren plus the
// outer `{`). Deeper occurrences are legitimate nested data.
if (depth === 2 && forbiddenKeyRe.test(inner)) {
const matchedKey = forbiddenKeys.find((key) => new RegExp(`^\\s*${key}\\s*:`).test(inner));
violations.push({
rule: 21,
file: posix,
line: j + 1,
message: `authored ${fn}({ ${matchedKey ?? forbiddenKeys[0]}: ... }) — derive the identifier from the file path instead. Adding an authored \`${matchedKey ?? forbiddenKeys[0]}\` creates a redundant source of truth that can drift from the path. Remove the field; the framework derives "${
posix
.split("/")
.pop()
?.replace(/\.[^.]+$/, "") ?? "<filename>"
}" automatically.`,
});
break;
}
if (started && depth <= 0) break;
}
}
}
// ---------- Rule 23: `as unknown as T` double casts ----------
const UNKNOWN_CAST_RE = /\bas\s+unknown\s+as\b/;
/**
* @param {string} posix
* @param {string[]} lines
* @param {{ baseline: Record<string, number>; current: Map<string, number> }} state
*/
function checkRule23(posix, lines, state) {
let count = 0;
for (const line of lines) {
if (UNKNOWN_CAST_RE.test(line)) count++;
}
if (count > 0) state.current.set(posix, count);
}
// ---------- Rule 25: install/reset/clear runtime session in test bodies ----------
const RUNTIME_SESSION_FN_RE =
/\b(installBundledCompiledArtifacts|resetBundledCompiledArtifacts|clearProcessDefaultRuntimeSession)\b/;
/**
* @param {string} posix
* @param {string[]} lines
* @param {{ allowlist: Set<string>; new: Map<string, number> }} state
*/
function checkRule25(posix, lines, state) {
let count = 0;
for (const line of lines) {
if (RUNTIME_SESSION_FN_RE.test(line)) count++;
}
if (count === 0) return;
if (state.allowlist.has(posix)) return;
state.new.set(posix, count);
}
// ---------- Rule 26: `loadContext() as ContextContainer` ----------
const LOAD_CONTEXT_CAST_RE = /loadContext\s*\(\s*\)\s*as\s+ContextContainer\b/;
/**
* @param {string} posix
* @param {string[]} lines
* @param {Violation[]} violations
*/
function checkRule26(posix, lines, violations) {
lines.forEach((line, idx) => {
if (LOAD_CONTEXT_CAST_RE.test(line)) {
violations.push({
rule: 26,
file: posix,
line: idx + 1,
message: `\`loadContext() as ContextContainer\` cast detected. Pass \`ctx: ContextContainer\` as an explicit parameter instead — the cast hides the runtime invariant behind a TypeScript assertion and creates an implicit AsyncLocalStorage dependency.`,
});
}
});
}
// ---------- Rule 27: hook return shapes have no `state` field ----------
const HOOK_DEFINITIONS_PATH = "packages/eve/src/public/definitions/hook.ts";
/** Matches a `state:` (or `readonly state:`, `state?:`) struct member declaration. */
const HOOK_STATE_FIELD_RE = /^\s*(readonly\s+)?state\??\s*:/;
/**
* @param {string} posix
* @param {string[]} lines
* @param {Violation[]} violations
*/
function checkRule27(posix, lines, violations) {
if (posix !== HOOK_DEFINITIONS_PATH) return;
lines.forEach((line, idx) => {
if (HOOK_STATE_FIELD_RE.test(line)) {
violations.push({
rule: 27,
file: posix,
line: idx + 1,
message: `\`state:\` field detected on a hook type definition. Hook return shapes must not carry a parallel state-patch channel — durable state goes through \`ctx.eve\`. Remove the \`state\` field; if the hook truly needs to persist something across turns, write it to a context key via \`ctx.eve.set(...)\` instead.`,
});
}
});
}
// ---------- Rule 28: scaffold layer dependency whitelist ----------
const SCAFFOLD_PREFIX = "packages/eve/src/setup/scaffold/";
// The curated connection and channel catalogs read canonical identity from
// the private `@eve/catalog` workspace package through eve's vendored copy.
// This keeps the published package self-contained without allowing the
// scaffold layer to reach into runtime, compiler, or provider SDK modules.
// Terminal UI adapters live outside the scaffold in `packages/eve/src/setup/cli/`.
const SCAFFOLD_ALLOWED_PACKAGES = new Set([]);
const SCAFFOLD_ALLOWED_INTERNAL_IMPORTS = new Set(["#compiled/@eve/catalog/index.js"]);
// Only match top-of-line `import` statements, not strings nested inside
// template literals (e.g. the channel templates embed `from "react"` as
// generated source for the scaffolded project).
const SCAFFOLD_IMPORT_RE = /^\s*import\b[^"']*\sfrom\s+["']([^"']+)["']/;
/**
* @param {string} posix
* @param {string[]} lines
* @param {Violation[]} violations
*/
function checkRule28(posix, lines, violations) {
if (!posix.startsWith(SCAFFOLD_PREFIX)) return;
// Test files never ship in the eve tarball, so the bundle-size rationale
// doesn't apply to them. Allow vitest and other test-only dependencies.
if (/\.(test|integration\.test|scenario\.test)\.ts$/.test(posix)) return;
// Channel templates embed full source files inside backtick literals
// (`from "react"`, etc.). Track template literal depth so we ignore
// import-like lines that live inside an open backtick block.
let insideTemplate = false;
for (let idx = 0; idx < lines.length; idx++) {
const line = lines[idx];
if (!line) continue;
if (!insideTemplate) {
const match = line.match(SCAFFOLD_IMPORT_RE);
if (match) {
const spec = match[1];
if (
spec &&
!spec.startsWith("node:") &&
!spec.startsWith(".") &&
!SCAFFOLD_ALLOWED_PACKAGES.has(spec) &&
!SCAFFOLD_ALLOWED_INTERNAL_IMPORTS.has(spec)
) {
violations.push({
rule: 28,
file: posix,
line: idx + 1,
message: `import from "${spec}" not allowed in the packages/eve/src/setup/scaffold source layer. Scaffold modules allow only node:* builtins, relative files, and #compiled/@eve/catalog/index.js. Keep runtime, compiler, terminal UI, and provider SDK dependencies in their owning package.`,
});
}
}
}
// Toggle template state on each unescaped backtick on this line.
const backticks = (line.match(/(^|[^\\])`/g) ?? []).length;
if (backticks % 2 === 1) insideTemplate = !insideTemplate;
}
}
// ---------- Rule 29: changeset package names exist in the workspace ----------
const CHANGESET_DIR = ".changeset";
/**
* @returns {Promise<Violation[]>}
*/
async function checkRule29ChangesetPackageNames() {
const workspacePackageNames = await readWorkspacePackageNames();
const changesetPath = join(REPO_ROOT, CHANGESET_DIR);
/** @type {Violation[]} */
const violations = [];
let entries;
try {
entries = await readdir(changesetPath, { withFileTypes: true });
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return violations;
}
throw error;
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "README.md") continue;
const relPath = `${CHANGESET_DIR}/${entry.name}`;
const content = await readFile(join(REPO_ROOT, relPath), "utf8");
if (!matter.test(content)) {
violations.push({
rule: 29,
file: relPath,
message:
"changeset files must start with YAML frontmatter mapping package names to version bump types.",
});
continue;
}
let data;
try {
data = matter(content).data;
} catch (error) {
violations.push({
rule: 29,
file: relPath,
message: `changeset frontmatter must be valid YAML: ${error instanceof Error ? error.message : String(error)}`,
});
continue;
}
if (data === null || typeof data !== "object" || Array.isArray(data)) {
violations.push({
rule: 29,
file: relPath,
message:
"changeset frontmatter must be an object mapping package names to version bump types.",
});
continue;
}
const releases = Object.entries(data);
if (releases.length === 0) {
violations.push({
rule: 29,
file: relPath,
message: "changeset frontmatter must declare at least one package bump.",
});
continue;
}
for (const [packageName] of releases) {
if (workspacePackageNames.has(packageName)) continue;
violations.push({
rule: 29,
file: relPath,
message: `changeset references package "${packageName}", but no workspace package has that name. Use the exact package.json "name" from the target workspace package; for packages/eve that is "eve".`,
});
}
}
return violations;
}
// ---------- Rule 30: vendored compiled output has no per-package package.json ----------
const VENDOR_COMPILED_DIR = "packages/eve/scripts/vendor-compiled";
// Matches a write/copy whose path argument is a `join(...)` ending in the
// `package.json` literal — i.e. emitting a package.json into the vendored
// output. Reads in `findPackageJson` use `readFile`, so keying on
// `writeFile`/`copyFile` distinguishes a write from a lookup. `[^)]*` keeps
// the match inside the single `join(...)` argument so an unrelated write
// (e.g. a stub `.d.ts`) followed later by a `package.json` read literal
// can't trigger a false positive.
const COMPILED_PACKAGE_JSON_WRITE_RE =
/(?:writeFile|copyFile)\s*\(\s*join\([^)]*["']package\.json["']/;
/**
* Rule 30. Scans the compiled-vendor scripts for any code that writes a
* `package.json` into a vendored output directory. Such a file shadows eve's
* `#compiled/*` imports map and silently turns cross-package vendored types
* into `any` (see the rule 30 note in the header). Scanning the scripts (not
* the generated artifact) keeps the guard meaningful in the `lint` CI job,
* which runs before any `build:compiled`.
*
* @returns {Promise<Violation[]>}
*/
async function checkRule30VendoredCompiledPackageJson() {
/** @type {Violation[]} */
const violations = [];
const scriptsRoot = join(REPO_ROOT, VENDOR_COMPILED_DIR);
for await (const { absPath, relPath } of walkFiles(scriptsRoot)) {
if (!absPath.endsWith(".mjs")) continue;
const content = await readFile(absPath, "utf8");
if (COMPILED_PACKAGE_JSON_WRITE_RE.test(content)) {
violations.push({
rule: 30,
file: toPosix(relPath),
message:
'vendored-compile pipeline writes a package.json into the compiled output. Remove it: a per-package package.json creates a scope that shadows eve\'s `#compiled/*` imports map, so cross-package vendored type references (e.g. @workflow/core -> @workflow/world -> zod) silently resolve to `any` under skipLibCheck. The bundled ESM inherits `"type": "module"` from eve\'s root package.json, so no per-package file is needed.',
});
}
}
return violations;
}
// ---------- Rule 31: removed CLI entry points stay removed ----------
const ACTIVE_CLI_REFERENCE_EXTENSIONS = /\.(?:[cm]?[jt]sx?|mdx?|json|ya?ml)$/;
const ACTIVE_CLI_REFERENCE_ROOTS = [
"apps/",
"docs/",
"e2e/",
"packages/eve/src/",
"packages/eve/test/",
];
const ACTIVE_CLI_REFERENCE_ROOT_FILES = new Set(["AGENTS.md", "CONTRIBUTING.md", "README.md"]);
const REMOVED_CLI_REFERENCES = [
{
pattern: /\b(?:npm|pnpm|yarn)\s+create\s+eve(?:@[^\s`"'<>]+)?\b/i,
replacement: "`eve init <name>`",
},
{ pattern: /\bcreate-eve\b/i, replacement: "`eve init`" },
{ pattern: /\beve\s+setup\b/i, replacement: "the dedicated current eve command" },
];
/**
* @param {string} posix
*/
function isActiveCliReferenceFile(posix) {
if (!ACTIVE_CLI_REFERENCE_EXTENSIONS.test(posix)) return false;
return (
ACTIVE_CLI_REFERENCE_ROOT_FILES.has(posix) ||
ACTIVE_CLI_REFERENCE_ROOTS.some((prefix) => posix.startsWith(prefix))
);
}
/**
* @returns {Promise<Violation[]>}
*/
async function checkRule31RemovedCliReferences() {
/** @type {Violation[]} */
const violations = [];
for await (const { absPath, relPath } of walkFiles(REPO_ROOT)) {
const posix = toPosix(relPath);
if (!isActiveCliReferenceFile(posix)) continue;
const lines = (await readFile(absPath, "utf8")).split(/\r?\n/);
lines.forEach((line, index) => {
const removed = REMOVED_CLI_REFERENCES.find(({ pattern }) => pattern.test(line));
if (removed === undefined) return;
violations.push({
rule: 31,
file: posix,
line: index + 1,
message: `references a removed eve CLI entry point. Replace it with ${removed.replacement}. Historical mentions belong only in changelogs or changesets.`,
});
});
}
return violations;
}
// ---------- Rule 32: research document frontmatter ----------
const RESEARCH_DIR = "research";
const RESEARCH_LAST_UPDATED_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* @returns {Promise<Violation[]>}
*/
async function checkRule32ResearchFrontmatter() {
/** @type {Violation[]} */
const violations = [];
const researchRoot = join(REPO_ROOT, RESEARCH_DIR);
try {
await readdir(researchRoot);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return violations;
}
throw error;
}
for await (const { absPath, relPath } of walkFiles(researchRoot)) {
const posix = toPosix(relPath);
if (!posix.endsWith(".md")) continue;
const content = await readFile(absPath, "utf8");
if (!matter.test(content)) {
violations.push({
rule: 32,
file: posix,
message:
"research documents must start with YAML frontmatter containing `issue`, `status`, and `last_updated` fields.",
});
continue;
}
let data;
try {
data = matter(content).data;
} catch (error) {
violations.push({
rule: 32,
file: posix,
message: `research frontmatter must be valid YAML: ${error instanceof Error ? error.message : String(error)}`,
});
continue;
}
if (data === null || typeof data !== "object" || Array.isArray(data)) {
violations.push({
rule: 32,
file: posix,
message: "research frontmatter must parse to an object.",
});
continue;
}
for (const field of ["issue", "status"]) {
if (typeof data[field] === "string" && data[field].trim().length > 0) continue;
violations.push({
rule: 32,
file: posix,
message: `research frontmatter must set \`${field}\` to a non-empty string.`,
});
}
if (
typeof data.last_updated !== "string" ||
!RESEARCH_LAST_UPDATED_RE.test(data.last_updated)
) {
violations.push({
rule: 32,
file: posix,
message: "research frontmatter must set `last_updated` to a quoted `YYYY-MM-DD` string.",
});
}
}
return violations;
}
// ---------- Rule 34: no `phase` imports under GPU/shader boundaries ----------
const PHASE_BOUNDARY_DIRS = [
"apps/docs/app/[lang]/(home)/components/eve-logo-shader/render",
"apps/docs/app/[lang]/(home)/components/eve-logo-shader/shaders",
"apps/docs/scripts/eve-render",
];
const PHASE_IMPORT_RE =
/(from\s+|import\s+)(?:type\s+)?['"]phase(?:\/[^'"]*)?['"]|require\(\s*['"]phase(?:\/[^'")]*)?['"]\s*\)|import\(\s*['"]phase(?:\/[^'")]*)?['"]\s*\)/;
/**
* @returns {Promise<Violation[]>}
*/
async function checkRule34PhaseBoundary() {
/** @type {Violation[]} */
const violations = [];
for (const relDir of PHASE_BOUNDARY_DIRS) {
const absDir = join(REPO_ROOT, relDir);
let stats;
try {
stats = await lstat(absDir);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
continue;
}
throw error;
}
if (!stats.isDirectory()) continue;
for await (const entry of walkFiles(absDir)) {
if (!entry.stat.isFile()) continue;
const content = await readFile(entry.absPath, "utf8");
const match = content.match(PHASE_IMPORT_RE);
if (!match) continue;
const before = content.slice(0, match.index ?? 0);
const line = before.split(/\r?\n/).length;
violations.push({
rule: 34,
file: entry.relPath,
line,
message:
"imports the `phase` package inside the GPU/shader boundary. Phase must stay in the lifecycle/runtime layer — add lifecycle hooks above render/ and keep render/, shaders/, and scripts/eve-render/ free of `phase` imports.",
});
}
}
return violations;
}
// ---------- Rule 38: one owner for the eve package build ----------
const NESTED_EVE_BUILD_RE = /\bpnpm\s+(?:--filter(?:=|\s+)eve|-F\s+eve)\s+(?:run\s+)?build\b/;
/**
* @returns {Promise<Violation[]>}
*/
async function checkRule38NoNestedEveBuild() {
/** @type {Violation[]} */
const violations = [];
for (const dir of await readPnpmWorkspacePackageDirs()) {
if (dir === "packages/eve") continue;
const packageJson = await readJsonIfExists(join(REPO_ROOT, dir, "package.json"));
for (const [scriptName, command] of Object.entries(packageJson?.scripts ?? {})) {
if (typeof command !== "string" || !NESTED_EVE_BUILD_RE.test(command)) continue;
violations.push({
rule: 38,
file: `${dir}/package.json`,
message: `script "${scriptName}" launches a nested eve package build. Declare eve as a workspace dependency and let Turbo's ^build edge produce it once; rebuilding eve inside a consumer races its destructive dist clean against other consumers.`,
});
}
}
return violations;
}
/**
* @returns {Promise<Set<string>>}
*/
async function readWorkspacePackageNames() {
const packageDirs = await readPnpmWorkspacePackageDirs();
const packageNames = new Set();
for (const dir of packageDirs) {
const packageJson = await readJsonIfExists(join(REPO_ROOT, dir, "package.json"));
if (packageJson?.name) packageNames.add(packageJson.name);
}
return packageNames;
}
/**
* @returns {Promise<string[]>}
*/
async function readPnpmWorkspacePackageDirs() {
const workspaceYaml = await readFile(join(REPO_ROOT, "pnpm-workspace.yaml"), "utf8");
const includeDirs = new Set();
const excludeDirs = new Set();
for (const rawPattern of readPnpmWorkspacePackagePatterns(workspaceYaml)) {
const excluded = rawPattern.startsWith("!");
const pattern = excluded ? rawPattern.slice(1) : rawPattern;
const dirs = await expandWorkspacePackagePattern(pattern);
if (!excluded && dirs.length === 0) {
throw new Error(`Workspace package pattern "${rawPattern}" matched no package.json files.`);
}
const target = excluded ? excludeDirs : includeDirs;
dirs.forEach((dir) => target.add(dir));
}
excludeDirs.forEach((dir) => includeDirs.delete(dir));
return [...includeDirs].sort();
}
/**
* @param {string} workspaceYaml
*/
function readPnpmWorkspacePackagePatterns(workspaceYaml) {
const patterns = [];
let inPackages = false;
for (const line of workspaceYaml.split(/\r?\n/)) {
if (/^\S/.test(line)) {
inPackages = /^packages:\s*$/.test(line);
continue;
}
if (!inPackages) continue;
const match = line.match(/^\s*-\s+(.+?)\s*$/);
if (!match) continue;
patterns.push(stripYamlString(match[1]));
}
return patterns;
}
/**
* @param {string} value
*/
function stripYamlString(value) {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
/**
* @param {string} pattern
* @returns {Promise<string[]>}
*/
async function expandWorkspacePackagePattern(pattern) {
const normalizedPattern = pattern.replace(/\/+$/, "");
const dirs = [];
for await (const manifestPath of glob(`${normalizedPattern}/package.json`, {
cwd: REPO_ROOT,
})) {
dirs.push(toPosix(dirname(manifestPath)));
}
return dirs.sort();
}
/**
* @param {string} path
* @returns {Promise<any | undefined>}
*/
async function readJsonIfExists(path) {
try {
return JSON.parse(await readFile(path, "utf8"));
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return undefined;
}
throw error;
}
}
// ---------- Reporting helpers ----------
/**
* @param {Violation[]} violations
*/
function printViolations(violations) {
for (const v of violations) {
const where = v.line ? `${v.file}:${v.line}` : v.file;
process.stderr.write(` [rule ${v.rule}] ${where}\n ${v.message}\n`);
}
}
/**
* Compares per-file counts against a baseline and returns the offending
* entries. Files whose count exceeds the baseline (or are absent from it)
* are reported as additions; baselines never increase.
*
* @param {Map<string, number>} current
* @param {Record<string, number>} baseline
*/
function diffCounts(current, baseline) {
/** @type {{ file: string; was: number; now: number }[]} */
const additions = [];
for (const [file, now] of current) {
const was = baseline[file] ?? 0;
if (now > was) additions.push({ file, was, now });
}
return additions;
}
// ---------- Entry point ----------
async function main() {
const baselineRaw = await readFile(BASELINE_PATH, "utf8");
const baseline = JSON.parse(baselineRaw);
const state = {
rule13: { baseline: baseline.rule13_spreadTernaryByFile, current: new Map() },
rule15: /** @type {Violation[]} */ ([]),
rule19: {
allowlist: new Set(baseline.rule19_asyncLocalStorageAllowlist),
current: new Set(),
lines: new Map(),
},
rule21: {
allowlist: new Set(baseline.rule21_authoredNameAllowlist ?? []),
violations: /** @type {Violation[]} */ ([]),
},
rule23: { baseline: baseline.rule23_unknownCastByFile, current: new Map() },
rule25: {
allowlist: new Set(baseline.rule25_installRuntimeArtifactsAllowlist),
new: new Map(),
},
rule26: /** @type {Violation[]} */ ([]),
rule27: /** @type {Violation[]} */ ([]),
rule28: /** @type {Violation[]} */ ([]),
rule33: /** @type {Violation[]} */ ([]),
rule35: /** @type {Violation[]} */ ([]),
rule37: /** @type {Violation[]} */ ([]),
rule42: /** @type {Violation[]} */ ([]),
rule43: /** @type {Violation[]} */ ([]),
symlinks: /** @type {string[]} */ ([]),
};
await scanRepo(state);
const violations = /** @type {Violation[]} */ ([]);
// Rule 9
for (const file of state.symlinks) {
violations.push({
rule: 9,
file,
message: `symlink detected. Symlinks are forbidden — they are too unpredictable for a framework to rely on. Replace it with a real file or a small loader that references the canonical location.`,
});
}
// Rule 13
for (const { file, was, now } of diffCounts(state.rule13.current, state.rule13.baseline)) {
violations.push({
rule: 13,
file,
message: `${now} spread-ternary object composition${now === 1 ? "" : "s"} detected (baseline: ${was}). Replace \`...(cond ? {} : { key: value })\` with explicit assignment: declare the object, then \`if (cond) obj.key = value;\` (or use the conditional form for the *value* not the spread).`,
});
}
// Rule 15
violations.push(...state.rule15);
// Rule 19
for (const file of state.rule19.current) {
if (state.rule19.allowlist.has(file)) continue;
const line = state.rule19.lines.get(file);
violations.push({
rule: 19,
file,
line,
message: `\`new AsyncLocalStorage()\` outside the allowlist. All ambient runtime state must flow through the unified EveContext (one AsyncLocalStorage). If you genuinely need a new ALS, justify it in code review and add this file to scripts/guard-invariants-baseline.json under "rule19_asyncLocalStorageAllowlist".`,
});
}
// Rule 21
violations.push(...state.rule21.violations);
// Rule 23
for (const { file, was, now } of diffCounts(state.rule23.current, state.rule23.baseline)) {
violations.push({
rule: 23,
file,
message: `${now} \`as unknown as T\` cast${now === 1 ? "" : "s"} detected (baseline: ${was}). Avoid double casts through \`unknown\` — they hide real type errors. Try a direct \`as T\`, fix the source type, or thread a properly typed parameter through. To lower the baseline after a cleanup, regenerate the baseline file. The baseline may shrink, never grow.`,
});
}
// Rule 25
for (const [file, count] of state.rule25.new) {
violations.push({
rule: 25,
file,
message: `${count} call${count === 1 ? "" : "s"} to install/reset/clear runtime-session helpers. Tests must scope runtime state through createTestRuntime().run(fn) / runAsSession(init, fn) / withRuntimeSession(...). Direct calls mutate the process-default RuntimeSession and leak state across tests.`,
});
}
// Rule 26
violations.push(...state.rule26);
// Rule 27
violations.push(...state.rule27);
// Rule 28
violations.push(...state.rule28);
// Rule 29
violations.push(...(await checkRule29ChangesetPackageNames()));
// Rule 30
violations.push(...(await checkRule30VendoredCompiledPackageJson()));
// Rule 31
violations.push(...(await checkRule31RemovedCliReferences()));
// Rule 32
violations.push(...(await checkRule32ResearchFrontmatter()));
// Rule 33
violations.push(...state.rule33);
// Rule 34
violations.push(...(await checkRule34PhaseBoundary()));
// Rule 35
violations.push(...state.rule35);
// Rule 36
for (const issue of await checkExtensionCapabilityContracts()) {
violations.push({ rule: 36, ...issue });
}
// Rule 37
violations.push(...state.rule37);
// Rule 38
violations.push(...(await checkRule38NoNestedEveBuild()));
// Rule 40
violations.push(...(await checkRule40WireContracts()));
// Rule 42
violations.push(...state.rule42);
// Rule 43
violations.push(...state.rule43);
if (violations.length === 0) {
process.stdout.write("[eve:guard:invariants] ok — all mechanical lints passed.\n");
return;
}
process.stderr.write(
`[eve:guard:invariants] FAIL: ${violations.length} violation${violations.length === 1 ? "" : "s"} of framework mechanical rules.\n\n`,
);
printViolations(violations);
process.stderr.write(
`\nEach rule above enforces a framework invariant. The header comment in scripts/guard-invariants.mjs explains the rationale for each rule ID. Fix the violation, or — if the failure is for a baselined rule and you have a deliberate reduction — update scripts/guard-invariants-baseline.json (counts and allowlists may shrink, never grow).\n`,
);
process.exit(1);
}
await main();