@attalabs/vinaya 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync12 } from "node:fs";
4
+ import { readFileSync as readFileSync13 } from "node:fs";
5
5
  import { dirname as dirname5, join as join15 } from "node:path";
6
6
  import { fileURLToPath as fileURLToPath2 } from "node:url";
7
7
 
@@ -1334,7 +1334,9 @@ function coreCheckRegistry() {
1334
1334
  timeoutMs: 15000,
1335
1335
  env: {
1336
1336
  BASE_SHA: { optional: true },
1337
- PR_NUMBER: { optional: true }
1337
+ PR_NUMBER: { optional: true },
1338
+ GITHUB_TOKEN: { optional: true },
1339
+ GH_TOKEN: { optional: true }
1338
1340
  }
1339
1341
  },
1340
1342
  {
@@ -1359,7 +1361,11 @@ function coreCheckRegistry() {
1359
1361
  run: bin("check-registry-gates"),
1360
1362
  scope: "full",
1361
1363
  timeoutMs: 30000,
1362
- env: { AEG_REPO: { optional: true } }
1364
+ env: {
1365
+ AEG_REPO: { optional: true },
1366
+ GITHUB_TOKEN: { optional: true },
1367
+ GH_TOKEN: { optional: true }
1368
+ }
1363
1369
  },
1364
1370
  {
1365
1371
  name: "review-gate",
@@ -1368,7 +1374,9 @@ function coreCheckRegistry() {
1368
1374
  timeoutMs: 30000,
1369
1375
  env: {
1370
1376
  BRANCH: { optional: true },
1371
- PR_NUMBER: { optional: true }
1377
+ PR_NUMBER: { optional: true },
1378
+ GITHUB_TOKEN: { optional: true },
1379
+ GH_TOKEN: { optional: true }
1372
1380
  }
1373
1381
  },
1374
1382
  {
@@ -1378,7 +1386,9 @@ function coreCheckRegistry() {
1378
1386
  timeoutMs: 30000,
1379
1387
  env: {
1380
1388
  BRANCH: { optional: true },
1381
- AEG_REPO: { optional: true }
1389
+ AEG_REPO: { optional: true },
1390
+ GITHUB_TOKEN: { optional: true },
1391
+ GH_TOKEN: { optional: true }
1382
1392
  }
1383
1393
  },
1384
1394
  {
@@ -1386,7 +1396,11 @@ function coreCheckRegistry() {
1386
1396
  run: bin("check-dead-branch-push"),
1387
1397
  scope: "full",
1388
1398
  timeoutMs: 30000,
1389
- env: { BRANCH: { optional: true } }
1399
+ env: {
1400
+ BRANCH: { optional: true },
1401
+ GITHUB_TOKEN: { optional: true },
1402
+ GH_TOKEN: { optional: true }
1403
+ }
1390
1404
  },
1391
1405
  {
1392
1406
  name: "first-push-dispatch",
@@ -1421,12 +1435,62 @@ function coreCheckRegistry() {
1421
1435
  timeoutMs: 30000,
1422
1436
  env: {
1423
1437
  AEG_REPO: { optional: true },
1424
- BRANCH: { optional: true }
1438
+ BRANCH: { optional: true },
1439
+ GITHUB_TOKEN: { optional: true },
1440
+ GH_TOKEN: { optional: true }
1425
1441
  }
1426
1442
  }
1427
1443
  ];
1428
1444
  }
1429
1445
 
1446
+ // src/checks/resolver.ts
1447
+ var NAMESPACE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/;
1448
+ var RESERVED_PREFIX = "vinaya";
1449
+ function isValidNamespacedKey(key) {
1450
+ const parts = key.split("/");
1451
+ if (parts.length !== 2)
1452
+ return false;
1453
+ const [prefix, rest] = parts;
1454
+ if (!prefix || !rest)
1455
+ return false;
1456
+ if (!NAMESPACE_SEGMENT.test(prefix) || !NAMESPACE_SEGMENT.test(rest))
1457
+ return false;
1458
+ if (prefix === RESERVED_PREFIX)
1459
+ return false;
1460
+ return true;
1461
+ }
1462
+ function resolveChecks(core, configChecks) {
1463
+ const resolved = core.map((spec) => ({ name: spec.name, state: "default", source: "core", spec }));
1464
+ const failures = [];
1465
+ if (!configChecks)
1466
+ return { resolved, failures };
1467
+ const indexByName = new Map(resolved.map((entry2, index) => [entry2.name, index]));
1468
+ for (const [key, entry2] of Object.entries(configChecks)) {
1469
+ const coreIndex = indexByName.get(key);
1470
+ if (coreIndex !== undefined) {
1471
+ resolved[coreIndex] = {
1472
+ name: key,
1473
+ state: "overridden",
1474
+ source: "config",
1475
+ spec: { name: key, ...entry2 }
1476
+ };
1477
+ continue;
1478
+ }
1479
+ if (isValidNamespacedKey(key)) {
1480
+ resolved.push({ name: key, state: "additive", source: "config", spec: { name: key, ...entry2 } });
1481
+ continue;
1482
+ }
1483
+ failures.push({ key, reason: 'bare key has no "/" and matches no core check id' });
1484
+ }
1485
+ return { resolved, failures };
1486
+ }
1487
+ function overriddenNextMinorWarning(name) {
1488
+ return `check "${name}" shares its name with a core check — it will replace the core check starting next minor (currently it runs alongside it under the flat registry).`;
1489
+ }
1490
+ function bareKeyNextMinorWarning(key) {
1491
+ return `check "${key}" has no namespace and matches no core check — it will be rejected starting next minor. Rename it to "<yourname>/x".`;
1492
+ }
1493
+
1430
1494
  // src/checks/runner.ts
1431
1495
  import { spawn } from "node:child_process";
1432
1496
  import { cpus } from "node:os";
@@ -1832,6 +1896,15 @@ function configPath() {
1832
1896
  return GLOBAL_CONFIG_PATH;
1833
1897
  return null;
1834
1898
  }
1899
+ function globalChecksIgnoredWarning(path) {
1900
+ return `${path}: "checks" registration in the global config is ignored — checks may only be registered from a repo-local vinaya.config.json.`;
1901
+ }
1902
+ function stripGlobalChecks(config, path) {
1903
+ if (path !== GLOBAL_CONFIG_PATH || !config.checks || Object.keys(config.checks).length === 0)
1904
+ return config;
1905
+ console.error(`⚠ ${globalChecksIgnoredWarning(path)}`);
1906
+ return { ...config, checks: undefined };
1907
+ }
1835
1908
  function loadConfigChecked() {
1836
1909
  const path = configPath();
1837
1910
  if (!path)
@@ -1847,7 +1920,7 @@ function loadConfigChecked() {
1847
1920
  const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
1848
1921
  return { ok: false, path, error: detail };
1849
1922
  }
1850
- return { ok: true, config: parsed.data };
1923
+ return { ok: true, config: stripGlobalChecks(parsed.data, path) };
1851
1924
  }
1852
1925
 
1853
1926
  // src/commands/check.ts
@@ -1887,25 +1960,112 @@ function configErrorOutcome(path, error) {
1887
1960
  function customSpecsFromConfig() {
1888
1961
  const result = loadConfigChecked();
1889
1962
  if (!result.ok)
1890
- return { specs: [], errorOutcome: configErrorOutcome(result.path, result.error) };
1963
+ return { specs: [], errorOutcome: configErrorOutcome(result.path, result.error), checks: undefined };
1891
1964
  const checks = result.config?.checks;
1892
1965
  if (!checks)
1893
- return { specs: [], errorOutcome: null };
1966
+ return { specs: [], errorOutcome: null, checks: undefined };
1894
1967
  const specs = Object.entries(checks).map(([name, entry2]) => ({ name, ...entry2 }));
1895
- return { specs, errorOutcome: null };
1968
+ return { specs, errorOutcome: null, checks };
1969
+ }
1970
+ function classifyEnvValue(value) {
1971
+ if (value === true)
1972
+ return "passthrough";
1973
+ if (typeof value === "string")
1974
+ return "literal";
1975
+ if ("anyOf" in value)
1976
+ return "anyOf";
1977
+ return "optional";
1978
+ }
1979
+ function renderPlanJson(result) {
1980
+ const checks = {};
1981
+ for (const entry2 of result.resolved) {
1982
+ const env = {};
1983
+ let envAnyOf;
1984
+ for (const [key, value] of Object.entries(entry2.spec.env ?? {})) {
1985
+ env[key] = classifyEnvValue(value);
1986
+ if (typeof value === "object" && value !== null && "anyOf" in value) {
1987
+ envAnyOf ??= {};
1988
+ envAnyOf[key] = value.anyOf;
1989
+ }
1990
+ }
1991
+ checks[entry2.name] = { state: entry2.state, source: entry2.source, env, envAnyOf, scope: entry2.spec.scope };
1992
+ }
1993
+ return {
1994
+ schema: 1,
1995
+ checks,
1996
+ roles: { available: false, reason: "role resolution not implemented yet — see task 6" },
1997
+ errors: result.failures
1998
+ };
1999
+ }
2000
+ function envCellFor(spec) {
2001
+ const entries = Object.entries(spec.env ?? {});
2002
+ if (entries.length === 0)
2003
+ return "—";
2004
+ return entries.map(([key, value]) => {
2005
+ if (value === true)
2006
+ return key;
2007
+ if (typeof value === "string")
2008
+ return `${key}=<redacted>`;
2009
+ if ("anyOf" in value)
2010
+ return value.anyOf.join("|");
2011
+ return `${key}?`;
2012
+ }).join(", ");
2013
+ }
2014
+ function renderPlanTable(result) {
2015
+ const header = ["NAME", "STATE", "SOURCE", "ENV"];
2016
+ const rows = result.resolved.map((entry2) => [entry2.name, entry2.state, entry2.source, envCellFor(entry2.spec)]);
2017
+ const failureRows = result.failures.map((f) => [f.key, "FAILED", "—", f.reason]);
2018
+ const table = [header, ...rows, ...failureRows];
2019
+ const widths = header.map((_, col) => Math.max(...table.map((row) => row[col]?.length ?? 0)));
2020
+ return table.map((row) => row.map((cell, col) => (cell ?? "").padEnd(widths[col] ?? 0)).join(" ")).join(`
2021
+ `);
2022
+ }
2023
+ function resolveForPlan(configResult) {
2024
+ if (configResult.ok) {
2025
+ return resolveChecks(coreCheckRegistry(), configResult.config?.checks);
2026
+ }
2027
+ const core = resolveChecks(coreCheckRegistry(), undefined);
2028
+ return {
2029
+ resolved: core.resolved,
2030
+ failures: [{ key: configResult.path, reason: `invalid \`checks\` registration — ${configResult.error}` }]
2031
+ };
2032
+ }
2033
+ function printClassificationWarnings(configChecks) {
2034
+ const classification = resolveChecks(coreCheckRegistry(), configChecks);
2035
+ for (const entry2 of classification.resolved) {
2036
+ if (entry2.state === "overridden")
2037
+ process.stdout.write(`${overriddenNextMinorWarning(entry2.name)}
2038
+ `);
2039
+ }
2040
+ for (const failure of classification.failures) {
2041
+ process.stdout.write(`${bareKeyNextMinorWarning(failure.key)}
2042
+ `);
2043
+ }
1896
2044
  }
1897
2045
  async function checkCommand(args) {
1898
2046
  const jsonOutput = args.includes("--json");
1899
2047
  const diffOnly = args.includes("--diff-only");
1900
2048
  const requestedParallel = parseParallel(args);
1901
2049
  const allRequested = args.includes("--all");
2050
+ const planRequested = args.includes("--plan");
1902
2051
  const positional = args.filter((a) => !a.startsWith("--"));
1903
2052
  const requestedName = positional[0];
2053
+ if (planRequested) {
2054
+ const result = resolveForPlan(loadConfigChecked());
2055
+ if (jsonOutput) {
2056
+ process.stdout.write(`${JSON.stringify(renderPlanJson(result), null, 2)}
2057
+ `);
2058
+ } else {
2059
+ process.stdout.write(`${renderPlanTable(result)}
2060
+ `);
2061
+ }
2062
+ process.exit(result.failures.length > 0 ? 1 : 0);
2063
+ }
1904
2064
  if (!allRequested && !requestedName) {
1905
- console.error("Usage: vinaya check <name> | --all [--json] [--diff-only] [--parallel[=n]]");
2065
+ console.error("Usage: vinaya check <name> | --all | --plan [--json] [--diff-only] [--parallel[=n]]");
1906
2066
  process.exit(2);
1907
2067
  }
1908
- const { specs: customSpecs, errorOutcome } = customSpecsFromConfig();
2068
+ const { specs: customSpecs, errorOutcome, checks: configChecks } = customSpecsFromConfig();
1909
2069
  const allSpecs = [...coreCheckRegistry(), ...customSpecs];
1910
2070
  const specsToRun = allRequested ? allSpecs : allSpecs.filter((s) => s.name === requestedName);
1911
2071
  if (!allRequested && specsToRun.length === 0) {
@@ -1935,6 +2095,7 @@ async function checkCommand(args) {
1935
2095
  process.stdout.write(` ${e.severity}: ${e.message}
1936
2096
  `);
1937
2097
  }
2098
+ printClassificationWarnings(configChecks);
1938
2099
  }
1939
2100
  const failed = allOutcomes.some((o) => o.status === "fail" || o.status === "error" || o.status === "timeout");
1940
2101
  process.exit(failed ? 1 : 0);
@@ -2149,15 +2310,17 @@ async function demoBreakCommand(args) {
2149
2310
  }
2150
2311
 
2151
2312
  // src/commands/doctor.ts
2152
- import { existsSync as existsSync8, readFileSync as readFileSync5, statSync } from "node:fs";
2313
+ import { existsSync as existsSync8, readFileSync as readFileSync6, statSync } from "node:fs";
2153
2314
  import { join as join8 } from "node:path";
2154
2315
 
2155
2316
  // src/lib/artifacts.ts
2317
+ import { readFileSync as readFileSync3 } from "node:fs";
2156
2318
  import { join as join6 } from "node:path";
2157
2319
  var CONFIG_PATH = "vinaya.config.json";
2158
2320
  var DOCTRINE_POINTER_PATH = "VINAYA.md";
2159
2321
  var CHECKS_WORKFLOW_PATH = ".github/workflows/vinaya-checks.yml";
2160
2322
  var REVIEW_WORKFLOW_PATH = ".github/workflows/vinaya-review.yml";
2323
+ var REVIEW_VERDICT_WORKFLOW_PATH = ".github/workflows/vinaya-review-verdict.yml";
2161
2324
  var ARCHIVIST_WORKFLOW_PATH = ".github/workflows/vinaya-archivist.yml";
2162
2325
  var MANAGED_NOTE = "Managed by Vinaya — created by `vinaya init`. `vinaya upgrade` regenerates it; `vinaya eject` removes it.";
2163
2326
  function starterConfig() {
@@ -2197,6 +2360,8 @@ jobs:
2197
2360
  runs-on: ubuntu-latest
2198
2361
  permissions:
2199
2362
  contents: read
2363
+ pull-requests: read
2364
+ issues: read
2200
2365
  steps:
2201
2366
  - uses: actions/checkout@v4
2202
2367
  with:
@@ -2205,36 +2370,94 @@ jobs:
2205
2370
  with:
2206
2371
  node-version: 20
2207
2372
  - name: Run checks
2373
+ env:
2374
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2375
+ PR_NUMBER: \${{ github.event.pull_request.number }}
2376
+ BRANCH: \${{ github.head_ref }}
2208
2377
  run: npx --yes @attalabs/vinaya check --all --diff-only
2209
2378
  `;
2210
2379
  }
2211
2380
  function reviewWorkflow() {
2212
2381
  return `# ${MANAGED_NOTE}
2213
2382
  #
2214
- # The review gate — split from the checks suite so a verdict *comment*
2215
- # re-triggers it. GitHub fires \`issue_comment\` for a new PR comment, a
2216
- # different event from \`pull_request\`; the checks workflow (pull_request only)
2217
- # structurally cannot receive it. A cheap \`contains(..., 'VERDICT')\` guard
2218
- # runs before any checkout cost, so ordinary PR chat spends no billed minute.
2383
+ # The required review gate — pull_request events only. The verdict-comment
2384
+ # half lives in its own workflow (vinaya-review-verdict.yml): a new PR
2385
+ # comment fires a different GitHub event that this pull_request-only
2386
+ # workflow structurally cannot receive and keeping the comment path in a
2387
+ # separate FILE means this workflow's runs never list permanently-skipped
2388
+ # comment jobs on the PR's checks panel. When a clean final verdict lands,
2389
+ # the verdict workflow re-runs this one, so the required check below goes
2390
+ # green natively with no manual rerun.
2219
2391
  name: Vinaya Review Gate
2220
2392
 
2221
2393
  on:
2222
2394
  pull_request:
2223
2395
  types: [opened, synchronize, reopened, labeled, unlabeled]
2224
- issue_comment:
2225
- types: [created]
2226
2396
 
2227
2397
  jobs:
2228
2398
  vinaya-review:
2229
2399
  name: vinaya review gate
2230
- if: >
2231
- github.event_name == 'pull_request' ||
2232
- (github.event.issue.pull_request != null && contains(github.event.comment.body, 'VERDICT'))
2233
2400
  runs-on: ubuntu-latest
2234
2401
  permissions:
2235
2402
  contents: read
2236
2403
  pull-requests: read
2237
2404
  issues: read
2405
+ steps:
2406
+ - uses: actions/checkout@v4
2407
+ with:
2408
+ # This job executes repo content; don't leave the token in
2409
+ # .git/config for scripts to read.
2410
+ persist-credentials: false
2411
+ fetch-depth: 0
2412
+ - uses: actions/setup-node@v4
2413
+ with:
2414
+ node-version: 20
2415
+ - name: Review gate
2416
+ env:
2417
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2418
+ # PR_NUMBER is what makes the review-gate check EVALUATE: without
2419
+ # it the adapter reads "no PR yet — local dev" and exits 0, and
2420
+ # the gate is green regardless of review state.
2421
+ PR_NUMBER: \${{ github.event.pull_request.number }}
2422
+ BRANCH: \${{ github.head_ref }}
2423
+ run: npx --yes @attalabs/vinaya check --all
2424
+ `;
2425
+ }
2426
+ function reviewVerdictWorkflow() {
2427
+ return `# ${MANAGED_NOTE}
2428
+ #
2429
+ # The verdict-comment half of the review gate. A reviewer's verdict arrives
2430
+ # as a PR comment (\`VERDICT: APPROVE\` / \`VERDICT: PASS\`), which fires
2431
+ # GitHub's \`issue_comment\` event — an event the required pull_request
2432
+ # workflow cannot receive. This workflow evaluates the gate on that comment
2433
+ # and, when the evaluation is clean, RE-RUNS the required workflow so its
2434
+ # check goes green natively with no manual rerun. (Writing check-run
2435
+ # conclusions directly is no longer possible: GitHub's 2025-02-12 change
2436
+ # restricts check-run updates to the owning workflow — re-running is the
2437
+ # supported channel.)
2438
+ #
2439
+ # Privilege split, deliberate: \`evaluate\` checks out and executes repo
2440
+ # content and therefore holds NO write permission; \`retrigger\` holds
2441
+ # \`actions: write\` but checks out and executes nothing — its only inputs
2442
+ # are the evaluator's outputs, resolved via \`gh pr view\` before any repo
2443
+ # content ran. A malicious branch can at worst fail its own evaluation.
2444
+ name: Vinaya Review Gate (on verdict)
2445
+
2446
+ on:
2447
+ issue_comment:
2448
+ types: [created]
2449
+
2450
+ jobs:
2451
+ evaluate:
2452
+ name: vinaya review gate (verdict check)
2453
+ if: github.event.issue.pull_request != null && contains(github.event.comment.body, 'VERDICT')
2454
+ runs-on: ubuntu-latest
2455
+ permissions:
2456
+ contents: read
2457
+ pull-requests: read
2458
+ issues: read
2459
+ outputs:
2460
+ branch: \${{ steps.pr.outputs.branch }}
2238
2461
  steps:
2239
2462
  # issue_comment payloads carry no PR head SHA/branch — resolve them
2240
2463
  # before checkout, and check out that exact commit (the event's default
@@ -2244,24 +2467,54 @@ jobs:
2244
2467
  env:
2245
2468
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2246
2469
  run: |
2247
- if [ "\${{ github.event_name }}" = "pull_request" ]; then
2248
- NUMBER="\${{ github.event.pull_request.number }}"
2249
- else
2250
- NUMBER="\${{ github.event.issue.number }}"
2251
- fi
2470
+ NUMBER="\${{ github.event.issue.number }}"
2471
+ BRANCH=$(gh pr view "$NUMBER" --repo "\${{ github.repository }}" --json headRefName -q .headRefName)
2252
2472
  SHA=$(gh pr view "$NUMBER" --repo "\${{ github.repository }}" --json headRefOid -q .headRefOid)
2473
+ echo "number=$NUMBER" >> "$GITHUB_OUTPUT"
2474
+ echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
2253
2475
  echo "sha=$SHA" >> "$GITHUB_OUTPUT"
2254
2476
  - uses: actions/checkout@v4
2255
2477
  with:
2256
2478
  ref: \${{ steps.pr.outputs.sha }}
2479
+ persist-credentials: false
2257
2480
  fetch-depth: 0
2258
2481
  - uses: actions/setup-node@v4
2259
2482
  with:
2260
2483
  node-version: 20
2261
- - name: Review gate
2484
+ - name: Review gate (verdict evaluation)
2262
2485
  env:
2263
2486
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2264
- run: npx --yes @attalabs/vinaya check --all
2487
+ # Same wiring as the required workflow: PR_NUMBER is what makes
2488
+ # the adapter evaluate instead of no-op'ing as "local dev".
2489
+ PR_NUMBER: \${{ steps.pr.outputs.number }}
2490
+ BRANCH: \${{ steps.pr.outputs.branch }}
2491
+ run: npx --yes @attalabs/vinaya check review-gate
2492
+
2493
+ # Executes nothing; consumes only the evaluator's outputs. Fires only on a
2494
+ # clean evaluation — a failed one leaves the standing red untouched.
2495
+ retrigger:
2496
+ name: vinaya review gate (retrigger)
2497
+ if: needs.evaluate.result == 'success'
2498
+ needs: evaluate
2499
+ runs-on: ubuntu-latest
2500
+ permissions:
2501
+ actions: write
2502
+ steps:
2503
+ - name: Re-run the required review gate for this branch
2504
+ env:
2505
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2506
+ BRANCH: \${{ needs.evaluate.outputs.branch }}
2507
+ run: |
2508
+ RUN_ID=$(gh run list --repo "\${{ github.repository }}" \\
2509
+ --workflow vinaya-review.yml --branch "$BRANCH" \\
2510
+ --status completed \\
2511
+ --json databaseId,event \\
2512
+ --jq '[.[] | select(.event=="pull_request")][0].databaseId // empty')
2513
+ if [ -z "$RUN_ID" ]; then
2514
+ echo "No completed pull_request run of vinaya-review.yml for branch $BRANCH - nothing to re-run."
2515
+ exit 0
2516
+ fi
2517
+ gh run rerun "$RUN_ID" --repo "\${{ github.repository }}"
2265
2518
  `;
2266
2519
  }
2267
2520
  function archivistWorkflow() {
@@ -2345,14 +2598,18 @@ jobs:
2345
2598
  }
2346
2599
  var HOOK_PREAMBLE = `#!/usr/bin/env sh
2347
2600
  `;
2601
+ function ownVersion() {
2602
+ const pkg = JSON.parse(readFileSync3(join6(packageRoot(import.meta.url), "package.json"), "utf-8"));
2603
+ return pkg.version;
2604
+ }
2348
2605
  function preCommitBody() {
2349
2606
  return `# Vinaya commit-time gate. Runs the deterministic checks over your staged
2350
2607
  # diff before the commit lands.
2351
- npx --no-install @attalabs/vinaya check --all --diff-only || exit 1`;
2608
+ npx --yes @attalabs/vinaya@${ownVersion()} check --all --diff-only || exit 1`;
2352
2609
  }
2353
2610
  function prePushBody() {
2354
2611
  return `# Vinaya pre-push gate. Runs branch/dispatch checks before the push leaves.
2355
- npx --no-install @attalabs/vinaya check --all || exit 1`;
2612
+ npx --yes @attalabs/vinaya@${ownVersion()} check --all || exit 1`;
2356
2613
  }
2357
2614
  function doctrinePointer() {
2358
2615
  const doctrineRoot = join6(packageRoot(import.meta.url), "aeg-root");
@@ -2461,6 +2718,12 @@ function buildInitOps(ctx) {
2461
2718
  const hookMode = 493;
2462
2719
  ops.push({ kind: "create-file", path: CHECKS_WORKFLOW_PATH, content: checksWorkflow(), group: "CI workflows" });
2463
2720
  ops.push({ kind: "create-file", path: REVIEW_WORKFLOW_PATH, content: reviewWorkflow(), group: "CI workflows" });
2721
+ ops.push({
2722
+ kind: "create-file",
2723
+ path: REVIEW_VERDICT_WORKFLOW_PATH,
2724
+ content: reviewVerdictWorkflow(),
2725
+ group: "CI workflows"
2726
+ });
2464
2727
  ops.push({ kind: "create-file", path: ARCHIVIST_WORKFLOW_PATH, content: archivistWorkflow(), group: "CI workflows" });
2465
2728
  ops.push({
2466
2729
  kind: "managed-block",
@@ -2519,7 +2782,7 @@ function buildInitProductOps(name) {
2519
2782
  }
2520
2783
 
2521
2784
  // src/lib/env-lint.ts
2522
- import { existsSync as existsSync6, readFileSync as readFileSync3 } from "node:fs";
2785
+ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:fs";
2523
2786
  var ENV_READ_PATTERN = /\b(?:process\.env|Bun\.env|Deno\.env)\b/;
2524
2787
  function checksMissingEnvDeclaration(specs) {
2525
2788
  const names = [];
@@ -2530,7 +2793,7 @@ function checksMissingEnvDeclaration(specs) {
2530
2793
  continue;
2531
2794
  let source;
2532
2795
  try {
2533
- source = readFileSync3(spec.run, "utf8");
2796
+ source = readFileSync4(spec.run, "utf8");
2534
2797
  } catch {
2535
2798
  continue;
2536
2799
  }
@@ -2544,7 +2807,7 @@ function envDeclarationWarning(name) {
2544
2807
  }
2545
2808
 
2546
2809
  // src/lib/ops.ts
2547
- import { chmodSync, existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
2810
+ import { chmodSync, existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
2548
2811
  import { dirname as dirname3, join as join7, resolve, sep } from "node:path";
2549
2812
  var MARKER_NS = "vinaya:managed";
2550
2813
  function markerLines(marker, comment) {
@@ -2574,7 +2837,7 @@ function fileContains(repoRoot, relPath, needle) {
2574
2837
  const p = abs(repoRoot, relPath);
2575
2838
  if (!existsSync7(p))
2576
2839
  return false;
2577
- return readFileSync4(p, "utf-8").includes(needle);
2840
+ return readFileSync5(p, "utf-8").includes(needle);
2578
2841
  }
2579
2842
  function planInstall(ops, repoRoot, ownedFiles = new Set) {
2580
2843
  const entries = [];
@@ -2684,7 +2947,7 @@ function writeFileWithDirs(target, content, mode) {
2684
2947
  }
2685
2948
  function appendBlock(repoRoot, op) {
2686
2949
  const target = abs(repoRoot, op.path);
2687
- const existing = readFileSync4(target, "utf-8");
2950
+ const existing = readFileSync5(target, "utf-8");
2688
2951
  const sep2 = existing.endsWith(`
2689
2952
  `) ? `
2690
2953
  ` : `
@@ -2806,7 +3069,7 @@ function planEject(manifest, repoRoot) {
2806
3069
  });
2807
3070
  continue;
2808
3071
  }
2809
- const content = readFileSync4(p, "utf-8");
3072
+ const content = readFileSync5(p, "utf-8");
2810
3073
  const stripped = stripBlockFromContent(content, b.marker, b.comment);
2811
3074
  const removesHost = stripped !== null && blockStripLeavesEmpty(stripped);
2812
3075
  actions.push({
@@ -2858,7 +3121,7 @@ function applyEject(plan, repoRoot) {
2858
3121
  const p = containedAbs(repoRoot, a.path);
2859
3122
  if (p === null)
2860
3123
  continue;
2861
- const content = readFileSync4(p, "utf-8");
3124
+ const content = readFileSync5(p, "utf-8");
2862
3125
  const stripped = stripBlockFromContent(content, a.marker, a.comment);
2863
3126
  if (stripped === null)
2864
3127
  continue;
@@ -2881,7 +3144,7 @@ function applyEject(plan, repoRoot) {
2881
3144
 
2882
3145
  // src/commands/doctor.ts
2883
3146
  function readVersion() {
2884
- const pkg = JSON.parse(readFileSync5(join8(packageRoot(import.meta.url), "package.json"), "utf-8"));
3147
+ const pkg = JSON.parse(readFileSync6(join8(packageRoot(import.meta.url), "package.json"), "utf-8"));
2885
3148
  return pkg.version;
2886
3149
  }
2887
3150
  function realDeps3() {
@@ -2905,7 +3168,7 @@ function readConfig(repoRoot) {
2905
3168
  return { kind: "missing" };
2906
3169
  let raw;
2907
3170
  try {
2908
- raw = JSON.parse(readFileSync5(p, "utf-8"));
3171
+ raw = JSON.parse(readFileSync6(p, "utf-8"));
2909
3172
  } catch (err) {
2910
3173
  return { kind: "invalid", error: `invalid JSON: ${err.message}` };
2911
3174
  }
@@ -2941,7 +3204,7 @@ function diagnoseInstall(repoRoot, ctx, manifest) {
2941
3204
  findings.push(owned ? error(check, `${op.path} is recorded as vinaya-managed but missing on disk — run \`vinaya upgrade\`.`) : error(check, `${op.path} is not installed — run \`vinaya init\`.`));
2942
3205
  continue;
2943
3206
  }
2944
- const content = readFileSync5(abs2, "utf-8");
3207
+ const content = readFileSync6(abs2, "utf-8");
2945
3208
  if (!owned) {
2946
3209
  findings.push(content === op.content ? warn(check, `${op.path} has vinaya's own content but isn't recorded in the manifest.`) : info(check, `${op.path} exists but is foreign content — not vinaya-managed, left untouched.`));
2947
3210
  continue;
@@ -2964,7 +3227,7 @@ function diagnoseInstall(repoRoot, ctx, manifest) {
2964
3227
  findings.push(owned ? error(check, `${op.path} is missing — likely a fresh clone (raw git hooks aren't tracked by git). Run \`vinaya upgrade\` to restore it.`) : info(check, `${op.path} is not installed.`));
2965
3228
  continue;
2966
3229
  }
2967
- const content = readFileSync5(abs2, "utf-8");
3230
+ const content = readFileSync6(abs2, "utf-8");
2968
3231
  const { begin, end } = markerLines(op.marker, op.comment);
2969
3232
  const hasMarkers = content.includes(begin) && content.includes(end);
2970
3233
  if (!hasMarkers) {
@@ -3007,6 +3270,34 @@ function diagnoseEnvDeclarations(repoRoot, config) {
3007
3270
  }
3008
3271
  return findings;
3009
3272
  }
3273
+ function diagnoseCheckClassification(config) {
3274
+ const classification = resolveChecks(coreCheckRegistry(), config?.checks);
3275
+ const findings = [];
3276
+ for (const entry2 of classification.resolved) {
3277
+ if (entry2.state === "overridden")
3278
+ findings.push(warn("checks", overriddenNextMinorWarning(entry2.name)));
3279
+ }
3280
+ for (const failure of classification.failures) {
3281
+ findings.push(warn("checks", bareKeyNextMinorWarning(failure.key)));
3282
+ }
3283
+ return findings;
3284
+ }
3285
+ function diagnoseGlobalConfigChecks() {
3286
+ if (!existsSync8(GLOBAL_CONFIG_PATH))
3287
+ return [];
3288
+ let raw;
3289
+ try {
3290
+ raw = JSON.parse(readFileSync6(GLOBAL_CONFIG_PATH, "utf-8"));
3291
+ } catch (err) {
3292
+ return [info("checks", `${GLOBAL_CONFIG_PATH} is invalid JSON — ${err.message}`)];
3293
+ }
3294
+ const parsed = VinayaConfigSchema.safeParse(raw);
3295
+ if (!parsed.success)
3296
+ return [];
3297
+ if (!parsed.data.checks || Object.keys(parsed.data.checks).length === 0)
3298
+ return [];
3299
+ return [warn("checks", globalChecksIgnoredWarning(GLOBAL_CONFIG_PATH))];
3300
+ }
3010
3301
  async function diagnoseEnvironment(deps, hasDrift) {
3011
3302
  const findings = [];
3012
3303
  const auth = await deps.ghAuthStatus();
@@ -3077,6 +3368,8 @@ async function runDoctor(args, deps) {
3077
3368
  findings.push(...diagnoseCustomChecks(repo.repoRoot, configRead.config));
3078
3369
  }
3079
3370
  findings.push(...diagnoseEnvDeclarations(repo.repoRoot, configRead.kind === "ok" ? configRead.config : null));
3371
+ findings.push(...diagnoseCheckClassification(configRead.kind === "ok" ? configRead.config : null));
3372
+ findings.push(...diagnoseGlobalConfigChecks());
3080
3373
  findings.push(...await diagnoseEnvironment(deps, hasDrift));
3081
3374
  findings.push(await diagnoseBranchProtection(deps, repo.owner, repo.repo));
3082
3375
  const healthy = findings.every((f) => f.severity === "ok" || f.severity === "info");
@@ -3092,7 +3385,7 @@ async function doctorCommand(args) {
3092
3385
  }
3093
3386
 
3094
3387
  // src/commands/eject.ts
3095
- import { existsSync as existsSync9, readFileSync as readFileSync6 } from "node:fs";
3388
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
3096
3389
  import { join as join9 } from "node:path";
3097
3390
 
3098
3391
  // src/lib/prompt.ts
@@ -3179,7 +3472,7 @@ function readManifest(repoRoot) {
3179
3472
  return { kind: "none" };
3180
3473
  let raw;
3181
3474
  try {
3182
- raw = JSON.parse(readFileSync6(p, "utf-8"));
3475
+ raw = JSON.parse(readFileSync7(p, "utf-8"));
3183
3476
  } catch (err) {
3184
3477
  return { kind: "orphan", reason: `vinaya.config.json is not valid JSON: ${err.message}` };
3185
3478
  }
@@ -3262,7 +3555,7 @@ async function ejectCommand(args) {
3262
3555
  }
3263
3556
 
3264
3557
  // src/commands/init.ts
3265
- import { existsSync as existsSync10, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
3558
+ import { existsSync as existsSync10, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "node:fs";
3266
3559
  import { join as join10 } from "node:path";
3267
3560
  function realDeps5() {
3268
3561
  return {
@@ -3287,14 +3580,14 @@ function readManifest2(repoRoot) {
3287
3580
  if (!existsSync10(p))
3288
3581
  return null;
3289
3582
  try {
3290
- return VinayaConfigSchema.parse(JSON.parse(readFileSync7(p, "utf-8"))).managed ?? null;
3583
+ return VinayaConfigSchema.parse(JSON.parse(readFileSync8(p, "utf-8"))).managed ?? null;
3291
3584
  } catch {
3292
3585
  return null;
3293
3586
  }
3294
3587
  }
3295
3588
  function writeManifest(repoRoot, manifest) {
3296
3589
  const configAbs = join10(repoRoot, CONFIG_PATH);
3297
- const seed = JSON.parse(readFileSync7(configAbs, "utf-8"));
3590
+ const seed = JSON.parse(readFileSync8(configAbs, "utf-8"));
3298
3591
  writeFileSync4(configAbs, `${JSON.stringify({ ...seed, managed: manifest }, null, 2)}
3299
3592
  `, "utf-8");
3300
3593
  }
@@ -3434,7 +3727,7 @@ import { execFileSync as execFileSync6 } from "node:child_process";
3434
3727
 
3435
3728
  // src/lib/forge-write.ts
3436
3729
  import { mkdtempSync, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "node:fs";
3437
- import { readFileSync as readFileSync8 } from "node:fs";
3730
+ import { readFileSync as readFileSync9 } from "node:fs";
3438
3731
  import { tmpdir } from "node:os";
3439
3732
  import { join as join11 } from "node:path";
3440
3733
  class ForgeArgError extends Error {
@@ -3446,13 +3739,13 @@ function locateBody(args) {
3446
3739
  const p = args[i + 1];
3447
3740
  if (!p)
3448
3741
  throw new ForgeArgError("`--body-file` was given with no path.");
3449
- return { body: readFileSync8(p, "utf8"), source: { kind: "file", argIndex: i + 1, inlineForm: false } };
3742
+ return { body: readFileSync9(p, "utf8"), source: { kind: "file", argIndex: i + 1, inlineForm: false } };
3450
3743
  }
3451
3744
  if (a.startsWith("--body-file=")) {
3452
3745
  const p = a.slice("--body-file=".length);
3453
3746
  if (!p)
3454
3747
  throw new ForgeArgError("`--body-file=` was given with no path.");
3455
- return { body: readFileSync8(p, "utf8"), source: { kind: "file", argIndex: i, inlineForm: true } };
3748
+ return { body: readFileSync9(p, "utf8"), source: { kind: "file", argIndex: i, inlineForm: true } };
3456
3749
  }
3457
3750
  if (a === "--body" || a === "-b") {
3458
3751
  const v = args[i + 1];
@@ -3742,7 +4035,7 @@ function issueEditCommand(args) {
3742
4035
  }
3743
4036
 
3744
4037
  // src/commands/new-check.ts
3745
- import { chmodSync as chmodSync2, existsSync as existsSync11, mkdirSync as mkdirSync3, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "node:fs";
4038
+ import { chmodSync as chmodSync2, existsSync as existsSync11, mkdirSync as mkdirSync3, readFileSync as readFileSync10, writeFileSync as writeFileSync6 } from "node:fs";
3746
4039
  import { join as join12 } from "node:path";
3747
4040
  var TEMPLATE_PATH = join12(packageRoot(import.meta.url), "templates", "custom-check.template.ts");
3748
4041
  var CHECKS_DIR = join12("scripts", "vinaya-checks");
@@ -3761,7 +4054,7 @@ function newCheckCommand(args) {
3761
4054
  console.error(`Error: ${targetPath} already exists.`);
3762
4055
  process.exit(1);
3763
4056
  }
3764
- const template = readFileSync9(TEMPLATE_PATH, "utf-8");
4057
+ const template = readFileSync10(TEMPLATE_PATH, "utf-8");
3765
4058
  const contents = template.split("{{CHECK_NAME}}").join(name);
3766
4059
  writeFileSync6(targetPath, contents, "utf-8");
3767
4060
  chmodSync2(targetPath, 493);
@@ -3925,7 +4218,7 @@ function prEditCommand(args) {
3925
4218
 
3926
4219
  // src/commands/studio.ts
3927
4220
  import { spawn as spawn2 } from "node:child_process";
3928
- import { existsSync as existsSync12, readFileSync as readFileSync10 } from "node:fs";
4221
+ import { existsSync as existsSync12, readFileSync as readFileSync11 } from "node:fs";
3929
4222
  import { dirname as dirname4, join as join13 } from "node:path";
3930
4223
  function resolveStudioTarget(cwd) {
3931
4224
  let dir = cwd;
@@ -3934,7 +4227,7 @@ function resolveStudioTarget(cwd) {
3934
4227
  const pkgPath = join13(webDir, "package.json");
3935
4228
  if (existsSync12(pkgPath)) {
3936
4229
  try {
3937
- const pkg = JSON.parse(readFileSync10(pkgPath, "utf-8"));
4230
+ const pkg = JSON.parse(readFileSync11(pkgPath, "utf-8"));
3938
4231
  if (pkg.name === "@atta/vinaya-web") {
3939
4232
  return { kind: "workspace", webDir };
3940
4233
  }
@@ -3967,7 +4260,7 @@ async function runStudio(cwd, args) {
3967
4260
  }
3968
4261
 
3969
4262
  // src/commands/upgrade.ts
3970
- import { existsSync as existsSync13, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "node:fs";
4263
+ import { existsSync as existsSync13, readFileSync as readFileSync12, writeFileSync as writeFileSync7 } from "node:fs";
3971
4264
  import { join as join14 } from "node:path";
3972
4265
  function realDeps6() {
3973
4266
  return {
@@ -3989,7 +4282,7 @@ function readManifest3(repoRoot) {
3989
4282
  return { kind: "missing" };
3990
4283
  let raw;
3991
4284
  try {
3992
- raw = JSON.parse(readFileSync11(p, "utf-8"));
4285
+ raw = JSON.parse(readFileSync12(p, "utf-8"));
3993
4286
  } catch (err) {
3994
4287
  return { kind: "invalid", error: `invalid JSON: ${err.message}` };
3995
4288
  }
@@ -4006,7 +4299,7 @@ function readManifest3(repoRoot) {
4006
4299
  }
4007
4300
  function writeManifestVersion(repoRoot, manifest) {
4008
4301
  const configAbs = join14(repoRoot, CONFIG_PATH);
4009
- const seed = JSON.parse(readFileSync11(configAbs, "utf-8"));
4302
+ const seed = JSON.parse(readFileSync12(configAbs, "utf-8"));
4010
4303
  const updated = { ...manifest, version: MANAGED_MANIFEST_VERSION };
4011
4304
  writeFileSync7(configAbs, `${JSON.stringify({ ...seed, managed: updated }, null, 2)}
4012
4305
  `, "utf-8");
@@ -4030,7 +4323,7 @@ function planUpgrade(ops, repoRoot, manifest) {
4030
4323
  } else if (!exists) {
4031
4324
  action = "recreate";
4032
4325
  hasChanges = true;
4033
- } else if (readFileSync11(abs2, "utf-8") !== op.content) {
4326
+ } else if (readFileSync12(abs2, "utf-8") !== op.content) {
4034
4327
  action = "regenerate";
4035
4328
  hasChanges = true;
4036
4329
  } else {
@@ -4047,7 +4340,7 @@ function planUpgrade(ops, repoRoot, manifest) {
4047
4340
  action = "recreate-host";
4048
4341
  hasChanges = true;
4049
4342
  } else {
4050
- const content = readFileSync11(abs2, "utf-8");
4343
+ const content = readFileSync12(abs2, "utf-8");
4051
4344
  const { begin, end } = markerLines(op.marker, op.comment);
4052
4345
  if (!(content.includes(begin) && content.includes(end))) {
4053
4346
  action = "recreate-append";
@@ -4124,7 +4417,7 @@ function renderUpgradeDiff(plan) {
4124
4417
  }
4125
4418
  function regenerateBlock(repoRoot, op) {
4126
4419
  const abs2 = join14(repoRoot, op.path);
4127
- const content = readFileSync11(abs2, "utf-8");
4420
+ const content = readFileSync12(abs2, "utf-8");
4128
4421
  const stripped = stripBlockFromContent(content, op.marker, op.comment);
4129
4422
  if (stripped !== null) {
4130
4423
  writeFileSync7(abs2, stripped.endsWith(`
@@ -4363,10 +4656,15 @@ var COMMANDS = [
4363
4656
  { flag: "--all", description: "Run every registered check instead of one named check" },
4364
4657
  { flag: "--json", description: "Enveloped JSON output (schema: 1)" },
4365
4658
  { flag: "--diff-only", description: "Scope diff-declared checks to changed files" },
4366
- { flag: "--parallel[=n]", description: "Concurrency cap (default: cpu-derived)" }
4659
+ { flag: "--parallel[=n]", description: "Concurrency cap (default: cpu-derived)" },
4660
+ {
4661
+ flag: "--plan",
4662
+ description: "Print the resolved check registry (default/overridden/additive) without running anything"
4663
+ }
4367
4664
  ],
4368
4665
  details: [
4369
- "Each spawned check's child process sees only a fixed baseline (`PATH`, `LANG`, `HOME`, `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `TMPDIR`) plus whatever its `CheckSpec['env']` declaration explicitly forwards — never the full parent environment. A required (`true`) or unsatisfied `anyOf` declaration missing from the caller's environment synthesizes a `CheckError` before the check ever spawns. Declare `env` (a core check's own registration, or `vinaya.config.json`'s `checks.<name>.env` for a custom one) for any check that reads `process.env`/`Bun.env`/`Deno.env` directly — `vinaya doctor` carries the permanent diagnostic for one that doesn't."
4666
+ "Each spawned check's child process sees only a fixed baseline (`PATH`, `LANG`, `HOME`, `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `TMPDIR`) plus whatever its `CheckSpec['env']` declaration explicitly forwards — never the full parent environment. A required (`true`) or unsatisfied `anyOf` declaration missing from the caller's environment synthesizes a `CheckError` before the check ever spawns. Declare `env` (a core check's own registration, or `vinaya.config.json`'s `checks.<name>.env` for a custom one) for any check that reads `process.env`/`Bun.env`/`Deno.env` directly — `vinaya doctor` carries the permanent diagnostic for one that doesn't.",
4667
+ "`--plan` composes with `--json`. It requires zero env vars and never prints an env value — only how each one resolves (passthrough, optional, literal, or anyOf). A `FAIL_CLOSED` entry (a bare key with no namespace matching no core check) always renders inline rather than being dropped, and exits non-zero. `--plan` previews what the next minor's execution will enforce — `vinaya check` itself still runs the flat, unvalidated registry this release."
4370
4668
  ],
4371
4669
  status: "shipped"
4372
4670
  },
@@ -4544,7 +4842,7 @@ function printHelp() {
4544
4842
  // src/index.ts
4545
4843
  var PACKAGE_ROOT = join15(dirname5(fileURLToPath2(import.meta.url)), "..");
4546
4844
  function readVersion2() {
4547
- const pkg = JSON.parse(readFileSync12(join15(PACKAGE_ROOT, "package.json"), "utf-8"));
4845
+ const pkg = JSON.parse(readFileSync13(join15(PACKAGE_ROOT, "package.json"), "utf-8"));
4548
4846
  return pkg.version;
4549
4847
  }
4550
4848
  var [, , command, ...args] = process.argv;