diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 84350e74..4b266b65 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,5 +6,9 @@ ## Checklist - [ ] Tests added or updated -- [ ] Lint/typecheck pass -- [ ] Docs updated if needed +- [ ] `npm run format:check`, `npm run lint`, `npm run typecheck`, `npm test`, and `npm run build` pass +- [ ] Applicable VS Code extension checks pass +- [ ] Docs, CLI help, schemas, and examples updated where affected +- [ ] Public contracts, JSON output, stderr diagnostics, and exit behavior remain compatible or are documented +- [ ] Security impact reviewed for paths, secrets, model input, dependencies, and remote writes +- [ ] Applicable constitution checks pass; deferred tasks have rationale, owner, and follow-up diff --git a/.github/instructions/vscode-extension.instructions.md b/.github/instructions/vscode-extension.instructions.md index 983cd941..d22c044b 100644 --- a/.github/instructions/vscode-extension.instructions.md +++ b/.github/instructions/vscode-extension.instructions.md @@ -20,14 +20,15 @@ Output is CommonJS (not ESM like the CLI). Bundled with esbuild, not tsup. ## Service Reuse via Path Alias -The extension imports CLI services through a `agentrc/*` path alias: +The extension imports shared services through the `@agentrc/core/*` path alias: ```typescript // vscode-extension/src/services.ts — re-export layer -export { analyzeRepo } from "agentrc/services/analyzer.js"; +export { analyzeRepo } from "@agentrc/core/services/analyzer"; ``` -This works because `tsconfig.json` maps `"agentrc/*": ["../src/*"]` and esbuild resolves it at bundle time. Never duplicate CLI service logic in the extension. +This works because `tsconfig.json` maps `@agentrc/core` to `../packages/core/src` and esbuild +resolves it at bundle time. Never duplicate core service logic in the extension. ## Git Integration diff --git a/.gitignore b/.gitignore index da950b1d..b707f6d6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,15 @@ coverage/ .vscode/ .idea/ +# Local agent integrations +.agents/ +.opencode/ +.claude/ +.serena/ + +# Local specification artifacts +specs/ + # OS .DS_Store Thumbs.db diff --git a/.prettierignore b/.prettierignore index 41146329..6dae79e2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,9 @@ *.lock.yml .github/workflows/agentics-maintenance.yml +# Generated SpecKit skills are integration-managed and read-only in local workspaces +.agents/ + # Webapp has its own formatting; Dockerfile is not parseable by Prettier webapp/ Dockerfile.webapp diff --git a/AGENTS.md b/AGENTS.md index 85f58ee9..d4b39da0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ vscode-extension/ # See .github/instructions/vscode-extension.instructions. ## Build & Test ```sh -npm run build # tsup → dist/index.js (ESM, Node 20) +npm run build # tsup → dist/index.js (ESM, Node 22+) npm run dev # tsx src/index.ts (run from source) npm run typecheck # tsc --noEmit npm run lint # eslint diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7738346e..a688b209 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ All pull requests run the following required status checks before merge: | `lint-extension` | ESLint (vscode-extension) | | `typecheck` | TypeScript (root) | | `typecheck-extension` | TypeScript (vscode-extension) | -| `test` | Vitest (Node 20 + 22, ubuntu + windows) | +| `test` | Vitest (Node 22, Ubuntu + Windows) | | `build` | tsup build + CLI version assertion + ext bundle | To configure branch protection rules in GitHub: diff --git a/README.md b/README.md index 0daea4b0..9d441dca 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ AgentRC reads your codebase and generates the files that close that gap — then npx github:microsoft/agentrc ``` -Works as a CLI, as a [VS Code extension](docs/extension.md), and in your [CI/CD pipeline](docs/ci-integration.md) to monitor drift. No config needed — runs on any repo with Node.js 20+. +Works as a CLI, as a [VS Code extension](docs/extension.md), and in your [CI/CD pipeline](docs/ci-integration.md) to monitor drift. No config needed — runs on any repo with Node.js 22+. ![AgentRC — Measure, Generate, Maintain cycle](docs/assets/agentrc-overview.png) diff --git a/docs/ci-integration.md b/docs/ci-integration.md index aefe221d..0ca99ffd 100644 --- a/docs/ci-integration.md +++ b/docs/ci-integration.md @@ -6,7 +6,7 @@ AgentRC commands return structured output and exit codes designed for CI pipelin ## Prerequisites -- **Node.js 20+** on the runner +- **Node.js 22+** on the runner - **Auth token** — GitHub: `GITHUB_TOKEN` or `GH_TOKEN`. Azure DevOps: `AZURE_DEVOPS_PAT` or `AZDO_PAT`. - **Copilot CLI** — required for `eval` (it calls the Copilot SDK). Not needed for `readiness`. See the [VS Code Copilot Chat extension](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot-chat) docs for installation. @@ -45,7 +45,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Check readiness run: npx github:microsoft/agentrc readiness --fail-level 3 --json @@ -72,7 +72,7 @@ pool: steps: - task: NodeTool@0 inputs: - versionSpec: "20.x" + versionSpec: "22.x" - script: npx github:microsoft/agentrc readiness --fail-level 3 --json displayName: Check readiness diff --git a/docs/getting-started.md b/docs/getting-started.md index d24f1ff0..883a9846 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,7 +4,7 @@ ## Prerequisites -- **Node.js 20+** +- **Node.js 22+** - **GitHub Copilot CLI** — bundled with the [VS Code Copilot Chat extension](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot-chat). Run `copilot` → `/login` to authenticate. - **Git host auth** — GitHub: `gh` CLI or `GITHUB_TOKEN`/`GH_TOKEN` env var. Azure DevOps: `AZURE_DEVOPS_PAT` (or `AZDO_PAT`) env var. diff --git a/docs/policies.md b/docs/policies.md index c1179990..722ca5cf 100644 --- a/docs/policies.md +++ b/docs/policies.md @@ -12,7 +12,7 @@ agentrc readiness --policy ./base.json,./overrides.json # chain multiple agentrc readiness --policy @org/agentrc-policy-strict # npm package ``` -Or set it in [configuration](configuration.md): +Or set a JSON policy in [configuration](configuration.md): ```json { @@ -20,15 +20,44 @@ Or set it in [configuration](configuration.md): } ``` -## Built-in examples +## Example policies -AgentRC ships with three example policies in `examples/policies/`: +The source checkout contains these example policies in `examples/policies/`. The published npm +package contains `dist/` only, so copy a reviewed example to a local path when using an installed +CLI. -| Policy | What it does | -| ----------------------- | ------------------------------------------------------ | -| `strict.json` | 100% pass rate, raises impact on key criteria | -| `ai-only.json` | Disables all repo-health checks, focuses on AI tooling | -| `repo-health-only.json` | Disables AI checks, focuses on traditional quality | +| Policy | What it does | +| ----------------------- | ----------------------------------------------------------------- | +| `strict.json` | 100% pass rate, raises impact on key criteria | +| `ai-only.json` | Disables all repo-health checks, focuses on AI tooling | +| `repo-health-only.json` | Disables AI checks, focuses on traditional quality | +| `rust.mjs` | Trusted Rust/Cargo readiness policy with conditional replacements | + +## Rust readiness policy + +`examples/policies/rust.mjs` is a self-contained `.mjs` module for Node.js 22 or newer. It replaces +six primary readiness criteria for pure Rust repositories—lint, format, type checking, build, test, +and lockfile checks—and adds explicit toolchain-pinning and supply-chain-policy criteria. It uses +fixed repository-relative probes, at most one 1 MiB-capped `Cargo.toml` content read, and no +subprocess, network, or filesystem-write capability. + +```bash +agentrc readiness /path/to/rust-repo --policy ./examples/policies/rust.mjs +``` + +Treat module policies as **trusted executable code** and review a copied file before loading it. +They are allowed only through the CLI `--policy` option; `agentrc.config.json` is JSON-only and must +not reference `.mjs` files. The example keeps existing Node behavior for mixed Rust/Node repositories +because repo-scoped checks cannot prove separate coverage for each ecosystem. A missing `Cargo.lock` +in a pure Rust repository is skipped rather than treated as an application failure. + +When an organization policy overrides metadata or disables a replacement, load it after the Rust +policy so the documented last-policy-wins behavior is preserved: + +```bash +agentrc readiness /path/to/rust-repo \ + --policy ./examples/policies/rust.mjs,./org-baseline.json +``` ## Writing a policy diff --git a/examples/README.md b/examples/README.md index 328eb01d..12a44a19 100644 --- a/examples/README.md +++ b/examples/README.md @@ -50,3 +50,31 @@ Use `agentrc instructions` when you want the dedicated instruction-generation wo `agentrc.eval.json` is a starter eval config with implementation planning tasks. Expectations can be a single string or an array of strings for structured criteria. All AgentRC JSON files support `//` and `/* */` comments (JSONC). See `policies/README.md` for details on the included readiness policies and how to compose them. + +## Rust readiness from a source checkout + +[`policies/rust.mjs`](policies/rust.mjs) is trusted executable code for repositories that use Cargo. +It replaces the JavaScript-centric lint, format, typecheck, build, test, and lockfile checks when the +repository is pure Rust, and adds Rust toolchain and supply-chain checks. + +From this source checkout, run: + +```sh +agentrc readiness /path/to/rust-repo --policy ./examples/policies/rust.mjs +``` + +The npm package publishes only `dist/`, not `examples/`. With an installed CLI, copy and review +`rust.mjs` into the target repository or another trusted local location, then pass that copied path +through `--policy`. Module policies cannot be configured in `agentrc.config.json`; that file accepts +JSON policies only. + +Treat that copy as version-coupled to the AgentRC release it came from. When upgrading AgentRC, +review the current source policy and re-copy it so its criterion IDs and result metadata stay aligned +with the installed CLI. + +For policy chains, place the Rust policy before an organization baseline so the organization's later +metadata overrides or disables continue to win: + +```sh +agentrc readiness /path/to/rust-repo --policy ./rust.mjs,./org-baseline.json +``` diff --git a/examples/policies/README.md b/examples/policies/README.md index 7f80e73e..d8d7bc4d 100644 --- a/examples/policies/README.md +++ b/examples/policies/README.md @@ -5,6 +5,7 @@ Readiness policies customize which criteria are evaluated and how they are score - narrow the report to a specific concern - exclude checks that do not apply to your repository - raise the quality bar for teams that want stricter gating +- replace JavaScript-centric readiness checks for a Rust repository ## Usage @@ -30,6 +31,7 @@ When policies are chained, later policies can further disable checks or override | `ai-only.json` | Disables repo-health criteria so the report focuses on AI tooling readiness | | `repo-health-only.json` | Disables AI-tooling criteria and the `agents-doc` extra so the report focuses on core repository health | | `strict.json` | Sets a 100% pass-rate threshold and raises the impact of selected criteria | +| `rust.mjs` | Trusted executable policy that recognizes Rust and Cargo tooling while preserving non-Rust behavior | ## Choosing a policy @@ -38,3 +40,39 @@ Use `ai-only.json` when you want to measure how ready a repository is for AI-ass Use `repo-health-only.json` when you want a traditional repository-quality pass that ignores AI-specific setup. Use `strict.json` when you want the default readiness model but with no partial credit on the overall threshold and stronger weighting on selected checks. + +## Rust readiness policy + +`rust.mjs` is a self-contained ECMAScript module for Rust repositories. It uses the existing legacy +`PolicyConfig` API to replace six readiness criteria in the primary report: lint configuration, +format configuration, type checking, build and test scripts, and lockfiles. It also adds +`rust-toolchain-pinned` and `rust-supply-chain` criteria. + +Run it from an AgentRC source checkout with Node.js 22 or newer: + +```sh +agentrc readiness /path/to/rust-repo --policy ./examples/policies/rust.mjs +``` + +The module treats `Cargo.toml` as Rust type-checking evidence, recognizes Clippy and rustfmt +configuration, detects `Cargo.lock`, Rust toolchain pins, and deny/audit/supply-chain configuration. +It makes only fixed-path reads, performs at most one `Cargo.toml` content read (capped at 1 MiB), +and rejects symbolic links for its evidence probes. A missing `Cargo.lock` in a pure Rust repository +skips that criterion because a static policy cannot determine whether the crate is a library. + +This policy is **trusted executable code**. Review it before use. Module policies must be passed +explicitly through `--policy`; they cannot be referenced from `agentrc.config.json`, which accepts +JSON policies only. The published npm package does not include `examples/`, so copy this reviewed +file into the target repository or another local path before using an installed AgentRC CLI. + +For a mixed Rust/Node repository, shared repository-scoped checks retain the current Node behavior; +the policy does not claim separate per-ecosystem coverage. When an organization policy must override +metadata or disable a replacement, load Rust first and the organization policy last: + +```sh +agentrc readiness /path/to/rust-repo \ + --policy ./examples/policies/rust.mjs,./org-baseline.json +``` + +The example is version-coupled to AgentRC's current built-in criterion metadata. Its focused tests +are designed to reveal drift when those built-ins change. diff --git a/examples/policies/rust.mjs b/examples/policies/rust.mjs new file mode 100644 index 00000000..3a546021 --- /dev/null +++ b/examples/policies/rust.mjs @@ -0,0 +1,461 @@ +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { TextDecoder } from "node:util"; + +const MAX_CARGO_MANIFEST_BYTES = 1024 * 1024; +const MAX_CARGO_MANIFEST_READ_BYTES = MAX_CARGO_MANIFEST_BYTES + 1; +const HAS_NOFOLLOW = typeof fsConstants.O_NOFOLLOW === "number"; +const READ_ONLY_NOFOLLOW_FLAGS = fsConstants.O_RDONLY | (HAS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); + +const NODE_LINT_CANDIDATES = [ + "eslint.config.js", + "eslint.config.mjs", + ".eslintrc", + ".eslintrc.js", + ".eslintrc.cjs", + ".eslintrc.json", + ".eslintrc.yml", + ".eslintrc.yaml", + "biome.json", + "biome.jsonc", + ".prettierrc", + ".prettierrc.json", + ".prettierrc.js", + ".prettierrc.cjs", + "prettier.config.js", + "prettier.config.cjs" +]; + +const NODE_FORMAT_CANDIDATES = [ + "biome.json", + "biome.jsonc", + ".prettierrc", + ".prettierrc.json", + ".prettierrc.js", + ".prettierrc.cjs", + "prettier.config.js", + "prettier.config.cjs" +]; + +const TYPECHECK_CANDIDATES = ["tsconfig.json", "tsconfig.base.json", "pyproject.toml", "mypy.ini"]; +const NODE_LOCKFILE_CANDIDATES = ["pnpm-lock.yaml", "yarn.lock", "package-lock.json", "bun.lockb"]; +const RUST_LINT_CANDIDATES = ["clippy.toml", ".clippy.toml"]; +const RUST_FORMAT_CANDIDATES = ["rustfmt.toml", ".rustfmt.toml"]; +const RUST_TOOLCHAIN_CANDIDATES = ["rust-toolchain.toml", "rust-toolchain"]; +const RUST_SUPPLY_CHAIN_CANDIDATES = ["deny.toml", ".cargo/audit.toml", "supply-chain/config.toml"]; + +function resolveContainedPath(repoPath, candidate) { + if (typeof repoPath !== "string" || typeof candidate !== "string" || path.isAbsolute(candidate)) { + return undefined; + } + + const root = path.resolve(repoPath); + const target = path.resolve(root, candidate); + const relative = path.relative(root, target); + if ( + !relative || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + return undefined; + } + + return target; +} + +async function isSafeFixedFile(repoPath, candidate) { + const opened = await openVerifiedFixedFile(repoPath, candidate); + if (!opened) return false; + + await opened.handle.close().catch(() => {}); + return true; +} + +async function inspectSafeFixedPath(repoPath, candidate) { + const target = resolveContainedPath(repoPath, candidate); + if (!target) return undefined; + + const root = path.resolve(repoPath); + const relative = path.relative(root, target); + const components = relative.split(path.sep); + let current = root; + + try { + const rootStats = await fs.lstat(root, { bigint: true }); + if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) return undefined; + + for (const [index, component] of components.entries()) { + current = path.join(current, component); + const stats = await fs.lstat(current, { bigint: true }); + if (stats.isSymbolicLink()) return undefined; + + const isTarget = index === components.length - 1; + if (isTarget ? !stats.isFile() : !stats.isDirectory()) return undefined; + if (isTarget) return { target, stats }; + } + } catch { + return undefined; + } + + return undefined; +} + +function hasSameIdentity(left, right) { + if (left.dev !== 0n && left.ino !== 0n && right.dev !== 0n && right.ino !== 0n) { + return left.dev === right.dev && left.ino === right.ino; + } + + if (left.dev !== 0n || left.ino !== 0n || right.dev !== 0n || right.ino !== 0n) { + return false; + } + + // Some filesystems report zero device or inode values. When O_NOFOLLOW protects the final path + // component, compare metadata instead of treating otherwise safe evidence as universally absent. + // This is only a compatibility fallback: the metadata can be reproduced by an attacker who can + // replace an intermediate directory, because Node has no portable openat-style path traversal. + return ( + HAS_NOFOLLOW && + left.mode === right.mode && + left.nlink === right.nlink && + left.size === right.size && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs + ); +} + +function hasSameReadMetadata(left, right) { + return ( + left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs + ); +} + +async function hasUnchangedSafePath(opened) { + const inspected = await inspectSafeFixedPath(opened.repoPath, opened.candidate); + return Boolean(inspected && hasSameIdentity(opened.stats, inspected.stats)); +} + +async function openVerifiedFixedFile(repoPath, candidate) { + const inspected = await inspectSafeFixedPath(repoPath, candidate); + if (!inspected) return undefined; + + let handle; + try { + handle = await fs.open(inspected.target, READ_ONLY_NOFOLLOW_FLAGS); + const openedStats = await handle.stat({ bigint: true }); + if (!openedStats.isFile() || !hasSameIdentity(inspected.stats, openedStats)) { + await handle.close().catch(() => {}); + return undefined; + } + + const opened = { + candidate, + handle, + repoPath, + stats: openedStats + }; + if (!(await hasUnchangedSafePath(opened))) { + await handle.close().catch(() => {}); + return undefined; + } + return opened; + } catch { + await handle?.close().catch(() => {}); + return undefined; + } +} + +async function firstSafeFixedFile(repoPath, candidates) { + for (const candidate of candidates) { + if (await isSafeFixedFile(repoPath, candidate)) return candidate; + } + return undefined; +} + +async function readSafeCargoManifest(repoPath) { + const opened = await openVerifiedFixedFile(repoPath, "Cargo.toml"); + if (!opened) return undefined; + + try { + if (opened.stats.size > BigInt(MAX_CARGO_MANIFEST_BYTES)) return undefined; + + const buffer = new Uint8Array(MAX_CARGO_MANIFEST_READ_BYTES); + let totalBytes = 0; + while (totalBytes < buffer.length) { + const { bytesRead } = await opened.handle.read( + buffer, + totalBytes, + buffer.length - totalBytes, + totalBytes + ); + if (bytesRead === 0) break; + totalBytes += bytesRead; + } + + const finalStats = await opened.handle.stat({ bigint: true }); + if ( + totalBytes > MAX_CARGO_MANIFEST_BYTES || + !finalStats.isFile() || + finalStats.size > BigInt(MAX_CARGO_MANIFEST_BYTES) || + !hasSameIdentity(opened.stats, finalStats) || + !hasSameReadMetadata(opened.stats, finalStats) || + !(await hasUnchangedSafePath(opened)) + ) { + return undefined; + } + + const content = new TextDecoder("utf-8", { fatal: true }).decode( + buffer.subarray(0, totalBytes) + ); + return content.includes("\0") ? undefined : content; + } catch { + return undefined; + } finally { + await opened.handle.close().catch(() => {}); + } +} + +function hasRootCandidate(context, candidates) { + const rootFiles = Array.isArray(context?.rootFiles) ? context.rootFiles : []; + return candidates.some((candidate) => rootFiles.includes(candidate)); +} + +function hasNodeScope(context) { + return ( + hasRootCandidate(context, ["package.json"]) || + Boolean(context?.rootPackageJson) || + (Array.isArray(context?.apps) && context.apps.some((app) => app?.ecosystem === "node")) + ); +} + +async function isRootRustRepository(context) { + return isSafeFixedFile(context?.repoPath, "Cargo.toml"); +} + +async function isPureRustRepository(context) { + return (await isRootRustRepository(context)) && !hasNodeScope(context); +} + +async function hasCargoLintHeader(repoPath) { + const content = await readSafeCargoManifest(repoPath); + if (!content) return false; + + const uncommented = content + .split(/\r?\n/u) + .filter((line) => !line.trimStart().startsWith("#")) + .join("\n"); + return /^\s*\[(?:workspace\.)?lints\]\s*(?:#.*)?$/mu.test(uncommented); +} + +async function rustLintResult(context) { + const evidence = await firstSafeFixedFile(context?.repoPath, RUST_LINT_CANDIDATES); + if (evidence) return { status: "pass", evidence: [evidence] }; + if (await hasCargoLintHeader(context?.repoPath)) { + return { status: "pass", evidence: ["Cargo.toml"] }; + } + return { + status: "fail", + reason: + "Missing Rust lint configuration (clippy.toml, .clippy.toml, or an uncommented [lints] table).", + evidence: [...RUST_LINT_CANDIDATES, "Cargo.toml"] + }; +} + +async function rustFormatResult(context) { + const evidence = await firstSafeFixedFile(context?.repoPath, RUST_FORMAT_CANDIDATES); + return evidence + ? { status: "pass", evidence: [evidence] } + : { + status: "fail", + reason: "Missing rustfmt configuration.", + evidence: [...RUST_FORMAT_CANDIDATES] + }; +} + +async function rustTypecheckResult(context) { + const found = await isRootRustRepository(context); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing a root Cargo.toml manifest for Rust static type checking.", + evidence: ["Cargo.toml"] + }; +} + +async function rustLockfileResult(context) { + if (await isSafeFixedFile(context?.repoPath, "Cargo.lock")) { + return { status: "pass", evidence: ["Cargo.lock"] }; + } + return { + status: "skip", + reason: "No Cargo.lock found; skipping because this may be a library crate." + }; +} + +async function rustOnlyResult(context, candidates, title) { + if (!(await isPureRustRepository(context))) { + return { status: "skip", reason: "Not a pure Rust repository." }; + } + + const evidence = await firstSafeFixedFile(context?.repoPath, candidates); + return evidence + ? { status: "pass", evidence: [evidence] } + : { status: "fail", reason: `Missing ${title}.`, evidence: [...candidates] }; +} + +async function nodeLintResult(context) { + const found = hasRootCandidate(context, NODE_LINT_CANDIDATES); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing ESLint/Biome/Prettier configuration.", + evidence: ["eslint.config.js", ".eslintrc", "biome.json", ".prettierrc"] + }; +} + +async function nodeFormatResult(context) { + const found = hasRootCandidate(context, NODE_FORMAT_CANDIDATES); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing Prettier/Biome formatting config." + }; +} + +async function typecheckResult(context) { + const found = hasRootCandidate(context, TYPECHECK_CANDIDATES); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing type checking config (tsconfig or equivalent).", + evidence: [...TYPECHECK_CANDIDATES] + }; +} + +function nodeBuildResult(app) { + const found = Boolean(app?.scripts?.build); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing build script in package.json." + }; +} + +function nodeTestResult(app) { + const found = Boolean(app?.scripts?.test); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing test script in package.json." + }; +} + +function nodeLockfileResult(context) { + const found = hasRootCandidate(context, NODE_LOCKFILE_CANDIDATES); + return { + status: found ? "pass" : "fail", + reason: found ? undefined : "Missing package manager lockfile." + }; +} + +export default { + name: "rust-readiness", + version: "1.0.0", + criteria: { + add: [ + { + id: "lint-config", + title: "Linting configured", + pillar: "style-validation", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => + (await isPureRustRepository(context)) ? rustLintResult(context) : nodeLintResult(context) + }, + { + id: "format-config", + title: "Formatter configured", + pillar: "code-quality", + level: 2, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => + (await isPureRustRepository(context)) + ? rustFormatResult(context) + : nodeFormatResult(context) + }, + { + id: "typecheck-config", + title: "Type checking configured", + pillar: "style-validation", + level: 2, + scope: "repo", + impact: "medium", + effort: "low", + check: async (context) => + (await isPureRustRepository(context)) + ? rustTypecheckResult(context) + : typecheckResult(context) + }, + { + id: "build-script", + title: "Build script present", + pillar: "build-system", + level: 1, + scope: "app", + impact: "high", + effort: "low", + check: async (_context, app) => + app?.ecosystem === "rust" + ? { status: "pass", evidence: ["Cargo build capability"] } + : nodeBuildResult(app) + }, + { + id: "test-script", + title: "Test script present", + pillar: "testing", + level: 1, + scope: "app", + impact: "high", + effort: "low", + check: async (_context, app) => + app?.ecosystem === "rust" + ? { status: "pass", evidence: ["Cargo test capability"] } + : nodeTestResult(app) + }, + { + id: "lockfile", + title: "Lockfile present", + pillar: "dev-environment", + level: 1, + scope: "repo", + impact: "high", + effort: "low", + check: async (context) => + (await isPureRustRepository(context)) + ? rustLockfileResult(context) + : nodeLockfileResult(context) + }, + { + id: "rust-toolchain-pinned", + title: "Rust toolchain pinned", + pillar: "dev-environment", + level: 2, + scope: "repo", + impact: "low", + effort: "low", + check: async (context) => + rustOnlyResult(context, RUST_TOOLCHAIN_CANDIDATES, "Rust toolchain pinning") + }, + { + id: "rust-supply-chain", + title: "Rust supply-chain policy configured", + pillar: "security-governance", + level: 3, + scope: "repo", + impact: "medium", + effort: "medium", + check: async (context) => + rustOnlyResult(context, RUST_SUPPLY_CHAIN_CANDIDATES, "Rust supply-chain policy") + } + ] + } +}; diff --git a/packages/core/src/services/readiness/criteria.ts b/packages/core/src/services/readiness/criteria.ts index b202a015..f0c8b91e 100644 --- a/packages/core/src/services/readiness/criteria.ts +++ b/packages/core/src/services/readiness/criteria.ts @@ -57,7 +57,7 @@ export function buildCriteria(): ReadinessCriterion[] { return { status: found ? "pass" : "fail", reason: found ? undefined : "Missing type checking config (tsconfig or equivalent).", - evidence: ["tsconfig.json", "pyproject.toml", "mypy.ini"] + evidence: ["tsconfig.json", "tsconfig.base.json", "pyproject.toml", "mypy.ini"] }; } }, diff --git a/src/services/__tests__/fixtures/rust-policy/cargo-workspace/Cargo.lock b/src/services/__tests__/fixtures/rust-policy/cargo-workspace/Cargo.lock new file mode 100644 index 00000000..7f7d0adc --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/cargo-workspace/Cargo.lock @@ -0,0 +1,2 @@ +# Workspace fixture lockfile. +version = 4 diff --git a/src/services/__tests__/fixtures/rust-policy/cargo-workspace/Cargo.toml b/src/services/__tests__/fixtures/rust-policy/cargo-workspace/Cargo.toml new file mode 100644 index 00000000..5b461820 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/cargo-workspace/Cargo.toml @@ -0,0 +1,2 @@ +[workspace] +members = ["crates/api", "crates/worker"] diff --git a/src/services/__tests__/fixtures/rust-policy/cargo-workspace/crates/api/Cargo.toml b/src/services/__tests__/fixtures/rust-policy/cargo-workspace/crates/api/Cargo.toml new file mode 100644 index 00000000..3d992580 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/cargo-workspace/crates/api/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "workspace-api" +version = "0.1.0" +edition = "2024" diff --git a/src/services/__tests__/fixtures/rust-policy/cargo-workspace/crates/worker/Cargo.toml b/src/services/__tests__/fixtures/rust-policy/cargo-workspace/crates/worker/Cargo.toml new file mode 100644 index 00000000..2d0aa38f --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/cargo-workspace/crates/worker/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "workspace-worker" +version = "0.1.0" +edition = "2024" diff --git a/src/services/__tests__/fixtures/rust-policy/minimal-rust/Cargo.toml b/src/services/__tests__/fixtures/rust-policy/minimal-rust/Cargo.toml new file mode 100644 index 00000000..a4b552ca --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/minimal-rust/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "minimal-rust" +version = "0.1.0" +edition = "2024" diff --git a/src/services/__tests__/fixtures/rust-policy/mixed-rust-node/Cargo.toml b/src/services/__tests__/fixtures/rust-policy/mixed-rust-node/Cargo.toml new file mode 100644 index 00000000..36690443 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/mixed-rust-node/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "mixed-rust-node" +version = "0.1.0" +edition = "2024" diff --git a/src/services/__tests__/fixtures/rust-policy/mixed-rust-node/biome.json b/src/services/__tests__/fixtures/rust-policy/mixed-rust-node/biome.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/mixed-rust-node/biome.json @@ -0,0 +1 @@ +{} diff --git a/src/services/__tests__/fixtures/rust-policy/mixed-rust-node/package-lock.json b/src/services/__tests__/fixtures/rust-policy/mixed-rust-node/package-lock.json new file mode 100644 index 00000000..ea4d7907 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/mixed-rust-node/package-lock.json @@ -0,0 +1,3 @@ +{ + "lockfileVersion": 3 +} diff --git a/src/services/__tests__/fixtures/rust-policy/mixed-rust-node/package.json b/src/services/__tests__/fixtures/rust-policy/mixed-rust-node/package.json new file mode 100644 index 00000000..c0374b44 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/mixed-rust-node/package.json @@ -0,0 +1,8 @@ +{ + "name": "mixed-rust-node", + "private": true, + "scripts": { + "build": "node build.mjs", + "test": "node test.mjs" + } +} diff --git a/src/services/__tests__/fixtures/rust-policy/node-only/.prettierrc b/src/services/__tests__/fixtures/rust-policy/node-only/.prettierrc new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/node-only/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/src/services/__tests__/fixtures/rust-policy/node-only/biome.json b/src/services/__tests__/fixtures/rust-policy/node-only/biome.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/node-only/biome.json @@ -0,0 +1 @@ +{} diff --git a/src/services/__tests__/fixtures/rust-policy/node-only/package-lock.json b/src/services/__tests__/fixtures/rust-policy/node-only/package-lock.json new file mode 100644 index 00000000..ea4d7907 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/node-only/package-lock.json @@ -0,0 +1,3 @@ +{ + "lockfileVersion": 3 +} diff --git a/src/services/__tests__/fixtures/rust-policy/node-only/package.json b/src/services/__tests__/fixtures/rust-policy/node-only/package.json new file mode 100644 index 00000000..d494b5a1 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/node-only/package.json @@ -0,0 +1,8 @@ +{ + "name": "node-only", + "private": true, + "scripts": { + "build": "node build.mjs", + "test": "node test.mjs" + } +} diff --git a/src/services/__tests__/fixtures/rust-policy/node-only/tsconfig.json b/src/services/__tests__/fixtures/rust-policy/node-only/tsconfig.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/node-only/tsconfig.json @@ -0,0 +1 @@ +{} diff --git a/src/services/__tests__/fixtures/rust-policy/python-only/mypy.ini b/src/services/__tests__/fixtures/rust-policy/python-only/mypy.ini new file mode 100644 index 00000000..f8b1844b --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/python-only/mypy.ini @@ -0,0 +1 @@ +[mypy] diff --git a/src/services/__tests__/fixtures/rust-policy/python-only/pyproject.toml b/src/services/__tests__/fixtures/rust-policy/python-only/pyproject.toml new file mode 100644 index 00000000..137a8db7 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/python-only/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "python-only" +version = "0.1.0" diff --git a/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/.cargo/audit.toml b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/.cargo/audit.toml new file mode 100644 index 00000000..ade0ef40 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/.cargo/audit.toml @@ -0,0 +1,2 @@ +[database] +stale = false diff --git a/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/AGENTS.md b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/AGENTS.md new file mode 100644 index 00000000..3475e6a3 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/AGENTS.md @@ -0,0 +1,3 @@ +# Fixture instructions + +Keep the workspace lint, formatting, test, and supply-chain evidence intact. diff --git a/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/Cargo.lock b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/Cargo.lock new file mode 100644 index 00000000..6f6f5d95 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/Cargo.lock @@ -0,0 +1,2 @@ +# This fixture lockfile intentionally contains no resolved packages. +version = 4 diff --git a/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/Cargo.toml b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/Cargo.toml new file mode 100644 index 00000000..0630a509 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = ["crates/app", "crates/worker"] + +[workspace.lints] +unsafe_code = "forbid" diff --git a/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/LICENSE b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/LICENSE new file mode 100644 index 00000000..d1e1072e --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/LICENSE @@ -0,0 +1 @@ +MIT License diff --git a/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/README.md b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/README.md new file mode 100644 index 00000000..61f2350c --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/README.md @@ -0,0 +1,3 @@ +# Well-tooled Rust workspace + +Fixture repository used to verify Rust readiness policy behavior. diff --git a/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/clippy.toml b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/clippy.toml new file mode 100644 index 00000000..cda8d17e --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/clippy.toml @@ -0,0 +1 @@ +avoid-breaking-exported-api = false diff --git a/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/crates/app/Cargo.toml b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/crates/app/Cargo.toml new file mode 100644 index 00000000..5ef803e2 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/crates/app/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "workspace-app" +version = "0.1.0" +edition = "2024" diff --git a/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/crates/worker/Cargo.toml b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/crates/worker/Cargo.toml new file mode 100644 index 00000000..2d0aa38f --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/crates/worker/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "workspace-worker" +version = "0.1.0" +edition = "2024" diff --git a/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/deny.toml b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/deny.toml new file mode 100644 index 00000000..ab0f3550 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/deny.toml @@ -0,0 +1,2 @@ +[advisories] +unmaintained = "workspace" diff --git a/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/rust-toolchain.toml b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/rust-toolchain.toml new file mode 100644 index 00000000..292fe499 --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" diff --git a/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/rustfmt.toml b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/rustfmt.toml new file mode 100644 index 00000000..f216078d --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/rustfmt.toml @@ -0,0 +1 @@ +edition = "2024" diff --git a/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/supply-chain/config.toml b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/supply-chain/config.toml new file mode 100644 index 00000000..dc686dba --- /dev/null +++ b/src/services/__tests__/fixtures/rust-policy/well-tooled-rust/supply-chain/config.toml @@ -0,0 +1,2 @@ +[policy] +allow = [] diff --git a/src/services/__tests__/rust-policy.test.ts b/src/services/__tests__/rust-policy.test.ts new file mode 100644 index 00000000..03f51b83 --- /dev/null +++ b/src/services/__tests__/rust-policy.test.ts @@ -0,0 +1,783 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { fileURLToPath, pathToFileURL } from "url"; + +import type { PolicyConfig } from "@agentrc/core/services/policy"; +import { loadPolicy } from "@agentrc/core/services/policy"; +import { isNativePlugin } from "@agentrc/core/services/policy/types"; +import type { + ReadinessContext, + ReadinessCriterion, + ReadinessCriterionResult, + ReadinessReport +} from "@agentrc/core/services/readiness"; +import { runReadinessReport } from "@agentrc/core/services/readiness"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(testDirectory, "../../.."); +const fixturesDirectory = path.join(testDirectory, "fixtures", "rust-policy"); +const policyPath = path.join(repositoryRoot, "examples", "policies", "rust.mjs"); +const strictPolicyPath = path.join(repositoryRoot, "examples", "policies", "strict.json"); +const maxCargoManifestBytes = 1024 * 1024; + +interface IdentityStats { + ctimeNs: bigint; + dev: bigint; + ino: bigint; + mode: bigint; + mtimeNs: bigint; + nlink: bigint; + size: bigint; +} + +const replacementIds = [ + "lint-config", + "format-config", + "typecheck-config", + "build-script", + "test-script", + "lockfile" +] as const; +const customIds = ["rust-toolchain-pinned", "rust-supply-chain"] as const; +const temporaryRoots = new Set(); +const criterionMetadata = { + "lint-config": { + id: "lint-config", + title: "Linting configured", + pillar: "style-validation", + level: 1, + scope: "repo", + impact: "high", + effort: "low" + }, + "format-config": { + id: "format-config", + title: "Formatter configured", + pillar: "code-quality", + level: 2, + scope: "repo", + impact: "medium", + effort: "low" + }, + "typecheck-config": { + id: "typecheck-config", + title: "Type checking configured", + pillar: "style-validation", + level: 2, + scope: "repo", + impact: "medium", + effort: "low" + }, + "build-script": { + id: "build-script", + title: "Build script present", + pillar: "build-system", + level: 1, + scope: "app", + impact: "high", + effort: "low" + }, + "test-script": { + id: "test-script", + title: "Test script present", + pillar: "testing", + level: 1, + scope: "app", + impact: "high", + effort: "low" + }, + lockfile: { + id: "lockfile", + title: "Lockfile present", + pillar: "dev-environment", + level: 1, + scope: "repo", + impact: "high", + effort: "low" + }, + "rust-toolchain-pinned": { + id: "rust-toolchain-pinned", + title: "Rust toolchain pinned", + pillar: "dev-environment", + level: 2, + scope: "repo", + impact: "low", + effort: "low" + }, + "rust-supply-chain": { + id: "rust-supply-chain", + title: "Rust supply-chain policy configured", + pillar: "security-governance", + level: 3, + scope: "repo", + impact: "medium", + effort: "medium" + } +} as const; + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + [...temporaryRoots].map((root) => fs.rm(root, { recursive: true, force: true })) + ); + temporaryRoots.clear(); +}); + +async function copyFixture(name: string): Promise { + const parent = await fs.mkdtemp(path.join(os.tmpdir(), "agentrc-rust-policy-")); + const destination = path.join(parent, name); + await fs.cp(path.join(fixturesDirectory, name), destination, { recursive: true }); + temporaryRoots.add(parent); + return destination; +} + +async function writeText( + root: string, + relativePath: string, + content: string | Uint8Array +): Promise { + const target = path.join(root, relativePath); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, content); +} + +async function loadIdentityComparator( + hasNofollow: boolean +): Promise<(left: IdentityStats, right: IdentityStats) => boolean> { + const parent = await fs.mkdtemp(path.join(os.tmpdir(), "agentrc-rust-policy-identity-")); + const variantPath = path.join(parent, `rust-${hasNofollow ? "nofollow" : "fallback"}.mjs`); + temporaryRoots.add(parent); + + const source = await fs.readFile(policyPath, "utf8"); + const variant = source + .replace( + 'const HAS_NOFOLLOW = typeof fsConstants.O_NOFOLLOW === "number";', + `const HAS_NOFOLLOW = ${hasNofollow};` + ) + .replace("\nexport default {", "\nexport { hasSameIdentity };\n\nexport default {"); + await fs.writeFile(variantPath, variant); + + const loaded = (await import(pathToFileURL(variantPath).href)) as { + hasSameIdentity: (left: IdentityStats, right: IdentityStats) => boolean; + }; + return loaded.hasSameIdentity; +} + +async function runPolicyReport( + repoPath: string, + policies = [policyPath] +): Promise { + return runReadinessReport({ repoPath, policies }); +} + +function getCriterion(report: ReadinessReport, id: string): ReadinessCriterionResult { + const result = report.criteria.find((criterion) => criterion.id === id); + expect(result, `expected ${id} in readiness report`).toBeDefined(); + return result!; +} + +function expectRustCriterion( + report: ReadinessReport, + id: keyof typeof criterionMetadata, + result: Omit +): void { + expect(getCriterion(report, id)).toEqual({ + ...criterionMetadata[id], + ...result + }); +} + +function getPolicyConfig(value: Awaited>): PolicyConfig { + expect(isNativePlugin(value)).toBe(false); + if (isNativePlugin(value)) { + throw new Error("The Rust example must use the legacy PolicyConfig path."); + } + return value; +} + +async function getPolicyCriterion(id: string): Promise { + const policy = getPolicyConfig(await loadPolicy(policyPath)); + const criterion = policy.criteria?.add?.find((candidate) => candidate.id === id); + expect(criterion, `expected ${id} in Rust policy`).toBeDefined(); + return criterion!; +} + +function createPolicyContext(repoPath: string): ReadinessContext { + return { + repoPath, + analysis: { + path: repoPath, + isGitRepo: false, + languages: ["Rust"], + frameworks: [] + }, + apps: [], + rootFiles: ["Cargo.toml"] + }; +} + +function replacementOutput(report: ReadinessReport): string { + return report.criteria + .filter((criterion) => replacementIds.includes(criterion.id as (typeof replacementIds)[number])) + .flatMap((criterion) => [criterion.reason ?? "", ...(criterion.evidence ?? [])]) + .join("\n"); +} + +function normalizeNonRustReport(report: ReadinessReport) { + return { + criteria: report.criteria.filter((criterion) => + replacementIds.includes(criterion.id as (typeof replacementIds)[number]) + ), + pillars: report.pillars, + levels: report.levels, + achievedLevel: report.achievedLevel, + extras: report.extras + }; +} + +describe("Rust readiness policy", () => { + it("loads as a root-level legacy PolicyConfig with eight criteria", async () => { + const policy = getPolicyConfig(await loadPolicy(policyPath)); + + expect(policy.name).toBe("rust-readiness"); + expect(policy.version).toBe("1.0.0"); + expect(policy.criteria?.add?.map((criterion) => criterion.id)).toEqual([ + ...replacementIds, + ...customIds + ]); + expect("meta" in policy).toBe(false); + }); + + it("changes the primary readiness result without using shadow-engine output", async () => { + const repoPath = await copyFixture("minimal-rust"); + const baseline = await runReadinessReport({ repoPath }); + const policyReport = await runPolicyReport(repoPath); + + expect(policyReport.engine).toBeUndefined(); + expect(policyReport.policies?.chain).toEqual(["rust-readiness"]); + expect(getCriterion(baseline, "typecheck-config").status).toBe("fail"); + expectRustCriterion(policyReport, "typecheck-config", { + status: "pass", + evidence: ["Cargo.toml"] + }); + expectRustCriterion(policyReport, "lockfile", { + status: "skip", + reason: "No Cargo.lock found; skipping because this may be a library crate." + }); + expect(policyReport.levels).not.toEqual(baseline.levels); + }); + + it("loads a reviewed copy of the policy from a Rust repository", async () => { + const repoPath = await copyFixture("minimal-rust"); + const copiedPolicyPath = path.join(repoPath, "rust.mjs"); + await fs.copyFile(policyPath, copiedPolicyPath); + + const report = await runPolicyReport(repoPath, [copiedPolicyPath]); + expect(getCriterion(report, "typecheck-config").status).toBe("pass"); + }); + + it("gives a well-tooled Rust workspace more passing readiness criteria than minimal Rust", async () => { + const minimalReport = await runPolicyReport(await copyFixture("minimal-rust")); + const wellTooledPath = await copyFixture("well-tooled-rust"); + const wellTooledBaseline = await runReadinessReport({ repoPath: wellTooledPath }); + const wellTooledReport = await runPolicyReport(wellTooledPath); + const passingCount = (report: ReadinessReport) => + report.criteria.filter((criterion) => criterion.status === "pass").length; + + expect(passingCount(wellTooledReport)).toBeGreaterThan(passingCount(minimalReport)); + expect(wellTooledReport.achievedLevel).toBeGreaterThan(minimalReport.achievedLevel); + expect(wellTooledReport.achievedLevel).toBeGreaterThan(wellTooledBaseline.achievedLevel); + expect(wellTooledReport.levels).not.toEqual(wellTooledBaseline.levels); + expect(wellTooledBaseline.levels.find((level) => level.level === 1)).toMatchObject({ + achieved: false, + passed: 3, + total: 7 + }); + expect(wellTooledReport.achievedLevel).toBe(1); + expect(wellTooledReport.levels.find((level) => level.level === 1)).toMatchObject({ + achieved: true, + passed: 7, + total: 7 + }); + expectRustCriterion(wellTooledReport, "build-script", { + status: "pass", + passRate: 1, + appSummary: { passed: 2, total: 2 }, + appFailures: [] + }); + expectRustCriterion(wellTooledReport, "test-script", { + status: "pass", + passRate: 1, + appSummary: { passed: 2, total: 2 }, + appFailures: [] + }); + }); + + it("uses analyzer-detected Cargo workspace apps while standalone crates retain app-scope skips", async () => { + const standalone = await runPolicyReport(await copyFixture("minimal-rust")); + const workspace = await runPolicyReport(await copyFixture("cargo-workspace")); + + expectRustCriterion(standalone, "build-script", { + status: "skip", + reason: "No application packages detected." + }); + expectRustCriterion(standalone, "test-script", { + status: "skip", + reason: "No application packages detected." + }); + expectRustCriterion(workspace, "build-script", { + status: "pass", + passRate: 1, + appSummary: { passed: 2, total: 2 }, + appFailures: [] + }); + expectRustCriterion(workspace, "test-script", { + status: "pass", + passRate: 1, + appSummary: { passed: 2, total: 2 }, + appFailures: [] + }); + }); + + it("uses Rust-specific output for pure Rust replacement criteria", async () => { + const report = await runPolicyReport(await copyFixture("minimal-rust")); + + expect(replacementOutput(report)).not.toMatch( + /npm|package\.json|typescript|eslint|biome|prettier/iu + ); + }); + + it.each([ + ["clippy.toml", "clippy.toml"], + [".clippy.toml", ".clippy.toml"], + ["Cargo.toml", '[package]\nname = "lint-header"\n\n[lints]\nunsafe_code = "forbid"\n'], + ["Cargo.toml", '[workspace]\nmembers = []\n\n[workspace.lints]\nunsafe_code = "forbid"\n'] + ])("recognizes lint evidence from %s", async (file, content) => { + const repoPath = await copyFixture("minimal-rust"); + await writeText(repoPath, file, content); + + expectRustCriterion(await runPolicyReport(repoPath), "lint-config", { + status: "pass", + evidence: [file] + }); + }); + + it("does not treat a commented Cargo lint header as evidence", async () => { + const repoPath = await copyFixture("minimal-rust"); + await writeText(repoPath, "Cargo.toml", '[package]\nname = "commented"\n# [lints]\n'); + + expectRustCriterion(await runPolicyReport(repoPath), "lint-config", { + status: "fail", + reason: + "Missing Rust lint configuration (clippy.toml, .clippy.toml, or an uncommented [lints] table).", + evidence: ["clippy.toml", ".clippy.toml", "Cargo.toml"] + }); + }); + + it.each(["rustfmt.toml", ".rustfmt.toml"])( + "recognizes %s as Rust format evidence", + async (file) => { + const repoPath = await copyFixture("minimal-rust"); + await writeText(repoPath, file, 'edition = "2024"\n'); + + expectRustCriterion(await runPolicyReport(repoPath), "format-config", { + status: "pass", + evidence: [file] + }); + } + ); + + it("recognizes a root Cargo manifest for static type checking", async () => { + const report = await runPolicyReport(await copyFixture("minimal-rust")); + + expectRustCriterion(report, "typecheck-config", { + status: "pass", + evidence: ["Cargo.toml"] + }); + }); + + it("passes Cargo lockfile evidence and skips a missing pure-Rust lockfile", async () => { + const minimal = await copyFixture("minimal-rust"); + const locked = await copyFixture("minimal-rust"); + await writeText(locked, "Cargo.lock", "version = 4\n"); + + expectRustCriterion(await runPolicyReport(minimal), "lockfile", { + status: "skip", + reason: "No Cargo.lock found; skipping because this may be a library crate." + }); + expectRustCriterion(await runPolicyReport(locked), "lockfile", { + status: "pass", + evidence: ["Cargo.lock"] + }); + }); + + it.each(["rust-toolchain.toml", "rust-toolchain"])( + "recognizes %s as a pinned toolchain", + async (file) => { + const repoPath = await copyFixture("minimal-rust"); + await writeText(repoPath, file, "stable\n"); + + expectRustCriterion(await runPolicyReport(repoPath), "rust-toolchain-pinned", { + status: "pass", + evidence: [file] + }); + } + ); + + it.each(["deny.toml", ".cargo/audit.toml", "supply-chain/config.toml"])( + "recognizes %s as supply-chain evidence", + async (file) => { + const repoPath = await copyFixture("minimal-rust"); + await writeText(repoPath, file, "[policy]\n"); + + expectRustCriterion(await runPolicyReport(repoPath), "rust-supply-chain", { + status: "pass", + evidence: [file] + }); + } + ); + + it("reports exact missing Rust evidence contracts", async () => { + const report = await runPolicyReport(await copyFixture("minimal-rust")); + + expectRustCriterion(report, "format-config", { + status: "fail", + reason: "Missing rustfmt configuration.", + evidence: ["rustfmt.toml", ".rustfmt.toml"] + }); + expectRustCriterion(report, "rust-toolchain-pinned", { + status: "fail", + reason: "Missing Rust toolchain pinning.", + evidence: ["rust-toolchain.toml", "rust-toolchain"] + }); + expectRustCriterion(report, "rust-supply-chain", { + status: "fail", + reason: "Missing Rust supply-chain policy.", + evidence: ["deny.toml", ".cargo/audit.toml", "supply-chain/config.toml"] + }); + }); + + it("preserves Node fallback behavior for a mixed repository", async () => { + const repoPath = await copyFixture("mixed-rust-node"); + const baseline = await runReadinessReport({ repoPath }); + const policyReport = await runPolicyReport(repoPath); + + for (const id of ["lint-config", "format-config", "typecheck-config", "lockfile"] as const) { + expect(getCriterion(policyReport, id)).toEqual(getCriterion(baseline, id)); + } + for (const id of customIds) { + expectRustCriterion(policyReport, id, { + status: "skip", + reason: "Not a pure Rust repository." + }); + } + }); + + it("keeps non-Rust Node and Python reports structurally identical after normalization", async () => { + for (const fixture of ["node-only", "python-only"]) { + const repoPath = await copyFixture(fixture); + const baseline = await runReadinessReport({ repoPath }); + const policyReport = await runPolicyReport(repoPath); + + expect(normalizeNonRustReport(policyReport)).toEqual(normalizeNonRustReport(baseline)); + for (const id of customIds) { + expect(getCriterion(policyReport, id).status).toBe("skip"); + } + } + }); + + it("lets a later organization policy retain its metadata override", async () => { + const report = await runPolicyReport(await copyFixture("well-tooled-rust"), [ + policyPath, + strictPolicyPath + ]); + + expect(getCriterion(report, "format-config").impact).toBe("high"); + }); + + it("lets a later organization policy disable a Rust replacement criterion", async () => { + const repoPath = await copyFixture("well-tooled-rust"); + const organizationPolicyPath = path.join(repoPath, "organization.json"); + await writeText( + repoPath, + "organization.json", + JSON.stringify({ + name: "organization-disable", + criteria: { disable: ["format-config"] } + }) + ); + + const report = await runPolicyReport(repoPath, [policyPath, organizationPolicyPath]); + + expect(report.policies?.chain).toEqual(["rust-readiness", "organization-disable"]); + expect(report.criteria.some((criterion) => criterion.id === "format-config")).toBe(false); + }); + + it("fails closed for a symlinked evidence file and oversized or binary Cargo content", async () => { + const repoPath = await copyFixture("minimal-rust"); + const externalPath = path.join(path.dirname(repoPath), "external-clippy.toml"); + await fs.writeFile(externalPath, "avoid-breaking-exported-api = false\n"); + await fs.symlink(externalPath, path.join(repoPath, "clippy.toml")); + + expect(getCriterion(await runPolicyReport(repoPath), "lint-config").status).toBe("fail"); + + await writeText(repoPath, "Cargo.toml", new Uint8Array(1_048_577)); + await expect(runPolicyReport(repoPath)).resolves.toBeDefined(); + expect(getCriterion(await runPolicyReport(repoPath), "lint-config").status).toBe("fail"); + + await writeText(repoPath, "Cargo.toml", new Uint8Array([0, 1, 2, 3])); + await expect(runPolicyReport(repoPath)).resolves.toBeDefined(); + }); + + it.each([ + [true, true], + [false, false] + ])( + "handles zero device and inode values when HAS_NOFOLLOW is %s", + async (hasNofollow, expected) => { + const hasSameIdentity = await loadIdentityComparator(hasNofollow); + const stats: IdentityStats = { + ctimeNs: 4n, + dev: 0n, + ino: 0n, + mode: 0o100644n, + mtimeNs: 3n, + nlink: 1n, + size: 2n + }; + + expect(hasSameIdentity(stats, { ...stats })).toBe(expected); + expect(hasSameIdentity(stats, { ...stats, nlink: 2n })).toBe(false); + } + ); + + it("fails closed if Cargo.toml becomes a symlink between inspection and open", async () => { + const repoPath = await copyFixture("minimal-rust"); + const cargoPath = path.join(repoPath, "Cargo.toml"); + const backupPath = path.join(repoPath, "Cargo.original.toml"); + const externalPath = path.join(path.dirname(repoPath), "external-Cargo.toml"); + await fs.writeFile(externalPath, '[package]\nname = "external"\n\n[lints]\n'); + + const originalOpen = fs.open.bind(fs); + let cargoOpenCount = 0; + vi.spyOn(fs, "open").mockImplementation(async (target, flags, mode) => { + if (path.resolve(String(target)) === cargoPath && ++cargoOpenCount === 2) { + await fs.rename(cargoPath, backupPath); + await fs.symlink(externalPath, cargoPath); + } + return originalOpen(target, flags, mode); + }); + + const lintCriterion = await getPolicyCriterion("lint-config"); + await expect(lintCriterion.check(createPolicyContext(repoPath))).resolves.toEqual({ + status: "fail", + reason: + "Missing Rust lint configuration (clippy.toml, .clippy.toml, or an uncommented [lints] table).", + evidence: ["clippy.toml", ".clippy.toml", "Cargo.toml"] + }); + }); + + it("rejects a Cargo manifest that grows after the bounded read reaches EOF", async () => { + const repoPath = await copyFixture("minimal-rust"); + const cargoPath = path.join(repoPath, "Cargo.toml"); + const content = Buffer.alloc(maxCargoManifestBytes, 0x20); + Buffer.from('[package]\nname = "growth-race"\n\n[lints]\n').copy(content); + await fs.writeFile(cargoPath, content); + + const originalOpen = fs.open.bind(fs); + const requestedReadLengths: number[] = []; + let totalBytesRead = 0; + let cargoOpenCount = 0; + let grewAfterEof = false; + vi.spyOn(fs, "open").mockImplementation(async (target, flags, mode) => { + const handle = await originalOpen(target, flags, mode); + if (path.resolve(String(target)) !== cargoPath || ++cargoOpenCount !== 2) return handle; + + const originalRead = handle.read.bind(handle) as ( + buffer: Uint8Array, + offset: number, + length: number, + position: number | null + ) => Promise<{ bytesRead: number; buffer: Uint8Array }>; + const interceptedRead = async ( + buffer: Uint8Array, + offset: number, + length: number, + position: number | null + ) => { + requestedReadLengths.push(length); + const result = await originalRead(buffer, offset, length, position); + totalBytesRead += result.bytesRead; + if (result.bytesRead === 0 && !grewAfterEof) { + const writer = await originalOpen(cargoPath, "a"); + try { + await writer.write(Buffer.from("x"), 0, 1, null); + } finally { + await writer.close(); + } + grewAfterEof = true; + } + return result; + }; + + return new Proxy(handle, { + get(targetHandle, property) { + if (property === "read") return interceptedRead; + const value = Reflect.get(targetHandle, property, targetHandle); + return typeof value === "function" ? value.bind(targetHandle) : value; + } + }); + }); + + const lintCriterion = await getPolicyCriterion("lint-config"); + await expect(lintCriterion.check(createPolicyContext(repoPath))).resolves.toMatchObject({ + status: "fail" + }); + expect(grewAfterEof).toBe(true); + expect(Math.max(...requestedReadLengths)).toBe(maxCargoManifestBytes + 1); + expect(totalBytesRead).toBeLessThanOrEqual(maxCargoManifestBytes + 1); + await expect(fs.stat(cargoPath)).resolves.toMatchObject({ size: maxCargoManifestBytes + 1 }); + }); + + it("rejects a Cargo manifest changed in place during a bounded read", async () => { + const repoPath = await copyFixture("minimal-rust"); + const cargoPath = path.join(repoPath, "Cargo.toml"); + const content = Buffer.alloc(maxCargoManifestBytes, 0x20); + Buffer.from('[package]\nname = "in-place-race"\n\n[lints]\n').copy(content); + await fs.writeFile(cargoPath, content); + + const originalOpen = fs.open.bind(fs); + let cargoOpenCount = 0; + let changedInPlace = false; + vi.spyOn(fs, "open").mockImplementation(async (target, flags, mode) => { + const handle = await originalOpen(target, flags, mode); + if (path.resolve(String(target)) !== cargoPath || ++cargoOpenCount !== 2) return handle; + + const originalRead = handle.read.bind(handle) as ( + buffer: Uint8Array, + offset: number, + length: number, + position: number | null + ) => Promise<{ bytesRead: number; buffer: Uint8Array }>; + const interceptedRead = async ( + buffer: Uint8Array, + offset: number, + length: number, + position: number | null + ) => { + const result = await originalRead(buffer, offset, length, position); + if (result.bytesRead === 0 && !changedInPlace) { + const writer = await originalOpen(cargoPath, "r+"); + try { + await writer.write(Buffer.from("x"), 0, 1, maxCargoManifestBytes - 1); + } finally { + await writer.close(); + } + changedInPlace = true; + } + return result; + }; + + return new Proxy(handle, { + get(targetHandle, property) { + if (property === "read") return interceptedRead; + const value = Reflect.get(targetHandle, property, targetHandle); + return typeof value === "function" ? value.bind(targetHandle) : value; + } + }); + }); + + const lintCriterion = await getPolicyCriterion("lint-config"); + await expect(lintCriterion.check(createPolicyContext(repoPath))).resolves.toMatchObject({ + status: "fail" + }); + expect(changedInPlace).toBe(true); + await expect(fs.stat(cargoPath)).resolves.toMatchObject({ size: maxCargoManifestBytes }); + }); + + it("rejects evidence beneath a symlinked repository directory", async () => { + const repoPath = await copyFixture("minimal-rust"); + const externalDirectory = path.join(path.dirname(repoPath), "external-cargo-config"); + await fs.mkdir(externalDirectory); + await fs.writeFile(path.join(externalDirectory, "audit.toml"), "[advisories]\n"); + await fs.symlink(externalDirectory, path.join(repoPath, ".cargo"), "dir"); + + expectRustCriterion(await runPolicyReport(repoPath), "rust-supply-chain", { + status: "fail", + reason: "Missing Rust supply-chain policy.", + evidence: ["deny.toml", ".cargo/audit.toml", "supply-chain/config.toml"] + }); + }); + + it("rejects a Cargo manifest containing malformed UTF-8 even when it has a lint header", async () => { + const repoPath = await copyFixture("minimal-rust"); + const prefix = Buffer.from('[package]\nname = "invalid-utf8"\n\n[lints]\n', "utf8"); + await writeText(repoPath, "Cargo.toml", Buffer.concat([prefix, Buffer.from([0xc3, 0x28])])); + + expectRustCriterion(await runPolicyReport(repoPath), "lint-config", { + status: "fail", + reason: + "Missing Rust lint configuration (clippy.toml, .clippy.toml, or an uncommented [lints] table).", + evidence: ["clippy.toml", ".clippy.toml", "Cargo.toml"] + }); + }); + + it("rejects directory evidence and keeps raw Cargo content and host paths out of results", async () => { + const repoPath = await copyFixture("minimal-rust"); + const canary = "RUST_POLICY_SECRET_CANARY"; + await fs.mkdir(path.join(repoPath, "rustfmt.toml")); + await writeText(repoPath, "Cargo.toml", `[package]\nname = "safe"\n# ${canary}\n`); + + const report = await runPolicyReport(repoPath); + const output = replacementOutput(report); + expect(getCriterion(report, "format-config").status).toBe("fail"); + expect(output).not.toContain(canary); + expect(output).not.toContain(repoPath); + }); + + it("contains no process, network, write, dynamic-loading, secret, or private-core capability", async () => { + const source = await fs.readFile(policyPath, "utf8"); + const imports = [...source.matchAll(/from\s+["']([^"']+)["']/gu)].map((match) => match[1]); + const fsMethods = [...source.matchAll(/\bfs\.([A-Za-z][A-Za-z0-9]*)/gu)] + .map((match) => match[1]) + .filter((method, index, methods) => methods.indexOf(method) === index) + .sort(); + const fileHandleMethods = [ + ...source.matchAll(/\b(?:opened\.handle|handle)(?:\?\.|\.)([A-Za-z][A-Za-z0-9]*)\s*\(/gu) + ] + .map((match) => match[1]) + .filter((method, index, methods) => methods.indexOf(method) === index) + .sort(); + + expect(imports).toEqual(["node:fs", "node:fs/promises", "node:path", "node:util"]); + expect(fsMethods).toEqual(["lstat", "open"]); + expect(fileHandleMethods).toEqual(["close", "read", "stat"]); + expect(source).toContain("MAX_CARGO_MANIFEST_BYTES + 1"); + expect(source).toContain('new TextDecoder("utf-8", { fatal: true })'); + expect(source).not.toMatch( + /child_process|process\.|\.(?:appendFile|chmod|chown|copyFile|cp|link|mkdir|rename|rm|rmdir|symlink|truncate|unlink|write|writeFile|writev)\s*\(|fetch\(|\bWebSocket\b|node:(?:http|https|net|tls|dgram)|import\(|eval\(|Function\(/u + ); + expect(source).not.toMatch(/\bO_(?:APPEND|CREAT|RDWR|TRUNC|WRONLY)\b/u); + expect(source).not.toMatch(/@agentrc\/core|from\s+["'](?!node:)[^"']+["']/u); + }); + + it("documents executable paths, trusted-code handling, and Rust-first policy chaining", async () => { + const [policyReadme, examplesReadme, policiesDoc] = await Promise.all([ + fs.readFile(path.join(repositoryRoot, "examples", "policies", "README.md"), "utf8"), + fs.readFile(path.join(repositoryRoot, "examples", "README.md"), "utf8"), + fs.readFile(path.join(repositoryRoot, "docs", "policies.md"), "utf8") + ]); + + for (const document of [policyReadme, examplesReadme, policiesDoc]) { + expect(document).toContain("rust.mjs"); + expect(document).toContain("--policy"); + expect(document).toMatch(/trusted executable code/iu); + expect(document).toContain("agentrc.config.json"); + expect(document).toContain("org-baseline.json"); + } + expect(examplesReadme).toMatch(/version-coupled/iu); + expect(examplesReadme).toMatch(/re-copy/iu); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1d823cf7..4fcf157a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ test: { environment: "node", testTimeout: 10_000, - exclude: ["webapp/**", "node_modules/**", "dist/**", "vscode-extension/**"], + exclude: ["webapp/**", "**/node_modules/**", "dist/**", "vscode-extension/**"], coverage: { provider: "v8", reporter: ["text", "html", "json-summary"],