From a30f1c23073ccd8505bc10dff571bbe0e72c4695 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 30 Sep 2026 06:02:57 +0000 Subject: [PATCH] chore: sync from vibgrate-cli monorepo (v2026.930.1) Outbound mirror of packages/vibgrate-cli-public. Passed the public-surface leak gate. --- DOCS.md | 84 ++++++-- action.yml | 2 +- charts/vibgrate/Chart.yaml | 2 +- package.json | 2 +- packaging/homebrew-tap/Formula/vg.rb | 4 +- packaging/scoop-bucket/vg.json | 2 +- releases/v2026.930.1.md | 57 ++++++ src/code/agent.ts | 2 +- src/code/long-session.test.ts | 3 +- src/commands/code.ts | 4 +- src/commands/config-readers.test.ts | 73 +++++++ src/commands/doctor.ts | 31 ++- src/commands/review.ts | 8 +- src/core-open/config.ts | 83 +++++++- src/core-open/drift-budget.ts | 223 +++++++++++++++++++++ src/core-open/index.ts | 26 ++- src/core-open/types.ts | 40 ++++ src/engine/discover.ts | 18 +- src/install/area-skills.ts | 15 +- src/reporting/commands/scan.ts | 24 ++- src/reporting/config.test.ts | 2 +- src/reporting/config.ts | 252 ------------------------ src/reporting/drift-budget-gate.test.ts | 55 ++++++ src/reporting/drift-budget-gate.ts | 66 +++++++ src/review/capsule.ts | 6 +- src/review/config.ts | 115 +++++++++-- src/review/format.ts | 3 +- src/review/packs.test.ts | 81 ++++++++ src/review/packs.ts | 67 ++++++- src/review/prepare.test.ts | 64 +++++- src/review/prepare.ts | 147 +++++++++++--- src/review/review.test.ts | 64 +++++- src/review/run.test.ts | 18 ++ src/review/run.ts | 8 +- src/review/schemas.ts | 7 +- src/version.ts | 2 +- 36 files changed, 1275 insertions(+), 385 deletions(-) create mode 100644 releases/v2026.930.1.md create mode 100644 src/commands/config-readers.test.ts create mode 100644 src/core-open/drift-budget.ts delete mode 100644 src/reporting/config.ts create mode 100644 src/reporting/drift-budget-gate.test.ts create mode 100644 src/reporting/drift-budget-gate.ts diff --git a/DOCS.md b/DOCS.md index 77e5fa6..6c49c4c 100644 --- a/DOCS.md +++ b/DOCS.md @@ -526,8 +526,8 @@ honestly and never blocks a merge. taint flow, a known-vulnerable dependency — carry `protected_finding: true`. While one is unresolved, policy cannot emit `pass`: not via an approved exception, not via low confidence, not via the quick path, and not via anything -the model says. Turn a rule off in `.vibgrate/review.toml` if it does not apply -to your repository; that is the only way to stop it gating. +the model says. Turn a rule off in the review policy (`review.protected`) if it +does not apply to your repository; that is the only way to stop it gating. #### What it looks for @@ -548,7 +548,7 @@ convention". Where peers are too evenly split to have one, Review says *no convention* instead of naming a plurality winner. **A majority is never treated as correct.** Peers establish what is *normal*; -only `target_pattern` establishes what is *right*. A file that goes through the +only the declared `targetPattern` establishes what is *right*. A file that goes through the service layer while all its peers bypass it is the first one to improve — Review will not flag it. That is the difference between this and a consistency scanner, which by construction scores your best file worst. @@ -653,25 +653,41 @@ to perpetuate the legacy it is migrating away from. `--inject-context` keeps the same content in a marked block inside `CLAUDE.md`, leaving everything a human wrote in that file untouched. -#### Configuration — `.vibgrate/review.toml` +#### Configuration — the `review` block -```toml -[review] -enforcement = "advisory" # advisory | enforced -fail_on = "fail" # fail | needs_review -target_pattern = "layered" # the architecture you say you want +The review policy is the `review` block of the project config +(`.vibgrate/config.yml` or `vibgrate.config.json`, which take the same settings): -[review.protected] -unguarded_entrypoint = true -known_vulnerable_dependency = true -validated_taint = true +```yaml +# .vibgrate/config.yml +review: + enforcement: advisory # advisory | enforced + failOn: fail # none | fail | needs_review + targetPattern: layered # the architecture you say you want + protected: + unguardedEntrypoint: true + knownVulnerableDependency: true + validatedTaint: true ``` Read from the **trusted base branch** when `--base` is given, so a pull request -cannot weaken the policy applied to itself. +cannot weaken the policy applied to itself. It is read as data: a `review` block +in a `.ts`/`.js` config is never run, so keep it in YAML or JSON. + +The first `vg review` in a repository with no policy writes an advisory starter +policy: a new `.vibgrate/config.yml` when there is no config, or a `review` block +added to an existing `.vibgrate/config.yml` / `vibgrate.config.json`. A `.ts` or +`.js` config is never rewritten, so those repositories get `.vibgrate/review.toml`. + +`.vibgrate/review.toml` keeps working wherever the config has no `review` block. +Its keys are the snake_case forms of the ones above (`fail_on`, `target_pattern`, +`[review.protected]`). `vg doctor` says when a `review` block makes it redundant. Team markdown packs live under `.vibgrate/review/` — the same tree the GitHub -App reads. `ignore.md` drops matching finding paths; `policy.md` is attached to +App reads. With `--base` they are read from the base branch, like the `review` +policy, so a change cannot add an `ignore.md` glob over the files it breaks or +delete a check to clear its own review; its edits apply after it merges. +Without `--base`, the files on disk are used. `ignore.md` drops matching finding paths; `policy.md` is attached to the human report; `merge.md` is evaluated locally (docs-only may approve; a change to `merge.md` itself is refused); `checks/*.md` each produce one CLI pass or a skipped-with-reason line. Custom checks have no extra correctness @@ -681,7 +697,7 @@ engine on the CLI either. (from a known current → latest pair). It does not rewrite lockfiles, does not open a hosted branch, and never starts unless you pass the flag. -Declaring `target_pattern` is what turns a layering observation into a +Declaring `targetPattern` is what turns a layering observation into a *regression*. Without it, a dependency that skips a tier is reported as a medium finding about the repository's own majority — because a majority is not the same thing as a decision, and Review will not treat it as one. @@ -2530,7 +2546,41 @@ const config: VibgrateConfig = { export default config; ``` -Also supports `vibgrate.config.js` and `vibgrate.config.json`. +Also supports `vibgrate.config.js`, `vibgrate.config.json`, and +`.vibgrate/config.yml` (or `.config.yaml`). A project has one config file: +Vibgrate reads the first it finds in the order `.vibgrate/config.yml`, +`.vibgrate/config.yaml`, `vibgrate.config.ts`, `vibgrate.config.js`, +`vibgrate.config.json`, and never merges two. YAML and JSON take exactly the +same settings; `vg doctor` names any config file that is present but ignored. + +### Drift budget + +`driftBudget` sets limits on DriftScore that `vg scan` and the Vibgrate GitHub +App check enforce: + +```yaml +# .vibgrate/config.yml +driftBudget: + mode: warn # warn (default) | enforce | shadow + maxScore: 40 # DriftScore ceiling, 0-100 + maxWorseningPercent: 5 # how much one change may worsen drift + agents: + maxWorseningPercent: 0 # stricter limit for bot and coding-agent pull requests +``` + +- `warn` prints a breach and exits `0`; `enforce` exits `2`; `shadow` reports only. +- `maxWorseningPercent` compares against `--baseline`; without one it is reported + as not evaluated, never as a failure. +- `agents.maxWorseningPercent` is checked by the GitHub App, which knows who + opened the pull request. +- `--drift-budget` / `--drift-worsening` still work; passing either uses the flags + and ignores `driftBudget`. +- Misspelt keys are reported, never silently ignored. + +The GitHub App reads `driftBudget` and `review` from the pull request's base +branch, so a change cannot loosen the limits it is checked against. It reads +`.vibgrate/config.yml` or `vibgrate.config.json` only; it never runs a `.ts`/`.js` +config. ### Thresholds diff --git a/action.yml b/action.yml index 25c8620..62c3c7e 100644 --- a/action.yml +++ b/action.yml @@ -46,7 +46,7 @@ inputs: image-tag: description: 'Scanner image tag to run (defaults to a pinned, tested release).' required: false - default: '2026.921.1' # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs + default: '2026.930.1' # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs verify: description: 'Verify the image cosign signature + provenance before running (requires cosign on the runner).' required: false diff --git a/charts/vibgrate/Chart.yaml b/charts/vibgrate/Chart.yaml index 2a09f7d..605a387 100644 --- a/charts/vibgrate/Chart.yaml +++ b/charts/vibgrate/Chart.yaml @@ -7,7 +7,7 @@ type: application # stamped to the released @vibgrate/cli calendar version by # scripts/stamp-release-pins.mjs (via the marker on the appVersion line below). version: 0.1.2 -appVersion: "2026.921.1" # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs +appVersion: "2026.930.1" # vibgrate:cli-version — stamped by scripts/stamp-release-pins.mjs home: https://vibgrate.com icon: https://vibgrate.com/web-app-manifest-512x512.png sources: diff --git a/package.json b/package.json index 3d0dced..6ea3d1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vibgrate/cli", - "version": "2026.921.1", + "version": "2026.930.1", "description": "vg — local codebase intelligence CLI + MCP server for AI coding agents: deterministic code graph, drift reporting, and version-correct library docs (Apache-2.0)", "//mcpName": "Official MCP registry ownership proof: the registry fetches the published npm package and requires this field to match the com.vibgrate/ai-context server entry (see docs/marketing/mcp-registry/README.md). Must ship in the published @vibgrate/cli package.json.", "mcpName": "com.vibgrate/ai-context", diff --git a/packaging/homebrew-tap/Formula/vg.rb b/packaging/homebrew-tap/Formula/vg.rb index dfa1487..40a501e 100644 --- a/packaging/homebrew-tap/Formula/vg.rb +++ b/packaging/homebrew-tap/Formula/vg.rb @@ -3,8 +3,8 @@ class Vg < Formula desc "Deterministic, no-API-key code graph for AI assistants (vg)" homepage "https://vibgrate.com" - url "https://registry.npmjs.org/@vibgrate/cli/-/cli-2026.921.1.tgz" - sha256 "a7239fa8e0ff6cd0a2f64af43b47bfee02ffe6bdcce571f715c617ab7a054c4e" + url "https://registry.npmjs.org/@vibgrate/cli/-/cli-2026.914.1.tgz" + sha256 "21c164080d1ba33dc53d604a8754ffa0079daa9c8b771a9053c224a2c43877bf" license "Apache-2.0" depends_on "node" diff --git a/packaging/scoop-bucket/vg.json b/packaging/scoop-bucket/vg.json index dc46fdd..b76a0cc 100644 --- a/packaging/scoop-bucket/vg.json +++ b/packaging/scoop-bucket/vg.json @@ -1,5 +1,5 @@ { - "version": "2026.921.1", + "version": "2026.914.1", "description": "Deterministic, no-API-key code graph for AI assistants (vg)", "homepage": "https://vibgrate.com", "license": "Apache-2.0", diff --git a/releases/v2026.930.1.md b/releases/v2026.930.1.md new file mode 100644 index 0000000..f5cd3b6 --- /dev/null +++ b/releases/v2026.930.1.md @@ -0,0 +1,57 @@ +# Vibgrate CLI 2026.930.1 + +_Released 2026-09-30_ + +This release of the Vibgrate CLI introduces support for YAML configuration files and enhances security in the review process. Users can now manage settings more flexibly and benefit from improved enforcement of review policies. + +## What changed + +### New + +- Vibgrate now supports reading settings from .vibgrate/config.yml alongside vibgrate.config.json. +- The new YAML configuration can include a driftBudget and your review policy. + +### Improved + +- Local reviews without --base continue to use the files on disk. + +### Changed + +- The first vg review writes its starter policy into the YAML config instead of a separate review.toml file. +- A base branch without a review policy defaults to standard settings rather than those added by the pull request. + +### Fixed + +- Existing review.toml files remain functional, and vg doctor will notify when a config file is ignored. + +### Security + +- vg review --base now reads team review files from the base branch, preventing pull requests from hiding findings. + +## Benchmarks + +Two-arm benchmark of this release against 2026.921.1, interleaved on one runner against the pinned corpus (236 metrics compared). + +| Metric | Previous | This release | +| --- | --- | --- | +| Languages with extraction | 19 count | 19 count | +| Definitions extracted (corpus total) | 25880 count | 25880 count | +| Call edges extracted (corpus total) | 17611 count | 17611 count | +| Locate accuracy (top-1) | 0.94 ratio | 0.94 ratio | +| Dependency detection (authored manifest truth) | 0.96 ratio | 0.96 ratio | +| CLI startup (--version, median) | 450.70 ms | 454.20 ms | + +2 regression(s) — published, not omitted: +- Tasks passed on both arms: 36 → 34 (-5.6%) +- Comparable-task rate (both arms passed / total): 0.95 → 0.89 (-5.6%) + +Full report and methodology: https://vibgrate.com/cli/benchmarks + +## Install or update + +```sh +npm install -g @vibgrate/cli +vg +``` + +Full changelog: https://vibgrate.com/changelog/cli/2026.930.1 diff --git a/src/code/agent.ts b/src/code/agent.ts index 1f6567d..4e7d9d6 100644 --- a/src/code/agent.ts +++ b/src/code/agent.ts @@ -1397,7 +1397,7 @@ export async function runAgent(options: AgentOptions): Promise { return finish( 'max-steps', `Stopped at the step limit (${maxSteps} steps) before the task was finished. ` + - 'Re-run with `--max-steps `, or set `maxSteps` in vibgrate.config.json, to give it more room.', + 'Re-run with `--max-steps `, or set `maxSteps` in .vibgrate/code.json, to give it more room.', maxSteps, ); } diff --git a/src/code/long-session.test.ts b/src/code/long-session.test.ts index 9a9471b..efefbcd 100644 --- a/src/code/long-session.test.ts +++ b/src/code/long-session.test.ts @@ -186,7 +186,8 @@ describe('long-session gold — max-steps names how to raise the cap', () => { expect(result.finalText).toMatch(/step limit \(2 steps\)/); expect(result.finalText).toMatch(/--max-steps/); expect(result.finalText).toMatch(/maxSteps/); - expect(result.finalText).toMatch(/vibgrate\.config\.json/); + // maxSteps lives in .vibgrate/code.json (loadCodeConfig), not the project config. + expect(result.finalText).toMatch(/\.vibgrate\/code\.json/); }); }); diff --git a/src/commands/code.ts b/src/commands/code.ts index 47abc9e..09377f1 100644 --- a/src/commands/code.ts +++ b/src/commands/code.ts @@ -43,7 +43,7 @@ export function registerCode(program: Command): void { .option('--apply', 'one-shot path (--single/--mock) only: write the change (still requires --yes or an interactive confirm)') .option('--yes', 'consent to write / to a first-use package install, non-interactively') .option('--auto', 'autonomous agent: auto-approve every edit and command (use with care)') - .option('--max-steps ', 'cap the number of agent steps (default 24; also settable as maxSteps in vibgrate.config.json)') + .option('--max-steps ', 'cap the number of agent steps (default 24; also settable as maxSteps in .vibgrate/code.json)') .option('--single', 'one-shot planner (single edit) instead of the multi-step agent') .option('--stream', 'stream the model output live') .option('--stream-json', 'machine protocol: NDJSON agent events on stdout, approval decisions on stdin (for host UIs like the VS Code panel)') @@ -388,7 +388,7 @@ export function registerCode(program: Command): void { const auto = opts.auto ?? config.auto; // No commander default here on purpose: a hard-coded default would always // populate opts.maxSteps and silently shadow `maxSteps` in - // vibgrate.config.json, so a raised project cap never took effect. + // .vibgrate/code.json, so a raised project cap never took effect. // Flag → config → the engine's own DEFAULT_MAX_STEPS (undefined). const parsedMaxSteps = opts.maxSteps === undefined ? NaN : Number(opts.maxSteps); const maxSteps = diff --git a/src/commands/config-readers.test.ts b/src/commands/config-readers.test.ts new file mode 100644 index 0000000..4ce1883 --- /dev/null +++ b/src/commands/config-readers.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { readConfigExcludes } from '../engine/discover.js'; +import { areaSkillsEnabled } from '../install/area-skills.js'; +import { configNotes } from './doctor.js'; + +const roots: string[] = []; +function project(files: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-config-readers-')); + roots.push(root); + for (const [rel, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), text); + } + return root; +} +afterEach(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); +}); + +describe('settings read outside a scan follow the one config file', () => { + it('reads exclude and areaSkills from .vibgrate/config.yml', () => { + const root = project({ '.vibgrate/config.yml': 'areaSkills: true\nexclude:\n - legacy/**\n' }); + expect(readConfigExcludes(root)).toEqual(['legacy/**']); + expect(areaSkillsEnabled(root)).toBe(true); + }); + + it('ignores vibgrate.config.json when a YAML config exists', () => { + const root = project({ + '.vibgrate/config.yml': 'exclude: []\n', + 'vibgrate.config.json': '{"areaSkills":true,"exclude":["from-json/**"]}', + }); + expect(readConfigExcludes(root)).toEqual([]); + expect(areaSkillsEnabled(root)).toBe(false); + }); + + it('still reads vibgrate.config.json on its own', () => { + const root = project({ 'vibgrate.config.json': '{"areaSkills":true,"exclude":["legacy/**"]}' }); + expect(readConfigExcludes(root)).toEqual(['legacy/**']); + expect(areaSkillsEnabled(root)).toBe(true); + }); +}); + +describe('vg doctor config notes', () => { + it('is quiet for a single config', () => { + expect(configNotes(project({ '.vibgrate/config.yml': 'exclude: []\n' }))).toEqual([]); + expect(configNotes(project({}))).toEqual([]); + }); + + it('names a shadowed config file', () => { + const root = project({ '.vibgrate/config.yml': 'exclude: []\n', 'vibgrate.config.json': '{}' }); + expect(configNotes(root)).toEqual(['vibgrate.config.json is ignored: .vibgrate/config.yml is the config in use']); + }); + + it('names legacy review files a review block replaces', () => { + const root = project({ + 'vibgrate.config.json': '{"review":{"enforcement":"advisory"}}', + '.vibgrate/review.toml': '[review]\n', + '.vibgrate/review/settings.md': 'mode: precise\n', + }); + expect(configNotes(root)).toEqual([ + '.vibgrate/review.toml is ignored: review settings come from the review block in vibgrate.config.json', + '.vibgrate/review/settings.md is ignored: review settings come from the review block in vibgrate.config.json', + ]); + }); + + it('surfaces a config that does not parse', () => { + const root = project({ '.vibgrate/config.yml': 'exclude: [unclosed\n' }); + expect(configNotes(root)[0]).toMatch(/\.vibgrate\/config\.yml is not valid YAML/); + }); +}); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 0f621a0..463b641 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -18,6 +18,7 @@ import { parseDsn } from '../reporting/commands/push.js'; import { gatherSystemMemory } from '../code/local-runtime.js'; import { buildLocalInferenceStatus, type LocalInferenceStatus } from '../runtime/local-inference-status.js'; import { VERSION } from '../version.js'; +import { findConfigFile, readDataConfigSync, shadowedConfigFiles } from '../core-open/config.js'; import { c, info, json } from '../util/output.js'; import { applyGlobalOptions, readGlobal, type GlobalOpts } from '../cli-options.js'; import { rootOf } from './util.js'; @@ -54,7 +55,29 @@ export function registerDoctor(program: Command): void { /** How long the hosted reachability probe waits before reporting unreachable. */ const REACH_TIMEOUT_MS = 3000; -const CONFIG_BASENAMES = ['vibgrate.config.ts', 'vibgrate.config.js', 'vibgrate.config.json']; +/** Review settings files the `review` block of the project config replaces. */ +const LEGACY_REVIEW_SETTINGS = ['.vibgrate/review.toml', '.vibgrate/review/settings.md']; + +/** + * Config files present but not read. Only one config file is ever used, so a + * second one — or a legacy review settings file behind a `review` block — is + * a trap worth naming. + */ +export function configNotes(root: string): string[] { + const file = findConfigFile(root); + if (!file) return []; + const notes = shadowedConfigFiles(root).map((other) => `${other} is ignored: ${file} is the config in use`); + const read = readDataConfigSync(root); + if (read.error && !read.error.includes('is code')) notes.push(read.error); + if (read.config?.review !== undefined) { + for (const legacy of LEGACY_REVIEW_SETTINGS) { + if (fs.existsSync(path.join(root, legacy))) { + notes.push(`${legacy} is ignored: review settings come from the review block in ${file}`); + } + } + } + return notes; +} interface Diagnosis { version: string; @@ -62,6 +85,8 @@ interface Diagnosis { platform: string; root: string; configFile: string | null; + /** Config files present but ignored, and config parse errors. */ + configNotes: string[]; map: { path: string; built: boolean; @@ -140,7 +165,7 @@ async function runDoctor(global: GlobalOpts): Promise { const local = global.offline === true; const graphPath = resolveGraphPath(root, global.graph); - const configFile = CONFIG_BASENAMES.find((f) => fs.existsSync(path.join(root, f))) ?? null; + const configFile = findConfigFile(root); const graph = loadGraph(root, graphPath); let staleFiles: number | null = null; @@ -172,6 +197,7 @@ async function runDoctor(global: GlobalOpts): Promise { platform: `${process.platform}/${process.arch}`, root, configFile, + configNotes: configNotes(root), map: { path: displayGraphPath(root, graphPath), built: graph !== null, @@ -199,6 +225,7 @@ async function runDoctor(global: GlobalOpts): Promise { info(`${c.cyan('vg')} doctor · v${d.version} · node ${d.node} · ${d.platform}`); info(` root ${d.root}`); info(` config ${d.configFile ? c.green(d.configFile) : c.dim('none (defaults) — `vg init` writes one')}`); + for (const note of d.configNotes) info(` ${c.yellow(note)}`); if (!d.map.built) { info(` map ${c.yellow('none')} — run ${c.bold('vg')} to build ${c.dim(d.map.path)}`); diff --git a/src/commands/review.ts b/src/commands/review.ts index e68004d..aadab1d 100644 --- a/src/commands/review.ts +++ b/src/commands/review.ts @@ -570,8 +570,8 @@ function reportPrepare(prepared: Awaited>, quie } /** - * First run in a repository with no review policy: write the starter - * `.vibgrate/review.toml` and say so. Skipped for `--base` runs (a PR review + * First run in a repository with no review policy: write the starter policy + * into the project config (see `seedReviewPolicy`) and say so. Skipped for `--base` runs (a PR review * must not mutate the tree it is reviewing) and under `--no-setup`. */ function maybeSeedPolicy(result: RunReviewResult, opts: ReviewOpts, quiet: boolean): void { @@ -588,9 +588,9 @@ function maybeSeedPolicy(result: RunReviewResult, opts: ReviewOpts, quiet: boole c.green(`\n Review baseline ready — wrote ${seeded.path}`) + c.dim( seeded.targetPattern - ? `\n target_pattern = "${seeded.targetPattern}" (derived from this repository). Edit it, commit it,` + ? `\n Target pattern "${seeded.targetPattern}" (derived from this repository). Edit it, commit it,` + '\n and future changes are judged against a declared architecture rather than a guess.' - : '\n No layering shape dominates yet, so target_pattern is left commented out.' + : '\n No layering shape dominates yet, so the target pattern is left commented out.' + '\n Set one and commit the file to have future changes judged against it.', ), ); diff --git a/src/core-open/config.ts b/src/core-open/config.ts index 8569ff0..daf08f5 100644 --- a/src/core-open/config.ts +++ b/src/core-open/config.ts @@ -3,15 +3,90 @@ // and re-run the vendor script. Apache-2.0. import * as path from 'node:path'; import * as fs from 'node:fs/promises'; +import { existsSync, readFileSync } from 'node:fs'; import type * as TsModule from 'typescript'; +import { parse as parseYaml } from 'yaml'; import type { VibgrateConfig } from './types.js'; import { pathExists, readTextFile } from './utils/fs.js'; -const CONFIG_FILES = [ +/** + * The project config, in lookup order. The FIRST file found is the config; + * files are never merged. `.vibgrate/config.yml` takes the same keys as + * `vibgrate.config.json` and wins over it when both exist. + * + * Keep in sync with the GitHub App's copy in + * `packages/vibgrate-api/src/lib/github-app/repo-config.ts`. + */ +export const CONFIG_FILES = [ + '.vibgrate/config.yml', + '.vibgrate/config.yaml', 'vibgrate.config.ts', 'vibgrate.config.js', 'vibgrate.config.json', -]; +] as const; + +export type ConfigFile = (typeof CONFIG_FILES)[number]; + +/** YAML and JSON are data; `.ts` / `.js` configs are code and need the loader. */ +export function isDataConfigFile(file: string): boolean { + return /\.(ya?ml|json)$/.test(file); +} + +/** + * Parse a data config (YAML or JSON) into a plain object. Throws an Error + * naming the file when the text is not a valid mapping. + */ +export function parseDataConfig(text: string, file: string): Record { + let parsed: unknown; + try { + parsed = /\.ya?ml$/.test(file) ? parseYaml(text) : JSON.parse(text); + } catch (err) { + const reason = err instanceof Error ? err.message.split('\n')[0] : 'unreadable'; + throw new Error(`${file} is not valid ${/\.ya?ml$/.test(file) ? 'YAML' : 'JSON'}: ${reason}`); + } + // An empty YAML file is an empty config, not an error. + if (parsed === null || parsed === undefined) return {}; + if (!isRecord(parsed)) throw new Error(`${file} must contain a mapping of settings.`); + return parsed; +} + +/** The config file this project uses, relative to `rootDir`, or null. */ +export function findConfigFile(rootDir: string): ConfigFile | null { + return CONFIG_FILES.find((file) => existsSync(path.join(rootDir, file))) ?? null; +} + +/** Config files present but not read, because an earlier one in the lookup order won. */ +export function shadowedConfigFiles(rootDir: string): ConfigFile[] { + const present = CONFIG_FILES.filter((file) => existsSync(path.join(rootDir, file))); + return present.slice(1); +} + +export interface DataConfigRead { + /** The config file in effect, or null when the project has none. */ + file: ConfigFile | null; + /** Parsed settings. Null for no config, a `.ts`/`.js` config, or an invalid file. */ + config: Record | null; + /** Why `config` is null despite a file existing. */ + error?: string; +} + +/** + * Synchronously read the project config when it is data (YAML or JSON), for + * callers that need one setting without running a scan. A `.ts`/`.js` config + * is reported, not executed — `loadConfig` is the only path that reads those. + */ +export function readDataConfigSync(rootDir: string): DataConfigRead { + const file = findConfigFile(rootDir); + if (!file) return { file: null, config: null }; + if (!isDataConfigFile(file)) { + return { file, config: null, error: `${file} is code; this setting is read from .vibgrate/config.yml or vibgrate.config.json.` }; + } + try { + return { file, config: parseDataConfig(readFileSync(path.join(rootDir, file), 'utf8'), file) }; + } catch (err) { + return { file, config: null, error: err instanceof Error ? err.message : String(err) }; + } +} const TRUSTED_CONFIG_ENV = 'VIBGRATE_TRUST_CONFIG'; @@ -146,9 +221,9 @@ export async function loadConfig(rootDir: string): Promise { for (const file of CONFIG_FILES) { const configPath = path.join(rootDir, file); if (await pathExists(configPath)) { - if (file.endsWith('.json')) { + if (isDataConfigFile(file)) { const txt = await readTextFile(configPath); - config = { ...DEFAULT_CONFIG, ...JSON.parse(txt) }; + config = { ...DEFAULT_CONFIG, ...parseDataConfig(txt, file) } as VibgrateConfig; break; } const txt = await readTextFile(configPath); diff --git a/src/core-open/drift-budget.ts b/src/core-open/drift-budget.ts new file mode 100644 index 0000000..587cc59 --- /dev/null +++ b/src/core-open/drift-budget.ts @@ -0,0 +1,223 @@ +// VENDORED from @vibgrate/core-open (packages/vibgrate-core-open) by +// scripts/vendor-core-open.mjs. Do not edit here — change the source package +// and re-run the vendor script. Apache-2.0. +/** + * Drift budget — the `driftBudget` block of the project config + * (`.vibgrate/config.yml` or `vibgrate.config.json`). + * + * One schema, one evaluator, three surfaces: `vg scan`, the GitHub App's + * `Vibgrate DriftScore` check, and Vibgrate Cloud. The GitHub App keeps a + * Worker-safe mirror (`packages/vibgrate-api/src/lib/github-app/drift-budget.ts`) + * with a parity test against this file — change both together. + * + * driftBudget: + * mode: warn # warn (default) | enforce | shadow + * maxScore: 40 # DriftScore ceiling, 0–100 (lower is better) + * maxWorseningPercent: 5 # how much one change may worsen drift + * agents: + * maxWorseningPercent: 0 # stricter limit for bot / coding-agent PRs + * + * Semantics match the historic flags: `maxScore` fails only when DriftScore is + * strictly above it (`--drift-budget`), and worsening is + * `delta / max(|base|, 0.0001) * 100`, counted only when drift got worse + * (`--drift-worsening`). A missing base is "not evaluated", never zero. + * + * Pure: no I/O, no Node APIs — safe in the Cloudflare Worker. + */ + +export type DriftBudgetMode = 'warn' | 'enforce' | 'shadow'; +export type AuthorClass = 'agent' | 'human' | 'unknown'; + +/** The `driftBudget` block as written in the config file. */ +export interface DriftBudgetConfig { + mode?: DriftBudgetMode; + maxScore?: number; + maxWorseningPercent?: number; + agents?: { maxWorseningPercent?: number }; +} + +/** A validated budget. Every limit is optional; at least one is set. */ +export interface DriftBudget { + mode: DriftBudgetMode; + maxScore: number | null; + maxWorseningPercent: number | null; + agentMaxWorseningPercent: number | null; +} + +export type DriftBudgetParse = { ok: true; budget: DriftBudget } | { ok: false; errors: string[] }; + +export type DriftBudgetRuleId = 'maxScore' | 'maxWorseningPercent' | 'agents.maxWorseningPercent'; +export type DriftBudgetRuleStatus = 'pass' | 'breach' | 'not_evaluated'; + +export interface DriftBudgetRuleResult { + id: DriftBudgetRuleId; + status: DriftBudgetRuleStatus; + /** One line: the limit, the actual value, and on a breach the smallest passing change. */ + message: string; +} + +export interface DriftBudgetVerdict { + mode: DriftBudgetMode; + /** No rule breached. */ + withinBudget: boolean; + /** A breach that gates: `enforce` mode and at least one rule breached. */ + blocking: boolean; + /** head − base. Positive means drift got worse. Null when the base is unknown. */ + delta: number | null; + rules: DriftBudgetRuleResult[]; + authorClass: AuthorClass; +} + +export interface DriftBudgetInput { + headScore: number; + /** DriftScore before the change (PR base or baseline). Null = unknown, not 0. */ + baseScore: number | null; + budget: DriftBudget; + authorClass?: AuthorClass; +} + +const MODES: readonly DriftBudgetMode[] = ['warn', 'enforce', 'shadow']; +const TOP_KEYS = new Set(['mode', 'maxScore', 'maxWorseningPercent', 'agents']); +const AGENT_KEYS = new Set(['maxWorseningPercent']); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readLimit(value: unknown, key: string, errors: string[], max?: number): number | null { + if (value === undefined) return null; + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || (max !== undefined && value > max)) { + errors.push(`driftBudget.${key} must be a number from 0${max !== undefined ? ` to ${max}` : ' or more'}.`); + return null; + } + return value; +} + +/** + * Validate a `driftBudget` block. Unknown keys are errors, not ignored: a + * misspelt limit on a merge gate must not silently pass. Returns null when the + * config has no `driftBudget` at all. + */ +export function parseDriftBudget(raw: unknown): DriftBudgetParse | null { + if (raw === undefined || raw === null) return null; + if (!isRecord(raw)) return { ok: false, errors: ['driftBudget must be a mapping of settings.'] }; + + const errors: string[] = []; + for (const key of Object.keys(raw)) { + if (!TOP_KEYS.has(key)) errors.push(`driftBudget.${key} is not a known setting.`); + } + + let mode: DriftBudgetMode = 'warn'; + if (raw.mode !== undefined) { + if (typeof raw.mode === 'string' && (MODES as readonly string[]).includes(raw.mode)) mode = raw.mode as DriftBudgetMode; + else errors.push('driftBudget.mode must be "warn", "enforce", or "shadow".'); + } + + const maxScore = readLimit(raw.maxScore, 'maxScore', errors, 100); + const maxWorseningPercent = readLimit(raw.maxWorseningPercent, 'maxWorseningPercent', errors); + + let agentMaxWorseningPercent: number | null = null; + if (raw.agents !== undefined) { + if (!isRecord(raw.agents)) { + errors.push('driftBudget.agents must be a mapping of settings.'); + } else { + for (const key of Object.keys(raw.agents)) { + if (!AGENT_KEYS.has(key)) errors.push(`driftBudget.agents.${key} is not a known setting.`); + } + agentMaxWorseningPercent = readLimit(raw.agents.maxWorseningPercent, 'agents.maxWorseningPercent', errors); + } + } + + if (errors.length > 0) return { ok: false, errors }; + if (maxScore === null && maxWorseningPercent === null && agentMaxWorseningPercent === null) { + return { + ok: false, + errors: ['driftBudget sets no limit. Add maxScore, maxWorseningPercent, or agents.maxWorseningPercent.'], + }; + } + return { ok: true, budget: { mode, maxScore, maxWorseningPercent, agentMaxWorseningPercent } }; +} + +/** Same arithmetic as `--drift-worsening`: only worsening counts; a zero base is guarded. */ +export function worseningPercent(headScore: number, baseScore: number): number { + const delta = headScore - baseScore; + if (delta <= 0) return 0; + return (delta / Math.max(Math.abs(baseScore), 0.0001)) * 100; +} + +function pct(value: number): string { + return `${Number(value.toFixed(2))}%`; +} + +function worseningRule( + id: 'maxWorseningPercent' | 'agents.maxWorseningPercent', + limit: number, + headScore: number, + baseScore: number | null, +): DriftBudgetRuleResult { + const label = id === 'agents.maxWorseningPercent' ? 'Agent change worsened drift by' : 'Drift worsened by'; + if (baseScore == null) { + return { + id, + status: 'not_evaluated', + message: `Worsening limit ${pct(limit)} not evaluated: there was no earlier DriftScore to compare against.`, + }; + } + const actual = worseningPercent(headScore, baseScore); + const move = `DriftScore ${baseScore} → ${headScore}`; + if (actual <= limit) { + return { id, status: 'pass', message: `${label} ${pct(actual)} (limit ${pct(limit)}); ${move}.` }; + } + const allowed = Math.floor(baseScore + (Math.max(Math.abs(baseScore), 0.0001) * limit) / 100); + return { + id, + status: 'breach', + message: `${label} ${pct(actual)}, over the ${pct(limit)} limit; ${move}. Bring DriftScore to ${allowed} or lower to pass.`, + }; +} + +export function evaluateDriftBudget(input: DriftBudgetInput): DriftBudgetVerdict { + const { budget, headScore, baseScore } = input; + const authorClass = input.authorClass ?? 'unknown'; + const rules: DriftBudgetRuleResult[] = []; + + if (budget.maxScore !== null) { + if (headScore > budget.maxScore) { + const over = Math.ceil((headScore - budget.maxScore) * 100) / 100; + rules.push({ + id: 'maxScore', + status: 'breach', + message: `DriftScore ${headScore} is above the budget of ${budget.maxScore}. Lower it by ${over} point${over === 1 ? '' : 's'} to pass.`, + }); + } else { + const headroom = Math.round((budget.maxScore - headScore) * 100) / 100; + rules.push({ id: 'maxScore', status: 'pass', message: `DriftScore ${headScore} of ${budget.maxScore}, headroom ${headroom}.` }); + } + } + + if (budget.maxWorseningPercent !== null) { + rules.push(worseningRule('maxWorseningPercent', budget.maxWorseningPercent, headScore, baseScore)); + } + + if (budget.agentMaxWorseningPercent !== null) { + if (authorClass === 'agent') { + rules.push(worseningRule('agents.maxWorseningPercent', budget.agentMaxWorseningPercent, headScore, baseScore)); + } else if (authorClass === 'unknown') { + rules.push({ + id: 'agents.maxWorseningPercent', + status: 'not_evaluated', + message: 'Agent limit not evaluated: the author of this change could not be identified.', + }); + } + } + + const withinBudget = rules.every((r) => r.status !== 'breach'); + return { + mode: budget.mode, + withinBudget, + blocking: budget.mode === 'enforce' && !withinBudget, + delta: baseScore == null ? null : headScore - baseScore, + rules, + authorClass, + }; +} diff --git a/src/core-open/index.ts b/src/core-open/index.ts index bebb2c7..2142847 100644 --- a/src/core-open/index.ts +++ b/src/core-open/index.ts @@ -10,7 +10,31 @@ export type * from './types.js'; // ── Config ───────────────────────────────────────────────────────────────── -export { loadConfig, appendExcludePatterns, writeDefaultConfig } from './config.js'; +export { + loadConfig, + appendExcludePatterns, + writeDefaultConfig, + CONFIG_FILES, + findConfigFile, + shadowedConfigFiles, + isDataConfigFile, + parseDataConfig, + readDataConfigSync, + type ConfigFile, + type DataConfigRead, +} from './config.js'; +export { + parseDriftBudget, + evaluateDriftBudget, + worseningPercent, + type AuthorClass, + type DriftBudget, + type DriftBudgetConfig, + type DriftBudgetMode, + type DriftBudgetParse, + type DriftBudgetRuleResult, + type DriftBudgetVerdict, +} from './drift-budget.js'; // ── Scoring (open) ─────────────────────────────────────────────────────────── export { diff --git a/src/core-open/types.ts b/src/core-open/types.ts index c2eda74..07f0335 100644 --- a/src/core-open/types.ts +++ b/src/core-open/types.ts @@ -3,6 +3,8 @@ // and re-run the vendor script. Apache-2.0. // ── Core types for Vibgrate CLI ── +import type { DriftBudgetConfig } from './drift-budget.js'; + export type DepSection = 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies'; export type RiskLevel = 'low' | 'moderate' | 'high' | 'none'; @@ -982,6 +984,44 @@ export interface VibgrateConfig { dependencyTwoPlusPercent?: number; }; }; + /** Generate per-area `vg-area-*` assistant skills. Default: false. */ + areaSkills?: boolean; + /** + * Drift budget — a DriftScore ceiling and worsening limits, enforced by + * `vg scan` and the GitHub App check. See `drift-budget.ts`. + */ + driftBudget?: DriftBudgetConfig; + /** Review settings for `vg review` and the GitHub App's Vibgrate Review check. */ + review?: ReviewSettingsConfig; +} + +/** + * `review` block of the project config. Structured settings only — the + * instructions a reviewer reads stay in `.vibgrate/review/*.md`. + * + * Replaces `.vibgrate/review.toml` (CLI policy) and + * `.vibgrate/review/settings.md` (App detection mode). Those files are still + * read when the config has no `review` block. + */ +export interface ReviewSettingsConfig { + /** `advisory` reports only; `enforced` gates at `failOn`. */ + enforcement?: 'advisory' | 'enforced'; + failOn?: 'none' | 'fail' | 'needs_review'; + /** The layering the repository declares it wants, e.g. `hexagonal`. */ + targetPattern?: string; + /** Layer pairs a change may cross without counting as a regression. */ + approvedExceptions?: string[]; + protected?: { + unguardedEntrypoint?: boolean; + knownVulnerableDependency?: boolean; + validatedTaint?: boolean; + }; + highConfidenceThreshold?: number; + highSeverityDecision?: 'fail' | 'needs_review'; + /** GitHub App Review: which deterministic packs run. */ + detectionMode?: 'budget' | 'balanced' | 'precise' | 'ultra'; + /** GitHub App Review: lowest severity that gets a PR comment. */ + minSeverity?: 'low' | 'medium' | 'high' | 'critical'; } // ── Extended scanner result types ── diff --git a/src/engine/discover.ts b/src/engine/discover.ts index 2cd2617..aa5fce8 100644 --- a/src/engine/discover.ts +++ b/src/engine/discover.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import ignore, { type Ignore } from 'ignore'; import { langForExtension, langById, type LanguageDef } from './languages.js'; +import { readDataConfigSync } from '../core-open/config.js'; /** * Deterministic file discovery. @@ -172,19 +173,14 @@ function toPosix(p: string): string { } /** - * Project-local exclude globs from `vibgrate.config.json`. - * JSON only — `.ts`/`.js` configs stay scan-side (they can execute). A missing - * or malformed file is an empty list, never an error. + * Project-local exclude globs from the project config (`.vibgrate/config.yml` + * or `vibgrate.config.json`). `.ts`/`.js` configs stay scan-side (they can + * execute). A missing or malformed file is an empty list, never an error. */ export function readConfigExcludes(root: string): string[] { - const configPath = path.join(root, 'vibgrate.config.json'); - try { - const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { exclude?: unknown }; - if (!Array.isArray(parsed.exclude)) return []; - return parsed.exclude.filter((x): x is string => typeof x === 'string' && x.trim() !== ''); - } catch { - return []; - } + const exclude = readDataConfigSync(root).config?.exclude; + if (!Array.isArray(exclude)) return []; + return exclude.filter((x): x is string => typeof x === 'string' && x.trim() !== ''); } /** Config excludes plus caller extras, de-duplicated, config-first. */ diff --git a/src/install/area-skills.ts b/src/install/area-skills.ts index c73619f..6e7bd68 100644 --- a/src/install/area-skills.ts +++ b/src/install/area-skills.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { Area, GraphNode, VgGraph } from '../schema.js'; import { versionMarker } from './content.js'; +import { readDataConfigSync } from '../core-open/config.js'; /** * Per-area generated skills: one SKILL.md per top graph area, so the host @@ -135,18 +136,12 @@ export interface AreaSkillChanges { } /** - * Generated `vg-area-*` skills are off unless `vibgrate.config.json` sets - * `"areaSkills": true`. Missing or malformed config keeps the default (off). + * Generated `vg-area-*` skills are off unless the project config + * (`.vibgrate/config.yml` or `vibgrate.config.json`) sets `areaSkills: true`. + * Missing or malformed config keeps the default (off). */ export function areaSkillsEnabled(root: string): boolean { - try { - const parsed = JSON.parse( - fs.readFileSync(path.join(root, 'vibgrate.config.json'), 'utf8'), - ) as { areaSkills?: unknown }; - return parsed.areaSkills === true; - } catch { - return false; - } + return readDataConfigSync(root).config?.areaSkills === true; } /** diff --git a/src/reporting/commands/scan.ts b/src/reporting/commands/scan.ts index 4a2f525..76a4e3b 100644 --- a/src/reporting/commands/scan.ts +++ b/src/reporting/commands/scan.ts @@ -16,7 +16,9 @@ import { resolveRepositoryName, parseExcludePatterns, loadConfig, + findConfigFile, } from '../../core-open/index.js'; +import { evaluateConfigDriftBudget } from '../drift-budget-gate.js'; import type { ScanOptions, ScanArtifact } from '../../core-open/index.js'; import { analyzeReachability, collectPreflightDependencies } from '../reachability.js'; import type { VgGraph } from '../../schema.js'; @@ -396,8 +398,8 @@ export const scanCommand = new Command('scan') .option('--iac', "Evaluate infrastructure misconfiguration rules (Terraform, Kubernetes, Helm, Dockerfiles) with the Architecture module's iac-cis-v1 pack; needs the code map") .option('--package-manifest ', 'Use local package-version manifest JSON/ZIP (for offline mode)') .option('--project-scan-timeout ', 'Per-project scan timeout in seconds (default: 180)') - .option('--drift-budget ', 'Fail if DriftScore is above budget (0-100)') - .option('--drift-worsening ', 'Fail if drift worsens by more than % since baseline') + .option('--drift-budget ', 'Fail if DriftScore is above budget (0-100); overrides driftBudget in the project config') + .option('--drift-worsening ', 'Fail if drift worsens by more than % since baseline; overrides driftBudget in the project config') .option('--repository-name ', 'Override the repository name recorded for this scan (defaults to the directory or package.json name)') .option('--force', 'Always create a fresh scan ingest, even if the repository is unchanged since the last scan (skips the unchanged/reuse optimization). Used by scheduled and dashboard-triggered scans.') .option('--no-graph', 'Skip building the local code map (the AI/docs index) that scan produces after scoring drift') @@ -910,6 +912,24 @@ export const scanCommand = new Command('scan') } } + // `driftBudget` in the project config — only when neither flag was passed, + // so an explicit flag keeps its exact historic meaning. + if (scanOpts.driftBudget === undefined && scanOpts.driftWorseningPercent === undefined) { + const projectConfig = await loadConfig(rootDir); + const gate = evaluateConfigDriftBudget({ + raw: projectConfig.driftBudget, + configFile: findConfigFile(rootDir), + headScore: artifact.drift.score, + baseScore: artifact.delta === undefined ? null : artifact.drift.score - artifact.delta, + }); + for (const line of gate.lines) { + if (line.level === 'error') console.error(chalk.red(line.text)); + else if (line.level === 'warn') console.error(chalk.yellow(line.text)); + else if (!opts.quiet) console.error(chalk.dim(line.text)); + } + if (gate.exitCode === 2) process.exit(2); + } + // Reachability hand-off (before push): post the dependency coordinates the // scan found in the package manifests to the symbols preflight, then query // the freshly built local code map for vulnerable-symbol usage and attach diff --git a/src/reporting/config.test.ts b/src/reporting/config.test.ts index 2c1b434..5ed02d0 100644 --- a/src/reporting/config.test.ts +++ b/src/reporting/config.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { loadConfig, writeDefaultConfig, appendExcludePatterns } from './config.js'; +import { loadConfig, writeDefaultConfig, appendExcludePatterns } from '../core-open/config.js'; async function createTempDir(): Promise { return fs.mkdtemp(path.join(tmpdir(), 'vibgrate-config-test-')); diff --git a/src/reporting/config.ts b/src/reporting/config.ts deleted file mode 100644 index 818d4b6..0000000 --- a/src/reporting/config.ts +++ /dev/null @@ -1,252 +0,0 @@ -import * as path from 'node:path'; -import * as fs from 'node:fs/promises'; -import ts from 'typescript'; -import type { VibgrateConfig } from './types.js'; -import { pathExists, readTextFile } from './utils/fs.js'; - -const CONFIG_FILES = [ - 'vibgrate.config.ts', - 'vibgrate.config.js', - 'vibgrate.config.json', -]; - -const TRUSTED_CONFIG_ENV = 'VIBGRATE_TRUST_CONFIG'; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function toStaticValue( - expr: ts.Expression, - constBindings: Map, -): unknown { - if (ts.isParenthesizedExpression(expr)) return toStaticValue(expr.expression, constBindings); - if (ts.isStringLiteralLike(expr)) return expr.text; - if (ts.isNumericLiteral(expr)) return Number(expr.text); - if (expr.kind === ts.SyntaxKind.TrueKeyword) return true; - if (expr.kind === ts.SyntaxKind.FalseKeyword) return false; - if (expr.kind === ts.SyntaxKind.NullKeyword) return null; - - if (ts.isArrayLiteralExpression(expr)) { - return expr.elements.map((el) => { - if (ts.isSpreadElement(el)) throw new Error('Spread not supported in static config arrays'); - return toStaticValue(el as ts.Expression, constBindings); - }); - } - - if (ts.isObjectLiteralExpression(expr)) { - const out: Record = {}; - for (const prop of expr.properties) { - if (!ts.isPropertyAssignment(prop) || prop.initializer === undefined) { - throw new Error('Only plain object properties are supported in static config'); - } - if (ts.isComputedPropertyName(prop.name)) { - throw new Error('Computed property names are not supported in static config'); - } - - const key = ts.isIdentifier(prop.name) - ? prop.name.text - : ts.isStringLiteral(prop.name) - ? prop.name.text - : ts.isNumericLiteral(prop.name) - ? prop.name.text - : null; - - if (key === null) { - throw new Error('Unsupported object key in static config'); - } - - out[key] = toStaticValue(prop.initializer, constBindings); - } - return out; - } - - if (ts.isIdentifier(expr)) { - const bound = constBindings.get(expr.text); - if (!bound) throw new Error(`Unknown identifier in static config: ${expr.text}`); - return toStaticValue(bound, constBindings); - } - - throw new Error('Non-static expression in config'); -} - -function tryParseStaticConfig(text: string, configPath: string): VibgrateConfig | null { - const scriptKind = configPath.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS; - const source = ts.createSourceFile(configPath, text, ts.ScriptTarget.ESNext, true, scriptKind); - const constBindings = new Map(); - - for (const stmt of source.statements) { - if (!ts.isVariableStatement(stmt)) continue; - if (!(stmt.declarationList.flags & ts.NodeFlags.Const)) continue; - - for (const decl of stmt.declarationList.declarations) { - if (ts.isIdentifier(decl.name) && decl.initializer) { - constBindings.set(decl.name.text, decl.initializer); - } - } - } - - for (const stmt of source.statements) { - if (!ts.isExportAssignment(stmt)) continue; - const parsed = toStaticValue(stmt.expression, constBindings); - if (!isRecord(parsed)) return null; - return { ...DEFAULT_CONFIG, ...parsed } as VibgrateConfig; - } - - return null; -} - -/** 5 MB — default ceiling for individual files read during a scan */ -const DEFAULT_MAX_FILE_SIZE = 5_242_880; - -/** 3 minutes — default per-project scan timeout (seconds) */ -const DEFAULT_PROJECT_SCAN_TIMEOUT = 180; - -const DEFAULT_CONFIG: VibgrateConfig = { - exclude: [], - maxFileSizeToScan: DEFAULT_MAX_FILE_SIZE, - projectScanTimeout: DEFAULT_PROJECT_SCAN_TIMEOUT, - thresholds: { - failOnError: { - eolDays: 180, - frameworkMajorLag: 3, - dependencyTwoPlusPercent: 50, - }, - warn: { - frameworkMajorLag: 2, - dependencyTwoPlusPercent: 30, - }, - }, -}; - -export async function loadConfig(rootDir: string): Promise { - let config = DEFAULT_CONFIG; - - for (const file of CONFIG_FILES) { - const configPath = path.join(rootDir, file); - if (await pathExists(configPath)) { - if (file.endsWith('.json')) { - const txt = await readTextFile(configPath); - config = { ...DEFAULT_CONFIG, ...JSON.parse(txt) }; - break; - } - const txt = await readTextFile(configPath); - let staticConfig: VibgrateConfig | null = null; - try { - staticConfig = tryParseStaticConfig(txt, configPath); - } catch { - staticConfig = null; - } - if (staticConfig) { - config = staticConfig; - break; - } - // Dynamic imports execute arbitrary code from the scanned repository. - // Require explicit opt-in for this behavior. - if (process.env[TRUSTED_CONFIG_ENV] === '1') { - try { - const mod = await import(configPath); - config = { ...DEFAULT_CONFIG, ...(mod.default ?? mod) }; - break; - } catch { - // Fall back to default - } - } - } - } - - // Merge sidecar auto-excludes (from stuck-dir detection) - const sidecarPath = path.join(rootDir, '.vibgrate', 'auto-excludes.json'); - if (await pathExists(sidecarPath)) { - try { - const txt = await readTextFile(sidecarPath); - const autoExcludes = JSON.parse(txt); - if (Array.isArray(autoExcludes) && autoExcludes.length > 0) { - const existing = config.exclude ?? []; - config = { ...config, exclude: [...new Set([...existing, ...autoExcludes])] }; - } - } catch { - // ignore corrupt sidecar - } - } - - return config; -} - -export async function writeDefaultConfig(rootDir: string): Promise { - const configPath = path.join(rootDir, 'vibgrate.config.ts'); - - const content = `import type { VibgrateConfig } from '@vibgrate/cli'; - -const config: VibgrateConfig = { - // exclude: ['legacy/**'], - // maxFileSizeToScan: 5_242_880, // 5 MB (default) - // projectScanTimeout: 180, // 3 min per project (default, in seconds) - thresholds: { - failOnError: { - eolDays: 180, - frameworkMajorLag: 3, - dependencyTwoPlusPercent: 50, - }, - warn: { - frameworkMajorLag: 2, - dependencyTwoPlusPercent: 30, - }, - }, -}; - -export default config; -`; - - await fs.writeFile(configPath, content, 'utf8'); - return configPath; -} - -/** - * Append exclude patterns to the config file. - * Deduplicates against existing excludes and writes back. - * Supports .json configs directly; for .ts/.js configs, falls back to creating - * a .vibgrate/auto-excludes.json sidecar (merged on next loadConfig). - * Returns true if patterns were persisted. - */ -export async function appendExcludePatterns(rootDir: string, newPatterns: string[]): Promise { - if (newPatterns.length === 0) return false; - - // Try JSON config first (easiest to update programmatically) - const jsonPath = path.join(rootDir, 'vibgrate.config.json'); - if (await pathExists(jsonPath)) { - try { - const txt = await readTextFile(jsonPath); - const cfg = JSON.parse(txt) as Record; - const existing = Array.isArray(cfg.exclude) ? (cfg.exclude as string[]) : []; - const merged = [...new Set([...existing, ...newPatterns])]; - cfg.exclude = merged; - await fs.writeFile(jsonPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8'); - return true; - } catch { - // fall through - } - } - - // For .ts/.js configs (or no config at all), use a sidecar JSON - const vibgrateDir = path.join(rootDir, '.vibgrate'); - const sidecarPath = path.join(vibgrateDir, 'auto-excludes.json'); - let existing: string[] = []; - if (await pathExists(sidecarPath)) { - try { - const txt = await readTextFile(sidecarPath); - const parsed = JSON.parse(txt); - if (Array.isArray(parsed)) existing = parsed; - } catch { - // ignore - } - } - const merged = [...new Set([...existing, ...newPatterns])]; - try { - await fs.mkdir(vibgrateDir, { recursive: true }); - await fs.writeFile(sidecarPath, JSON.stringify(merged, null, 2) + '\n', 'utf8'); - return true; - } catch { - return false; - } -} diff --git a/src/reporting/drift-budget-gate.test.ts b/src/reporting/drift-budget-gate.test.ts new file mode 100644 index 0000000..541f6f0 --- /dev/null +++ b/src/reporting/drift-budget-gate.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { evaluateConfigDriftBudget } from './drift-budget-gate.js'; + +const file = '.vibgrate/config.yml'; + +describe('evaluateConfigDriftBudget', () => { + it('does nothing when the config has no driftBudget', () => { + expect(evaluateConfigDriftBudget({ raw: undefined, configFile: file, headScore: 90, baseScore: null })).toEqual({ + exitCode: 0, + lines: [], + verdict: null, + }); + }); + + it('exits 2 on an enforced breach and says how to pass', () => { + const gate = evaluateConfigDriftBudget({ raw: { mode: 'enforce', maxScore: 40 }, configFile: file, headScore: 44, baseScore: null }); + expect(gate.exitCode).toBe(2); + expect(gate.lines).toContainEqual({ + level: 'error', + text: 'drift budget (enforce): DriftScore 44 is above the budget of 40. Lower it by 4 points to pass.', + }); + }); + + it('warns without failing in warn mode, and names the switch', () => { + const gate = evaluateConfigDriftBudget({ raw: { maxScore: 40 }, configFile: file, headScore: 44, baseScore: null }); + expect(gate.exitCode).toBe(0); + expect(gate.lines[0]?.level).toBe('warn'); + expect(gate.lines.at(-1)?.text).toContain('set driftBudget.mode: enforce in .vibgrate/config.yml'); + }); + + it('needs --baseline for a worsening limit and never fails without one', () => { + const gate = evaluateConfigDriftBudget({ raw: { mode: 'enforce', maxWorseningPercent: 0 }, configFile: file, headScore: 60, baseScore: null }); + expect(gate.exitCode).toBe(0); + expect(gate.lines[0]?.text).toContain('Run with --baseline to compare.'); + }); + + it('leaves agent limits to the GitHub App check', () => { + const gate = evaluateConfigDriftBudget({ + raw: { mode: 'enforce', agents: { maxWorseningPercent: 0 } }, + configFile: file, + headScore: 60, + baseScore: 10, + }); + expect(gate).toMatchObject({ exitCode: 0, lines: [] }); + }); + + it('reports an invalid budget without failing the scan', () => { + const gate = evaluateConfigDriftBudget({ raw: { mode: 'enforce', maxscore: 40 }, configFile: file, headScore: 90, baseScore: null }); + expect(gate.exitCode).toBe(0); + expect(gate.lines.map((l) => l.text)).toEqual([ + 'driftBudget in .vibgrate/config.yml was not applied:', + ' driftBudget.maxscore is not a known setting.', + ]); + }); +}); diff --git a/src/reporting/drift-budget-gate.ts b/src/reporting/drift-budget-gate.ts new file mode 100644 index 0000000..5b1497b --- /dev/null +++ b/src/reporting/drift-budget-gate.ts @@ -0,0 +1,66 @@ +/** + * `driftBudget` from the project config, applied to a finished `vg scan`. + * + * The `--drift-budget` / `--drift-worsening` flags keep their historic + * behaviour and take precedence: when either flag is passed, the config budget + * is not consulted. Otherwise the config budget is evaluated with the same + * evaluator the GitHub App check uses (`core-open/drift-budget.ts`). + * + * `vg scan` cannot tell who authored a change, so `agents.maxWorseningPercent` + * is enforced by the GitHub App check only. + */ +import { evaluateDriftBudget, parseDriftBudget, type DriftBudgetVerdict } from '../core-open/index.js'; + +export interface DriftBudgetGateInput { + /** The `driftBudget` value from the loaded config (unvalidated). */ + raw: unknown; + /** Where the config came from, for messages. */ + configFile: string | null; + headScore: number; + /** Score before this change: `headScore - delta` when `--baseline` was used. */ + baseScore: number | null; +} + +export type GateLineLevel = 'info' | 'warn' | 'error'; + +export interface DriftBudgetGateResult { + /** 2 when an enforced limit was breached; otherwise 0. */ + exitCode: 0 | 2; + lines: { level: GateLineLevel; text: string }[]; + verdict: DriftBudgetVerdict | null; +} + +export function evaluateConfigDriftBudget(input: DriftBudgetGateInput): DriftBudgetGateResult { + const source = input.configFile ?? 'the project config'; + const parsed = parseDriftBudget(input.raw); + if (!parsed) return { exitCode: 0, lines: [], verdict: null }; + if (!parsed.ok) { + return { + exitCode: 0, + verdict: null, + lines: [ + { level: 'warn', text: `driftBudget in ${source} was not applied:` }, + ...parsed.errors.map((e) => ({ level: 'warn' as const, text: ` ${e}` })), + ], + }; + } + + const verdict = evaluateDriftBudget({ + headScore: input.headScore, + baseScore: input.baseScore, + budget: parsed.budget, + }); + const lines: DriftBudgetGateResult['lines'] = []; + const breachLevel: GateLineLevel = verdict.mode === 'enforce' ? 'error' : 'warn'; + for (const rule of verdict.rules) { + // An agent limit can't be judged locally; the App check owns it. + if (rule.id === 'agents.maxWorseningPercent') continue; + if (rule.status === 'breach') lines.push({ level: breachLevel, text: `drift budget (${verdict.mode}): ${rule.message}` }); + else if (rule.status === 'not_evaluated') lines.push({ level: 'info', text: `drift budget: ${rule.message} Run with --baseline to compare.` }); + else lines.push({ level: 'info', text: `drift budget: ${rule.message}` }); + } + if (!verdict.withinBudget && verdict.mode === 'warn') { + lines.push({ level: 'info', text: `drift budget: warn mode — set driftBudget.mode: enforce in ${source} to fail the scan.` }); + } + return { exitCode: verdict.blocking ? 2 : 0, lines, verdict }; +} diff --git a/src/review/capsule.ts b/src/review/capsule.ts index f144c5e..1bbd993 100644 --- a/src/review/capsule.ts +++ b/src/review/capsule.ts @@ -17,7 +17,7 @@ import type { DeclaredIntent } from './intent.js'; import type { ArchitectureLayer } from '../core-open/types.js'; import type { GraphEdge, GraphNode, VgGraph } from '../schema.js'; import { VERSION } from '../version.js'; -import type { ReviewConfig } from './config.js'; +import { REVIEW_CONFIG_PATH, type ReviewConfig } from './config.js'; import { isIntroducedEdge, removedDestinations } from './delta.js'; import { isDependencyManifest, isNonCodePath } from './surface.js'; import type { ChangeSet, ChangedFile } from './git.js'; @@ -409,7 +409,9 @@ export function compileCapsule(input: CompileCapsuleInput): CompiledCapsule { // is intent, not review.toml — labelling it as the policy file would tell a // reader to look for a setting that is not there. const policySource: CapsulePolicyFact['source'] = config.target_pattern - ? 'review.toml' + ? config.file && config.file !== REVIEW_CONFIG_PATH + ? 'config' + : 'review.toml' : declared ? 'intent' : 'derived'; diff --git a/src/review/config.ts b/src/review/config.ts index 5d9f2f3..6602868 100644 --- a/src/review/config.ts +++ b/src/review/config.ts @@ -1,17 +1,26 @@ /** - * `.vibgrate/review.toml` — the review policy configuration (spec §5). + * Review policy configuration (spec §5). + * + * The policy is the `review` block of the project config + * (`.vibgrate/config.yml` or `vibgrate.config.json`). The older + * `.vibgrate/review.toml` is still read when the config has no `review` block. * * **Read from the trusted base branch, never the working tree.** A PR that - * edits `review.toml` must not weaken the policy applied to itself, so when a - * base ref is available the file is read via `git show :.vibgrate/review.toml`. - * The working-tree copy is used only when there is no base (a local - * `vg review` against HEAD, where HEAD *is* the trusted state) — and even then - * the committed HEAD copy wins over an uncommitted edit. + * edits the policy must not weaken the policy applied to itself, so when a + * base ref is given it is read via `git show :`, and only there: + * a base with no policy means the defaults, never the change's own copy. + * Without a base, the committed HEAD copy is used, and the working-tree copy + * only when git has nothing to show (no commits, or not a repository). + * + * A base-branch read is a data read: a `.ts`/`.js` project config is code and + * is never executed here, so its `review` block cannot be used — put review + * settings in `.vibgrate/config.yml` or `vibgrate.config.json`. */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { parseToml } from '../core-open/utils/toml.js'; +import { CONFIG_FILES, isDataConfigFile, parseDataConfig, readDataConfigSync } from '../core-open/config.js'; import type { GitRunner } from './git.js'; import type { ReviewEnforcement } from './schemas.js'; @@ -44,6 +53,8 @@ export interface ReviewConfig { high_severity_decision: 'fail' | 'needs_review'; /** Where the effective config came from — recorded for the human report. */ source: 'base-branch' | 'head' | 'working-tree' | 'defaults'; + /** The file that supplied it: a project config file, `.vibgrate/review.toml`, or null for defaults. */ + file: string | null; } export const DEFAULT_REVIEW_CONFIG: ReviewConfig = { @@ -59,6 +70,7 @@ export const DEFAULT_REVIEW_CONFIG: ReviewConfig = { high_confidence_threshold: 0.8, high_severity_decision: 'fail', source: 'defaults', + file: null, }; function bool(value: unknown, fallback: boolean): boolean { @@ -71,11 +83,46 @@ function str(value: unknown, allowed: readonly T[], fallback: : fallback; } +/** + * Map the `review` block of the project config (camelCase keys) onto the + * policy. Unknown keys are ignored, never fatal — the same contract as + * `review.toml`. + */ +export function reviewConfigFromBlock( + block: unknown, + source: ReviewConfig['source'], + file: string, +): ReviewConfig { + const review = (block && typeof block === 'object' && !Array.isArray(block) ? block : {}) as Record; + const prot = (review.protected ?? {}) as Record; + return normalise( + { + enforcement: review.enforcement, + fail_on: review.failOn, + target_pattern: review.targetPattern, + approved_exceptions: review.approvedExceptions, + protected: { + unguarded_entrypoint: prot.unguardedEntrypoint, + known_vulnerable_dependency: prot.knownVulnerableDependency, + validated_taint: prot.validatedTaint, + }, + high_confidence_threshold: review.highConfidenceThreshold, + high_severity_decision: review.highSeverityDecision, + }, + source, + file, + ); +} + /** Parse a `review.toml` document. Unknown keys are ignored, never fatal. */ export function parseReviewConfig(text: string, source: ReviewConfig['source']): ReviewConfig { const doc = parseToml(text); - if (!doc) return { ...DEFAULT_REVIEW_CONFIG, source }; - const review = (doc.review ?? {}) as Record; + if (!doc) return { ...DEFAULT_REVIEW_CONFIG, source, file: REVIEW_CONFIG_PATH }; + return normalise((doc.review ?? {}) as Record, source, REVIEW_CONFIG_PATH); +} + +/** Validate snake_case policy values, falling back to defaults per key. */ +function normalise(review: Record, source: ReviewConfig['source'], file: string): ReviewConfig { const prot = (review.protected ?? {}) as Record; const exceptions = Array.isArray(review.approved_exceptions) ? (review.approved_exceptions as unknown[]).filter((e): e is string => typeof e === 'string') @@ -103,35 +150,67 @@ export function parseReviewConfig(text: string, source: ReviewConfig['source']): DEFAULT_REVIEW_CONFIG.high_severity_decision, ), source, + file, }; } +/** + * The project config's `review` block as committed at `ref`, when the config + * there is data. Returns `undefined` when there is no block to use — no + * config, a `.ts`/`.js` config, an unparseable file, or no `review` key. + */ +function reviewBlockAtRef(root: string, ref: string, run: GitRunner): { file: string; block: unknown } | undefined { + for (const file of CONFIG_FILES) { + const res = run(['show', `${ref}:${file}`], root); + if (res.status !== 0) continue; + // The first config file present is the config — never fall through to a + // shadowed one, or base and working tree could disagree about which file + // is in force. + if (!isDataConfigFile(file)) return undefined; + try { + const doc = parseDataConfig(res.stdout, file); + return doc.review === undefined ? undefined : { file, block: doc.review }; + } catch { + return undefined; + } + } + return undefined; +} + /** * Load the effective config for this review. * - * Order: the base ref's committed copy (trusted), then HEAD's committed copy, - * then the working tree (only when git can't answer at all), then defaults. + * With a base: the base ref's committed copy, else the defaults — nothing + * from the change itself. Without a base: HEAD's committed copy, then the + * working tree (only when git can't answer at all), then the defaults. */ export function loadReviewConfig( root: string, base: string | undefined, run: GitRunner, ): ReviewConfig { - const fromRef = (ref: string): string | null => { + // At each ref the config's `review` block wins; `review.toml` is the + // fallback for repositories that have not moved their policy yet. + const fromRef = (ref: string, source: ReviewConfig['source']): ReviewConfig | null => { + const block = reviewBlockAtRef(root, ref, run); + if (block) return reviewConfigFromBlock(block.block, source, block.file); const res = run(['show', `${ref}:${REVIEW_CONFIG_PATH}`], root); - return res.status === 0 && res.stdout.trim() ? res.stdout : null; + return res.status === 0 && res.stdout.trim() ? parseReviewConfig(res.stdout, source) : null; }; - if (base) { - const text = fromRef(base); - if (text) return parseReviewConfig(text, 'base-branch'); - } - const head = fromRef('HEAD'); - if (head) return parseReviewConfig(head, 'head'); + // With a base, the base is the only source. Falling back to HEAD would let a + // change introduce a weaker policy in a repository whose base has none. + if (base) return fromRef(base, 'base-branch') ?? { ...DEFAULT_REVIEW_CONFIG }; + const head = fromRef('HEAD', 'head'); + if (head) return head; // No git-visible copy (a repo with no commits, or a non-repo). The working // tree is the only state there is, and it is not "a PR weakening its own // policy" — there is no base to weaken relative to. + const project = readDataConfigSync(root); + if (project.file && project.config?.review !== undefined) { + return reviewConfigFromBlock(project.config.review, 'working-tree', project.file); + } const local = path.join(root, REVIEW_CONFIG_PATH); if (fs.existsSync(local)) { try { diff --git a/src/review/format.ts b/src/review/format.ts index a7f938c..6f46340 100644 --- a/src/review/format.ts +++ b/src/review/format.ts @@ -89,7 +89,8 @@ export function formatText(result: RunReviewResult): string { function formatPacksText(result: RunReviewResult): string[] { const packs = result.packs; if (!packs) return []; - const lines = ['', c.dim(' Review packs (.vibgrate/review/)')]; + const from = packs.source === 'base-branch' ? ' · from the base branch' : ''; + const lines = ['', c.dim(` Review packs (.vibgrate/review/)${from}`)]; lines.push(c.dim(` ignore: ${packs.ignore.patterns.length} pattern(s)`)); lines.push(c.dim(packs.policy ? ' policy: present' : ' policy: none')); if (packs.mergeDecision) { diff --git a/src/review/packs.test.ts b/src/review/packs.test.ts index 96ba01b..30622e6 100644 --- a/src/review/packs.test.ts +++ b/src/review/packs.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -8,6 +9,7 @@ import { parseIgnoreMarkdown, parseMergeMarkdown, } from './packs.js'; +import type { GitRunner } from './git.js'; const dirs: string[] = []; @@ -87,3 +89,82 @@ describe('loadReviewPacks', () => { expect(self.mergeDecision).toBe('refuse'); }); }); + +describe('loadReviewPacks from the --base ref', () => { + const run: GitRunner = (args, cwd) => { + const res = spawnSync('git', args, { cwd, encoding: 'utf8' }); + return { stdout: res.stdout ?? '', status: res.status ?? 1 }; + }; + const git = (cwd: string, ...args: string[]) => { + const res = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (res.status !== 0) throw new Error(`git ${args.join(' ')}: ${res.stderr}`); + }; + const write = (root: string, rel: string, text: string) => { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), text); + }; + + /** A repo whose `main` carries the team's rules, checked out on a feature branch. */ + function repoWithBaseRules(): string { + const root = tmp(); + git(root, 'init', '-q', '-b', 'main'); + git(root, 'config', 'user.email', 'test@example.com'); + git(root, 'config', 'user.name', 'Test'); + write(root, '.vibgrate/review/ignore.md', '- vendor/**\n'); + write(root, '.vibgrate/review/policy.md', 'Keep controllers thin.\n'); + write(root, '.vibgrate/review/checks/api.md', '---\ntitle: API check\ninclude:\n - src/**\n---\nCheck the API.\n'); + write(root, '.vibgrate/review/checks/notes.txt', 'not a check\n'); + write(root, '.vibgrate/review/checks/nested/deep.md', '---\ntitle: Nested\n---\nNot a top-level check.\n'); + write(root, '.vibgrate/review/checks/huge.md', `---\ntitle: Huge\n---\n${'x'.repeat(70 * 1024)}\n`); + write(root, 'src/a.ts', 'export const a = 1;\n'); + git(root, 'add', '-A'); + git(root, 'commit', '-q', '-m', 'base'); + git(root, 'checkout', '-q', '-b', 'feature'); + return root; + } + + it('reads the rules as committed on the base, not the change\'s edits', () => { + const root = repoWithBaseRules(); + // The change widens ignore.md over the file it breaks and deletes the check. + write(root, '.vibgrate/review/ignore.md', '- vendor/**\n- src/**\n'); + fs.rmSync(path.join(root, '.vibgrate/review/checks/api.md')); + git(root, 'add', '-A'); + git(root, 'commit', '-q', '-m', 'hide my change'); + + const fromBase = loadReviewPacks(root, ['src/a.ts'], { kind: 'ref', ref: 'main', run }); + expect(fromBase.source).toBe('base-branch'); + expect(fromBase.ignore.patterns).toEqual(['vendor/**']); + expect(fromBase.policy?.instructions).toBe('Keep controllers thin.'); + expect(fromBase.checks.map((c) => [c.id, c.ran])).toEqual([['api', true]]); + + const fromTree = loadReviewPacks(root, ['src/a.ts']); + expect(fromTree.source).toBe('working-tree'); + expect(fromTree.ignore.patterns).toEqual(['vendor/**', 'src/**']); + expect(fromTree.checks.map((c) => c.id)).toEqual([]); + }); + + it('skips non-markdown, nested, and oversized check files at the ref, as on disk', () => { + const root = repoWithBaseRules(); + const fromBase = loadReviewPacks(root, ['src/a.ts'], { kind: 'ref', ref: 'main', run }); + expect(fromBase.checks.map((c) => c.id)).toEqual(['api']); + expect(loadReviewPacks(root, ['src/a.ts']).checks.map((c) => c.id)).toEqual(['api']); + }); + + it('treats rules the base does not have as absent — the change cannot add them', () => { + const root = tmp(); + git(root, 'init', '-q', '-b', 'main'); + git(root, 'config', 'user.email', 'test@example.com'); + git(root, 'config', 'user.name', 'Test'); + write(root, 'src/a.ts', 'export const a = 1;\n'); + git(root, 'add', '-A'); + git(root, 'commit', '-q', '-m', 'base without rules'); + git(root, 'checkout', '-q', '-b', 'feature'); + write(root, '.vibgrate/review/ignore.md', '- src/**\n'); + git(root, 'add', '-A'); + git(root, 'commit', '-q', '-m', 'add my own ignore'); + + const fromBase = loadReviewPacks(root, ['src/a.ts'], { kind: 'ref', ref: 'main', run }); + expect(fromBase.loaded).toBe(false); + expect(fromBase.ignore.patterns).toEqual([]); + }); +}); diff --git a/src/review/packs.ts b/src/review/packs.ts index 36e7e3b..479dede 100644 --- a/src/review/packs.ts +++ b/src/review/packs.ts @@ -4,11 +4,21 @@ * Canonical tree only. Team ignore / policy / merge / checks live next to the * code they govern. This loader is local and deterministic — it never calls a * hosted model. + * + * **With `--base`, the packs are read from the base ref, never the working + * tree.** A change must not be able to clear its own review — by adding an + * `ignore.md` glob over the files it breaks, or deleting a check — so a PR + * review sees the rules as they are on the branch it merges into, the same as + * the GitHub App and the `review` policy block. A file the base does not have + * is absent; there is no fallback to the change's own copy. Without `--base` + * (a local review of uncommitted work) the working tree is the only state and + * is read directly. */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { evaluateMergePolicy, matchSimpleGlob, type MergeDecision } from './merge-policy.js'; +import type { GitRunner } from './git.js'; export const REVIEW_ROOT = '.vibgrate/review'; export const REVIEW_CHECK_DIR = '.vibgrate/review/checks'; @@ -53,8 +63,10 @@ export interface ReviewCheckRun { } export interface ReviewPackReport { - /** True when at least one pack file existed on disk. */ + /** True when at least one pack file was found. */ loaded: boolean; + /** Where the packs were read from: the `--base` ref, or the working tree. */ + source: 'base-branch' | 'working-tree'; ignore: ReviewIgnore; policy: ReviewPolicyDoc | null; merge: ReviewMergeDoc | null; @@ -93,14 +105,26 @@ export function isIgnoredPath(filePath: string, ignore: ReviewIgnore): boolean { return ignore.patterns.some((pattern) => matchSimpleGlob(pattern, value)); } +/** Where the packs are read from. */ +export type ReviewPackSource = + | { kind: 'working-tree' } + /** A committed ref (the `--base`), read with `git show` / `git ls-tree`. */ + | { kind: 'ref'; ref: string; run: GitRunner }; + +const MAX_CHECK_BYTES = 64 * 1024; + export function loadReviewPacks( root: string, changedFiles: string[], + source: ReviewPackSource = { kind: 'working-tree' }, ): ReviewPackReport { - const ignoreText = readIfPresent(root, REVIEW_IGNORE_PATH); - const policyText = readIfPresent(root, REVIEW_POLICY_PATH); - const mergeText = readIfPresent(root, REVIEW_MERGE_PATH); - const checkFiles = listCheckFiles(root); + const read = source.kind === 'ref' + ? (rel: string) => readAtRef(root, source.ref, rel, source.run) + : (rel: string) => readIfPresent(root, rel); + const ignoreText = read(REVIEW_IGNORE_PATH); + const policyText = read(REVIEW_POLICY_PATH); + const mergeText = read(REVIEW_MERGE_PATH); + const checkFiles = source.kind === 'ref' ? listCheckFilesAtRef(root, source.ref, source.run) : listCheckFiles(root); const loaded = Boolean(ignoreText || policyText || mergeText || checkFiles.length); const ignore = ignoreText ? parseIgnoreMarkdown(ignoreText) : { patterns: [] }; @@ -133,6 +157,7 @@ export function loadReviewPacks( return { loaded, + source: source.kind === 'ref' ? 'base-branch' : 'working-tree', ignore, policy, merge, @@ -198,7 +223,7 @@ function listCheckFiles(root: string): { path: string; content: string }[] { const abs = path.join(root, rel); try { const st = fs.statSync(abs); - if (!st.isFile() || st.size > 64 * 1024) continue; + if (!st.isFile() || st.size > MAX_CHECK_BYTES) continue; out.push({ path: rel, content: fs.readFileSync(abs, 'utf8') }); } catch { /* unreadable */ @@ -207,6 +232,36 @@ function listCheckFiles(root: string): { path: string; content: string }[] { return out; } +/** A file as committed at `ref`, or null when the ref does not have it. */ +function readAtRef(root: string, ref: string, rel: string, run: GitRunner): string | null { + const res = run(['show', `${ref}:${rel}`], root); + return res.status === 0 ? res.stdout : null; +} + +/** `checks/*.md` as committed at `ref`, in the same order and size limit as the working-tree walk. */ +function listCheckFilesAtRef(root: string, ref: string, run: GitRunner): { path: string; content: string }[] { + // `-l` adds the blob size, so an oversized check is skipped without reading it. + const res = run(['ls-tree', '-l', ref, '--', `${REVIEW_CHECK_DIR}/`], root); + if (res.status !== 0) return []; + const entries: { rel: string; size: number }[] = []; + for (const line of res.stdout.split('\n')) { + // SP SP SP+ TAB + const match = /^\d+ (\w+) [0-9a-f]+\s+(\S+)\t(.+)$/.exec(line); + if (!match || match[1] !== 'blob') continue; + const rel = match[3] ?? ''; + const name = rel.slice(REVIEW_CHECK_DIR.length + 1); + if (!name.endsWith('.md') || name.includes('/')) continue; + entries.push({ rel, size: Number(match[2]) }); + } + const out: { path: string; content: string }[] = []; + for (const entry of entries.sort((a, b) => a.rel.localeCompare(b.rel))) { + if (!(entry.size <= MAX_CHECK_BYTES)) continue; + const content = readAtRef(root, ref, entry.rel, run); + if (content !== null) out.push({ path: entry.rel, content }); + } + return out; +} + function readIfPresent(root: string, rel: string): string | null { const abs = path.join(root, rel); try { diff --git a/src/review/prepare.test.ts b/src/review/prepare.test.ts index 5edf877..abcb130 100644 --- a/src/review/prepare.test.ts +++ b/src/review/prepare.test.ts @@ -5,10 +5,11 @@ import * as path from 'node:path'; import { ensureCodeMap, renderReviewPolicy, + renderReviewPolicyYaml, reviewPolicyState, seedReviewPolicy, } from './prepare.js'; -import { parseReviewConfig } from './config.js'; +import { loadReviewConfig, parseReviewConfig } from './config.js'; import type { GitRunner } from './git.js'; function tmpRoot(): string { @@ -57,14 +58,13 @@ describe('reviewPolicyState', () => { }); describe('seedReviewPolicy', () => { - it('writes a parseable, advisory policy carrying the derived pattern', () => { + it('creates .vibgrate/config.yml with an advisory review block carrying the derived pattern', () => { const root = tmpRoot(); const result = seedReviewPolicy({ root, observedPattern: 'clean' }); - expect(result).toEqual({ written: true, path: '.vibgrate/review.toml', targetPattern: 'clean' }); - const text = fs.readFileSync(path.join(root, '.vibgrate/review.toml'), 'utf8'); - const config = parseReviewConfig(text, 'working-tree'); - expect(config.target_pattern).toBe('clean'); + expect(result).toEqual({ written: true, path: '.vibgrate/config.yml', targetPattern: 'clean' }); + const config = loadReviewConfig(root, undefined, fakeGit({})); + expect(config).toMatchObject({ source: 'working-tree', file: '.vibgrate/config.yml', target_pattern: 'clean' }); // The seed must never turn a green CI job red on its own. expect(config.enforcement).toBe('advisory'); expect(config.protected).toEqual({ @@ -72,27 +72,71 @@ describe('seedReviewPolicy', () => { known_vulnerable_dependency: true, validated_taint: true, }); + expect(fs.existsSync(path.join(root, '.vibgrate/review.toml'))).toBe(false); }); - it('leaves target_pattern undeclared when no shape dominates', () => { + it('leaves the target pattern undeclared when no shape dominates', () => { const root = tmpRoot(); seedReviewPolicy({ root, observedPattern: null }); + expect(loadReviewConfig(root, undefined, fakeGit({})).target_pattern).toBeNull(); + }); + + it('appends to an existing YAML config without touching what is there', () => { + const root = tmpRoot(); + const original = '# team settings\nexclude:\n - legacy/**\n'; + fs.mkdirSync(path.join(root, '.vibgrate'), { recursive: true }); + fs.writeFileSync(path.join(root, '.vibgrate/config.yml'), original); + + expect(seedReviewPolicy({ root, observedPattern: 'layered' }).written).toBe(true); + const text = fs.readFileSync(path.join(root, '.vibgrate/config.yml'), 'utf8'); + expect(text.startsWith(original)).toBe(true); + expect(loadReviewConfig(root, undefined, fakeGit({})).target_pattern).toBe('layered'); + }); + + it('adds a review object to vibgrate.config.json', () => { + const root = tmpRoot(); + fs.writeFileSync(path.join(root, 'vibgrate.config.json'), '{"exclude":["legacy/**"]}'); + expect(seedReviewPolicy({ root, observedPattern: 'clean' })).toMatchObject({ written: true, path: 'vibgrate.config.json' }); + const json = JSON.parse(fs.readFileSync(path.join(root, 'vibgrate.config.json'), 'utf8')); + expect(json.exclude).toEqual(['legacy/**']); + expect(json.review).toMatchObject({ enforcement: 'advisory', targetPattern: 'clean' }); + }); + + it('writes review.toml rather than rewriting a .ts config', () => { + const root = tmpRoot(); + const code = 'export default { exclude: [] };\n'; + fs.writeFileSync(path.join(root, 'vibgrate.config.ts'), code); + expect(seedReviewPolicy({ root, observedPattern: 'clean' }).path).toBe('.vibgrate/review.toml'); + expect(fs.readFileSync(path.join(root, 'vibgrate.config.ts'), 'utf8')).toBe(code); const config = parseReviewConfig(fs.readFileSync(path.join(root, '.vibgrate/review.toml'), 'utf8'), 'working-tree'); - expect(config.target_pattern).toBeNull(); + expect(config.target_pattern).toBe('clean'); + }); + + it('does not append to a YAML config it could not read back identically', () => { + const root = tmpRoot(); + fs.mkdirSync(path.join(root, '.vibgrate'), { recursive: true }); + fs.writeFileSync(path.join(root, '.vibgrate/config.yml'), '{ exclude: [legacy/**] }'); + expect(seedReviewPolicy({ root, observedPattern: 'clean' }).written).toBe(false); + expect(fs.readFileSync(path.join(root, '.vibgrate/config.yml'), 'utf8')).toBe('{ exclude: [legacy/**] }'); }); it('never overwrites a policy that is already there', () => { const root = tmpRoot(); fs.mkdirSync(path.join(root, '.vibgrate'), { recursive: true }); fs.writeFileSync(path.join(root, '.vibgrate/review.toml'), '[review]\nenforcement = "enforced"\n'); - expect(seedReviewPolicy({ root, observedPattern: 'clean' }).written).toBe(false); expect(fs.readFileSync(path.join(root, '.vibgrate/review.toml'), 'utf8')).toContain('enforced'); + + const withBlock = tmpRoot(); + fs.writeFileSync(path.join(withBlock, 'vibgrate.config.json'), '{"review":{"enforcement":"enforced"}}'); + expect(seedReviewPolicy({ root: withBlock, observedPattern: 'clean' }).written).toBe(false); }); - it('renders a commented target_pattern rather than an invented one', () => { + it('renders a commented target pattern rather than an invented one', () => { expect(renderReviewPolicy(null)).toContain('# target_pattern = "clean"'); expect(renderReviewPolicy('hexagonal')).toContain('target_pattern = "hexagonal"'); + expect(renderReviewPolicyYaml(null)).toContain('# targetPattern: clean'); + expect(renderReviewPolicyYaml('hexagonal')).toContain('targetPattern: "hexagonal"'); }); }); diff --git a/src/review/prepare.ts b/src/review/prepare.ts index 21a3ef8..0c4bab6 100644 --- a/src/review/prepare.ts +++ b/src/review/prepare.ts @@ -39,7 +39,8 @@ import { refreshIfStale } from '../engine/refresh.js'; import { acquireLock, releaseLock } from '../engine/lock.js'; import { cacheDir } from '../engine/cache.js'; import { ProgressBar } from '../util/progress.js'; -import { REVIEW_CONFIG_PATH } from './config.js'; +import { REVIEW_CONFIG_PATH, loadReviewConfig } from './config.js'; +import { CONFIG_FILES, isDataConfigFile, parseDataConfig, readDataConfigSync } from '../core-open/config.js'; import type { GitRunner } from './git.js'; /** Matches `refresh.ts` — one lock, so a refresh and an auto-build never race. */ @@ -150,7 +151,7 @@ async function firstBuild( } export interface ReviewPolicyState { - /** A committed or working-tree `.vibgrate/review.toml` was found. */ + /** A review policy (config `review` block or `.vibgrate/review.toml`) was found. */ present: boolean; where: 'base-branch' | 'head' | 'working-tree' | null; } @@ -158,60 +159,150 @@ export interface ReviewPolicyState { /** * Is a review policy already set up for this repository? * - * Mirrors {@link import('./config.js').loadReviewConfig}'s search order, so - * "present" here means exactly "that loader will find something", never merely - * "a file exists on disk". + * Answered by {@link loadReviewConfig} itself, so "present" means exactly + * "the loader will find something", never merely "a file exists on disk". */ export function reviewPolicyState( root: string, base: string | undefined, run: GitRunner, ): ReviewPolicyState { - const inRef = (ref: string): boolean => { - const res = run(['show', `${ref}:${REVIEW_CONFIG_PATH}`], root); - return res.status === 0 && res.stdout.trim().length > 0; - }; - if (base && inRef(base)) return { present: true, where: 'base-branch' }; - if (inRef('HEAD')) return { present: true, where: 'head' }; - if (fs.existsSync(path.join(root, REVIEW_CONFIG_PATH))) { - return { present: true, where: 'working-tree' }; - } - return { present: false, where: null }; + const config = loadReviewConfig(root, base, run); + return config.source === 'defaults' + ? { present: false, where: null } + : { present: true, where: config.source }; } export interface SeedPolicyOptions { root: string; /** * The shape the repository already exhibits - * (`capsule.patterns.observed_dominant_pattern`). Seeded as `target_pattern` - * when present — a *declared* target is what lets Review call a layer - * traversal a regression instead of an unknown. + * (`capsule.patterns.observed_dominant_pattern`). Seeded as the target + * pattern, so the first review has a declared shape to judge against. */ - observedPattern?: string | null; + observedPattern: string | null; } export interface SeedPolicyResult { written: boolean; + /** The file the policy was (or would have been) written to. */ path: string; targetPattern: string | null; } /** - * Write the starter `.vibgrate/review.toml`. Never overwrites: the caller - * checks {@link reviewPolicyState} first, and this re-checks the file on disk - * so a concurrent run cannot clobber a hand-edited policy. + * Write the starter review policy — into the project config, so the + * repository keeps one settings file: + * + * - no config yet → create `.vibgrate/config.yml` with a `review` block; + * - `.vibgrate/config.yml` → append a `review` block (existing text untouched); + * - `vibgrate.config.json` → add a `review` object; + * - `vibgrate.config.ts` / `.js` → code is never rewritten, so write the + * older `.vibgrate/review.toml`, which is still honoured. + * + * Never overwrites a policy that exists anywhere, and never writes a file it + * cannot read back. */ export function seedReviewPolicy(opts: SeedPolicyOptions): SeedPolicyResult { - const target = path.join(opts.root, REVIEW_CONFIG_PATH); const pattern = opts.observedPattern ?? null; - if (fs.existsSync(target)) return { written: false, path: REVIEW_CONFIG_PATH, targetPattern: pattern }; + const skip = (at: string): SeedPolicyResult => ({ written: false, path: at, targetPattern: pattern }); + + if (fs.existsSync(path.join(opts.root, REVIEW_CONFIG_PATH))) return skip(REVIEW_CONFIG_PATH); + const project = readDataConfigSync(opts.root); + if (project.config?.review !== undefined) return skip(project.file as string); + + if (project.file === null) { + const file = CONFIG_FILES[0]; + const target = path.join(opts.root, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, renderReviewPolicyYaml(pattern), 'utf8'); + return { written: true, path: file, targetPattern: pattern }; + } + + if (!isDataConfigFile(project.file)) { + const target = path.join(opts.root, REVIEW_CONFIG_PATH); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, renderReviewPolicy(pattern), 'utf8'); + return { written: true, path: REVIEW_CONFIG_PATH, targetPattern: pattern }; + } - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, renderReviewPolicy(pattern), 'utf8'); - return { written: true, path: REVIEW_CONFIG_PATH, targetPattern: pattern }; + // A data config that does not parse is the user's to fix; do not touch it. + if (!project.config) return skip(project.file); + + const target = path.join(opts.root, project.file); + const current = fs.readFileSync(target, 'utf8'); + const next = project.file.endsWith('.json') + ? `${JSON.stringify({ ...project.config, review: reviewPolicyBlock(pattern) }, null, 2)}\n` + : `${current}${current === '' || current.endsWith('\n') ? '' : '\n'}\n${renderReviewPolicyYaml(pattern)}`; + + // Appending to YAML is safe only when the result still parses to the same + // settings plus `review` (a flow-style document would not). + try { + const reread = parseDataConfig(next, project.file); + const { review, ...rest } = reread; + if (JSON.stringify(rest) !== JSON.stringify(project.config) || review === undefined) return skip(project.file); + } catch { + return skip(project.file); + } + fs.writeFileSync(target, next, 'utf8'); + return { written: true, path: project.file, targetPattern: pattern }; } -/** The seeded policy document. Pure, so its bytes are covered by a test. */ +/** The seeded policy as config data (camelCase), for JSON configs. */ +export function reviewPolicyBlock(observedPattern: string | null): Record { + return { + enforcement: 'advisory', + failOn: 'fail', + ...(observedPattern ? { targetPattern: observedPattern } : {}), + approvedExceptions: [], + protected: { unguardedEntrypoint: true, knownVulnerableDependency: true, validatedTaint: true }, + }; +} + +/** The seeded `review` block for `.vibgrate/config.yml`. Pure, so its bytes are covered by a test. */ +export function renderReviewPolicyYaml(observedPattern: string | null): string { + const lines = [ + '# Vibgrate Review policy — written by `vg review` on its first run.', + '# Commit this file: Review reads it from the *base branch*, so a pull', + '# request cannot weaken the policy that judges it.', + 'review:', + ' # "advisory" reports without ever gating. Switch to "enforced" when you', + ' # want failOn to decide the exit code in CI.', + ' enforcement: advisory', + ' failOn: fail', + ]; + if (observedPattern) { + lines.push( + ' # Derived from the layering this repository already exhibits. Change it', + ' # to the shape you want — Review judges changes against this, not against', + ' # the majority.', + ` targetPattern: ${JSON.stringify(observedPattern)}`, + ); + } else { + lines.push( + ' # No single layering shape dominates this repository yet, so nothing is', + ' # declared. Set one (e.g. clean, layered, hexagonal) to have Review', + ' # judge layer traversals instead of reporting them as unknown.', + ' # targetPattern: clean', + ); + } + lines.push( + ' # Layer pairs an author may traverse without it counting as a regression.', + ' approvedExceptions: []', + ' # Protected findings can never be blessed into a pass.', + ' protected:', + ' unguardedEntrypoint: true', + ' knownVulnerableDependency: true', + ' validatedTaint: true', + '', + ); + return lines.join('\n'); +} + +/** + * The seeded `review.toml` — used only when the project config is `.ts`/`.js` + * (code, which is never rewritten). Pure, so its bytes are covered by a test. + */ export function renderReviewPolicy(observedPattern: string | null): string { const lines = [ '# Vibgrate Review policy — written by `vg review` on its first run.', diff --git a/src/review/review.test.ts b/src/review/review.test.ts index 4af7383..e442c43 100644 --- a/src/review/review.test.ts +++ b/src/review/review.test.ts @@ -461,7 +461,55 @@ unguarded_entrypoint = false const cfg = loadReviewConfig('/repo', 'origin/main', run); expect(cfg.source).toBe('base-branch'); expect(cfg.enforcement).toBe('enforced'); - expect(calls[0]).toEqual(['show', 'origin/main:.vibgrate/review.toml']); + // Every lookup before a hit is at the base ref — the project config's + // review block first, then review.toml. HEAD is never consulted. + expect(calls.every((c) => c[1]?.startsWith('origin/main:'))).toBe(true); + expect(calls.at(-1)).toEqual(['show', 'origin/main:.vibgrate/review.toml']); + }); + + it('reads the review block of the project config at the base ref, over review.toml', () => { + const files: Record = { + 'origin/main:.vibgrate/config.yml': 'review:\n enforcement: enforced\n targetPattern: hexagonal\n protected:\n validatedTaint: false\n', + 'origin/main:.vibgrate/review.toml': '[review]\nenforcement = "advisory"\n', + 'HEAD:.vibgrate/config.yml': 'review:\n enforcement: advisory\n', + }; + const run: GitRunner = (args) => + files[args[1] ?? ''] !== undefined ? { stdout: files[args[1] ?? '']!, status: 0 } : { stdout: '', status: 1 }; + const cfg = loadReviewConfig('/repo', 'origin/main', run); + expect(cfg).toMatchObject({ + source: 'base-branch', + file: '.vibgrate/config.yml', + enforcement: 'enforced', + target_pattern: 'hexagonal', + protected: { unguarded_entrypoint: true, validated_taint: false }, + }); + }); + + it('reads vibgrate.config.json when there is no YAML config, and falls back to review.toml without a review block', () => { + const json: GitRunner = (args) => + args[1] === 'HEAD:vibgrate.config.json' + ? { stdout: '{"review":{"failOn":"needs_review"}}', status: 0 } + : { stdout: '', status: 1 }; + expect(loadReviewConfig('/repo', undefined, json)).toMatchObject({ file: 'vibgrate.config.json', fail_on: 'needs_review' }); + + const noBlock: GitRunner = (args) => + args[1] === 'HEAD:.vibgrate/config.yml' + ? { stdout: 'exclude: [legacy/**]\n', status: 0 } + : args[1] === 'HEAD:.vibgrate/review.toml' + ? { stdout: '[review]\nenforcement = "enforced"\n', status: 0 } + : { stdout: '', status: 1 }; + expect(loadReviewConfig('/repo', undefined, noBlock)).toMatchObject({ file: '.vibgrate/review.toml', enforcement: 'enforced' }); + }); + + it('never executes a .ts config to find a review block', () => { + const run: GitRunner = (args) => + args[1] === 'HEAD:vibgrate.config.ts' + ? { stdout: 'export default { review: { enforcement: "enforced" } }', status: 0 } + : args[1] === 'HEAD:vibgrate.config.json' + ? { stdout: '{"review":{"enforcement":"enforced"}}', status: 0 } + : { stdout: '', status: 1 }; + // The .ts file is the config in force; a shadowed JSON file is not consulted. + expect(loadReviewConfig('/repo', undefined, run).source).toBe('defaults'); }); it('prefers the base branch even when HEAD carries a weaker policy', () => { @@ -476,6 +524,20 @@ unguarded_entrypoint = false }; expect(loadReviewConfig('/repo', 'origin/main', run).protected.unguarded_entrypoint).toBe(true); }); + + it('uses the defaults, not the change\'s own policy, when the base has none', () => { + const run: GitRunner = (args) => + args[1] === 'HEAD:.vibgrate/review.toml' + ? { stdout: '[review]\n[review.protected]\nunguarded_entrypoint = false\n', status: 0 } + : args[1] === 'HEAD:.vibgrate/config.yml' + ? { stdout: 'review:\n protected:\n validatedTaint: false\n', status: 0 } + : { stdout: '', status: 1 }; + const cfg = loadReviewConfig('/repo', 'origin/main', run); + expect(cfg.source).toBe('defaults'); + expect(cfg.protected).toEqual({ unguarded_entrypoint: true, known_vulnerable_dependency: true, validated_taint: true }); + // Without --base, HEAD is the trusted state and its policy applies. + expect(loadReviewConfig('/repo', undefined, run).source).toBe('head'); + }); }); // ── git plumbing ──────────────────────────────────────────────────────────── diff --git a/src/review/run.test.ts b/src/review/run.test.ts index d94a59a..d2d9c8f 100644 --- a/src/review/run.test.ts +++ b/src/review/run.test.ts @@ -414,4 +414,22 @@ describe('runReview — receipt', () => { expect(calls).toContainEqual(['diff', '-U3', '-M', `${BASE}..HEAD`]); expect(calls).toContainEqual(['show', `origin/main:.vibgrate/review.toml`]); }); + + it('in --base mode ignores team rules the change adds to its own working tree', async () => { + const { root, graphPath } = routeRepo(); + // The change adds an ignore glob over the very file it modifies. The base + // (answered by git as "no such file") has no ignore.md. + fs.mkdirSync(path.join(root, '.vibgrate/review'), { recursive: true }); + fs.writeFileSync(path.join(root, '.vibgrate/review/ignore.md'), `- ${ROUTE}\n`); + const calls: string[][] = []; + const result = await review(root, graphPath, { nameStatus: `M\t${ROUTE}\n`, numstat: `3\t1\t${ROUTE}\n`, calls }, { base: 'origin/main' }); + expect(result.packs?.source).toBe('base-branch'); + expect(result.packs?.ignore.patterns).toEqual([]); + expect(calls).toContainEqual(['show', 'origin/main:.vibgrate/review/ignore.md']); + + // Without --base the working tree is the only state, so the same file applies. + const local = await review(root, graphPath, modified(ROUTE)); + expect(local.packs?.source).toBe('working-tree'); + expect(local.packs?.ignore.patterns).toEqual([ROUTE]); + }); }); diff --git a/src/review/run.ts b/src/review/run.ts index 25a970d..21b726c 100644 --- a/src/review/run.ts +++ b/src/review/run.ts @@ -316,7 +316,13 @@ export async function runReview(opts: RunReviewOptions): Promise f.path)); + // With --base the team rules come from the base ref, so this change cannot + // clear its own review (see packs.ts). + const packs = loadReviewPacks( + repoRoot, + change.files.map((f) => f.path), + opts.base ? { kind: 'ref', ref: opts.base, run } : { kind: 'working-tree' }, + ); const keepFinding = (f: { paths: string[] }): boolean => { if (packs.ignore.patterns.length === 0) return true; return !f.paths.every((p) => isIgnoredPath(p, packs.ignore)); diff --git a/src/review/schemas.ts b/src/review/schemas.ts index 3861634..291dafc 100644 --- a/src/review/schemas.ts +++ b/src/review/schemas.ts @@ -165,11 +165,12 @@ export interface CapsulePolicyFact { id: string; rule: string; /** - * Where the profile these rules enforce was declared: `.vibgrate/review.toml`, - * an agent-instruction file such as CLAUDE.md or AGENTS.md (`intent`), or + * Where the profile these rules enforce was declared: the `review` block of + * the project config (`config`), the older `.vibgrate/review.toml`, an + * agent-instruction file such as CLAUDE.md or AGENTS.md (`intent`), or * nothing — `derived` from the shape the repository is observed to have. */ - source: 'review.toml' | 'intent' | 'derived'; + source: 'config' | 'review.toml' | 'intent' | 'derived'; } export interface CapsuleVerificationFact { diff --git a/src/version.ts b/src/version.ts index d85b2c1..045ac4e 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Calendar version (YYYY.DDD.PATCH), shared scheme with @vibgrate/cli. -export const VERSION = '2026.921.1'; +export const VERSION = '2026.930.1';