mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
73aec259ca
Signed-off-by: Casey Gowrie <ctgowrie@gmail.com>
1756 lines
63 KiB
JavaScript
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();
|