@attalabs/vinaya 0.2.0 → 0.4.0

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
@@ -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";
@@ -1470,11 +1534,79 @@ function installSignalForwarding() {
1470
1534
  process.on("SIGINT", () => forward("SIGINT", 130));
1471
1535
  process.on("SIGTERM", () => forward("SIGTERM", 143));
1472
1536
  }
1473
- async function runOne(spec, timeoutMs) {
1537
+ var ENV_BASELINE_KEYS = ["PATH", "LANG", "HOME", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "TMPDIR"];
1538
+ function buildCheckEnv(env, callerEnv = process.env) {
1539
+ const out = {};
1540
+ for (const key of ENV_BASELINE_KEYS) {
1541
+ const value = callerEnv[key];
1542
+ if (value !== undefined)
1543
+ out[key] = value;
1544
+ }
1545
+ if (!env)
1546
+ return out;
1547
+ for (const [key, decl] of Object.entries(env)) {
1548
+ if (decl === true || typeof decl === "object" && "optional" in decl) {
1549
+ const value = callerEnv[key];
1550
+ if (value !== undefined)
1551
+ out[key] = value;
1552
+ } else if (typeof decl === "object" && "anyOf" in decl) {
1553
+ for (const member of decl.anyOf) {
1554
+ const value = callerEnv[member];
1555
+ if (value !== undefined)
1556
+ out[member] = value;
1557
+ }
1558
+ } else if (typeof decl === "string") {
1559
+ out[key] = decl;
1560
+ }
1561
+ }
1562
+ return out;
1563
+ }
1564
+ function missingEnvErrors(spec, callerEnv) {
1565
+ if (!spec.env)
1566
+ return [];
1567
+ const errors = [];
1568
+ for (const [key, decl] of Object.entries(spec.env)) {
1569
+ if (decl === true) {
1570
+ if (callerEnv[key] === undefined) {
1571
+ errors.push({
1572
+ schema: CHECK_SCHEMA_VERSION,
1573
+ check: spec.name,
1574
+ severity: "error",
1575
+ message: `Could not run check "${spec.name}": required environment variable \`${key}\` is not set.`,
1576
+ agent_recovery_prompt: `Set \`${key}\` in the environment before re-running \`vinaya check ${spec.name}\`, or relax its declaration to \`{ optional: true }\` in its \`env\` registration if the check can tolerate absence.`
1577
+ });
1578
+ }
1579
+ } else if (typeof decl === "object" && decl !== null && "anyOf" in decl) {
1580
+ const satisfied = decl.anyOf.some((member) => callerEnv[member] !== undefined);
1581
+ if (!satisfied) {
1582
+ errors.push({
1583
+ schema: CHECK_SCHEMA_VERSION,
1584
+ check: spec.name,
1585
+ severity: "error",
1586
+ message: `Could not run check "${spec.name}": none of its required environment variables (${decl.anyOf.join(", ")}) are set.`,
1587
+ agent_recovery_prompt: `Set one of ${decl.anyOf.join(", ")} in the environment before re-running \`vinaya check ${spec.name}\`.`
1588
+ });
1589
+ }
1590
+ }
1591
+ }
1592
+ return errors;
1593
+ }
1594
+ async function runOne(spec, timeoutMs, callerEnv) {
1474
1595
  const start = performance.now();
1596
+ const envErrors = missingEnvErrors(spec, callerEnv);
1597
+ if (envErrors.length > 0) {
1598
+ return {
1599
+ name: spec.name,
1600
+ status: "error",
1601
+ exitCode: null,
1602
+ errors: envErrors,
1603
+ durationMs: performance.now() - start
1604
+ };
1605
+ }
1475
1606
  const proc = spawn(spec.run, spec.args ?? [], {
1476
1607
  stdio: ["ignore", "pipe", "pipe"],
1477
- detached: true
1608
+ detached: true,
1609
+ env: buildCheckEnv(spec.env, callerEnv)
1478
1610
  });
1479
1611
  function killTree(signal) {
1480
1612
  const pid = proc.pid;
@@ -1590,6 +1722,7 @@ async function runOne(spec, timeoutMs) {
1590
1722
  return { name: spec.name, status, exitCode, errors, durationMs };
1591
1723
  }
1592
1724
  async function runChecks(specs, opts) {
1725
+ const callerEnv = opts.callerEnv ?? process.env;
1593
1726
  const results = new Array(specs.length);
1594
1727
  const toRun = [];
1595
1728
  for (let i = 0;i < specs.length; i++) {
@@ -1606,7 +1739,7 @@ async function runChecks(specs, opts) {
1606
1739
  const idx = toRun[cursor];
1607
1740
  cursor += 1;
1608
1741
  const spec = specs[idx];
1609
- results[idx] = await runOne(spec, spec.timeoutMs ?? opts.defaultTimeoutMs);
1742
+ results[idx] = await runOne(spec, spec.timeoutMs ?? opts.defaultTimeoutMs, callerEnv);
1610
1743
  }
1611
1744
  };
1612
1745
  const workerCount = Math.max(1, Math.min(opts.parallel, toRun.length));
@@ -1763,6 +1896,15 @@ function configPath() {
1763
1896
  return GLOBAL_CONFIG_PATH;
1764
1897
  return null;
1765
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
+ }
1766
1908
  function loadConfigChecked() {
1767
1909
  const path = configPath();
1768
1910
  if (!path)
@@ -1778,32 +1920,7 @@ function loadConfigChecked() {
1778
1920
  const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
1779
1921
  return { ok: false, path, error: detail };
1780
1922
  }
1781
- return { ok: true, config: parsed.data };
1782
- }
1783
-
1784
- // src/lib/env-lint.ts
1785
- import { existsSync as existsSync5, readFileSync as readFileSync2 } from "node:fs";
1786
- var ENV_READ_PATTERN = /\b(?:process\.env|Bun\.env|Deno\.env)\b/;
1787
- function checksMissingEnvDeclaration(specs) {
1788
- const names = [];
1789
- for (const spec of specs) {
1790
- if (spec.env)
1791
- continue;
1792
- if (!existsSync5(spec.run))
1793
- continue;
1794
- let source;
1795
- try {
1796
- source = readFileSync2(spec.run, "utf8");
1797
- } catch {
1798
- continue;
1799
- }
1800
- if (ENV_READ_PATTERN.test(source))
1801
- names.push(spec.name);
1802
- }
1803
- return names;
1804
- }
1805
- function envDeclarationWarning(name) {
1806
- return `check "${name}" reads environment variables directly but declares no \`env\` — it will lose environment access at the next minor. Declare \`env\` for it (CheckSpec['env'] in src/checks/contract.ts, or vinaya.config.json's checks.<name>.env).`;
1923
+ return { ok: true, config: stripGlobalChecks(parsed.data, path) };
1807
1924
  }
1808
1925
 
1809
1926
  // src/commands/check.ts
@@ -1843,34 +1960,118 @@ function configErrorOutcome(path, error) {
1843
1960
  function customSpecsFromConfig() {
1844
1961
  const result = loadConfigChecked();
1845
1962
  if (!result.ok)
1846
- return { specs: [], errorOutcome: configErrorOutcome(result.path, result.error) };
1963
+ return { specs: [], errorOutcome: configErrorOutcome(result.path, result.error), checks: undefined };
1847
1964
  const checks = result.config?.checks;
1848
1965
  if (!checks)
1849
- return { specs: [], errorOutcome: null };
1966
+ return { specs: [], errorOutcome: null, checks: undefined };
1850
1967
  const specs = Object.entries(checks).map(([name, entry2]) => ({ name, ...entry2 }));
1851
- 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
+ }
1852
2044
  }
1853
2045
  async function checkCommand(args) {
1854
2046
  const jsonOutput = args.includes("--json");
1855
2047
  const diffOnly = args.includes("--diff-only");
1856
2048
  const requestedParallel = parseParallel(args);
1857
2049
  const allRequested = args.includes("--all");
2050
+ const planRequested = args.includes("--plan");
1858
2051
  const positional = args.filter((a) => !a.startsWith("--"));
1859
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
+ }
1860
2064
  if (!allRequested && !requestedName) {
1861
- 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]]");
1862
2066
  process.exit(2);
1863
2067
  }
1864
- const { specs: customSpecs, errorOutcome } = customSpecsFromConfig();
2068
+ const { specs: customSpecs, errorOutcome, checks: configChecks } = customSpecsFromConfig();
1865
2069
  const allSpecs = [...coreCheckRegistry(), ...customSpecs];
1866
2070
  const specsToRun = allRequested ? allSpecs : allSpecs.filter((s) => s.name === requestedName);
1867
2071
  if (!allRequested && specsToRun.length === 0) {
1868
2072
  console.error(`Unknown check: ${requestedName}`);
1869
2073
  process.exit(2);
1870
2074
  }
1871
- for (const name of checksMissingEnvDeclaration(specsToRun)) {
1872
- console.error(`⚠ ${envDeclarationWarning(name)}`);
1873
- }
1874
2075
  const changed = diffOnly ? changedFiles() : null;
1875
2076
  const outcomes = specsToRun.length > 0 ? await runChecks(specsToRun, {
1876
2077
  parallel: requestedParallel ?? defaultParallelism(),
@@ -1894,6 +2095,7 @@ async function checkCommand(args) {
1894
2095
  process.stdout.write(` ${e.severity}: ${e.message}
1895
2096
  `);
1896
2097
  }
2098
+ printClassificationWarnings(configChecks);
1897
2099
  }
1898
2100
  const failed = allOutcomes.some((o) => o.status === "fail" || o.status === "error" || o.status === "timeout");
1899
2101
  process.exit(failed ? 1 : 0);
@@ -1901,7 +2103,7 @@ async function checkCommand(args) {
1901
2103
 
1902
2104
  // src/commands/demo.ts
1903
2105
  import { execFileSync as execFileSync5, spawnSync } from "node:child_process";
1904
- import { existsSync as existsSync6, readFileSync as readFileSync3, rmSync, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
2106
+ import { existsSync as existsSync5, readFileSync as readFileSync2, rmSync, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
1905
2107
  import { isAbsolute, join as join5 } from "node:path";
1906
2108
  var DEMO_BRANCH_PREFIX = "vinaya/demo-break-";
1907
2109
  var FIXTURE_PATH = ".vinaya-demo-brief.md";
@@ -1944,9 +2146,9 @@ function gitDirAbs(repoRoot) {
1944
2146
  function recoverFromCrash(repoRoot, statePath) {
1945
2147
  const currentBranch = currentBranchName(repoRoot);
1946
2148
  let state = null;
1947
- if (existsSync6(statePath)) {
2149
+ if (existsSync5(statePath)) {
1948
2150
  try {
1949
- state = JSON.parse(readFileSync3(statePath, "utf-8"));
2151
+ state = JSON.parse(readFileSync2(statePath, "utf-8"));
1950
2152
  } catch {
1951
2153
  state = null;
1952
2154
  }
@@ -2016,14 +2218,14 @@ function cleanup(repoRoot, originalBranch, demoBranch, statePath) {
2016
2218
  gitCapture(repoRoot, ["clean", "-fd"]);
2017
2219
  gitCapture(repoRoot, ["checkout", originalBranch]);
2018
2220
  gitCapture(repoRoot, ["branch", "-D", demoBranch]);
2019
- if (existsSync6(statePath))
2221
+ if (existsSync5(statePath))
2020
2222
  rmSync(statePath, { force: true });
2021
2223
  }
2022
2224
  async function runDemoBreak(repoRoot, args) {
2023
2225
  const keep = args.includes("--keep");
2024
2226
  const hookDir = resolveHookDir(repoRoot);
2025
2227
  const hookPath = join5(repoRoot, hookDir, "pre-commit");
2026
- if (!existsSync6(hookPath)) {
2228
+ if (!existsSync5(hookPath)) {
2027
2229
  console.error("Vinaya hooks are not installed in this repo. Run `vinaya init` first.");
2028
2230
  return 1;
2029
2231
  }
@@ -2081,7 +2283,7 @@ Attempting to commit…
2081
2283
  process.stdout.write(`✓ Commit passed — the fix worked.
2082
2284
  `);
2083
2285
  if (keep) {
2084
- if (existsSync6(statePath))
2286
+ if (existsSync5(statePath))
2085
2287
  unlinkSync(statePath);
2086
2288
  process.stdout.write(`
2087
2289
  --keep: leaving \`${demoBranch}\` checked out for you to inspect. Clean up with:
@@ -2117,6 +2319,7 @@ var CONFIG_PATH = "vinaya.config.json";
2117
2319
  var DOCTRINE_POINTER_PATH = "VINAYA.md";
2118
2320
  var CHECKS_WORKFLOW_PATH = ".github/workflows/vinaya-checks.yml";
2119
2321
  var REVIEW_WORKFLOW_PATH = ".github/workflows/vinaya-review.yml";
2322
+ var REVIEW_VERDICT_WORKFLOW_PATH = ".github/workflows/vinaya-review-verdict.yml";
2120
2323
  var ARCHIVIST_WORKFLOW_PATH = ".github/workflows/vinaya-archivist.yml";
2121
2324
  var MANAGED_NOTE = "Managed by Vinaya — created by `vinaya init`. `vinaya upgrade` regenerates it; `vinaya eject` removes it.";
2122
2325
  function starterConfig() {
@@ -2156,6 +2359,8 @@ jobs:
2156
2359
  runs-on: ubuntu-latest
2157
2360
  permissions:
2158
2361
  contents: read
2362
+ pull-requests: read
2363
+ issues: read
2159
2364
  steps:
2160
2365
  - uses: actions/checkout@v4
2161
2366
  with:
@@ -2164,36 +2369,94 @@ jobs:
2164
2369
  with:
2165
2370
  node-version: 20
2166
2371
  - name: Run checks
2372
+ env:
2373
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2374
+ PR_NUMBER: \${{ github.event.pull_request.number }}
2375
+ BRANCH: \${{ github.head_ref }}
2167
2376
  run: npx --yes @attalabs/vinaya check --all --diff-only
2168
2377
  `;
2169
2378
  }
2170
2379
  function reviewWorkflow() {
2171
2380
  return `# ${MANAGED_NOTE}
2172
2381
  #
2173
- # The review gate — split from the checks suite so a verdict *comment*
2174
- # re-triggers it. GitHub fires \`issue_comment\` for a new PR comment, a
2175
- # different event from \`pull_request\`; the checks workflow (pull_request only)
2176
- # structurally cannot receive it. A cheap \`contains(..., 'VERDICT')\` guard
2177
- # runs before any checkout cost, so ordinary PR chat spends no billed minute.
2382
+ # The required review gate — pull_request events only. The verdict-comment
2383
+ # half lives in its own workflow (vinaya-review-verdict.yml): a new PR
2384
+ # comment fires a different GitHub event that this pull_request-only
2385
+ # workflow structurally cannot receive and keeping the comment path in a
2386
+ # separate FILE means this workflow's runs never list permanently-skipped
2387
+ # comment jobs on the PR's checks panel. When a clean final verdict lands,
2388
+ # the verdict workflow re-runs this one, so the required check below goes
2389
+ # green natively with no manual rerun.
2178
2390
  name: Vinaya Review Gate
2179
2391
 
2180
2392
  on:
2181
2393
  pull_request:
2182
2394
  types: [opened, synchronize, reopened, labeled, unlabeled]
2183
- issue_comment:
2184
- types: [created]
2185
2395
 
2186
2396
  jobs:
2187
2397
  vinaya-review:
2188
2398
  name: vinaya review gate
2189
- if: >
2190
- github.event_name == 'pull_request' ||
2191
- (github.event.issue.pull_request != null && contains(github.event.comment.body, 'VERDICT'))
2192
2399
  runs-on: ubuntu-latest
2193
2400
  permissions:
2194
2401
  contents: read
2195
2402
  pull-requests: read
2196
2403
  issues: read
2404
+ steps:
2405
+ - uses: actions/checkout@v4
2406
+ with:
2407
+ # This job executes repo content; don't leave the token in
2408
+ # .git/config for scripts to read.
2409
+ persist-credentials: false
2410
+ fetch-depth: 0
2411
+ - uses: actions/setup-node@v4
2412
+ with:
2413
+ node-version: 20
2414
+ - name: Review gate
2415
+ env:
2416
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2417
+ # PR_NUMBER is what makes the review-gate check EVALUATE: without
2418
+ # it the adapter reads "no PR yet — local dev" and exits 0, and
2419
+ # the gate is green regardless of review state.
2420
+ PR_NUMBER: \${{ github.event.pull_request.number }}
2421
+ BRANCH: \${{ github.head_ref }}
2422
+ run: npx --yes @attalabs/vinaya check --all
2423
+ `;
2424
+ }
2425
+ function reviewVerdictWorkflow() {
2426
+ return `# ${MANAGED_NOTE}
2427
+ #
2428
+ # The verdict-comment half of the review gate. A reviewer's verdict arrives
2429
+ # as a PR comment (\`VERDICT: APPROVE\` / \`VERDICT: PASS\`), which fires
2430
+ # GitHub's \`issue_comment\` event — an event the required pull_request
2431
+ # workflow cannot receive. This workflow evaluates the gate on that comment
2432
+ # and, when the evaluation is clean, RE-RUNS the required workflow so its
2433
+ # check goes green natively with no manual rerun. (Writing check-run
2434
+ # conclusions directly is no longer possible: GitHub's 2025-02-12 change
2435
+ # restricts check-run updates to the owning workflow — re-running is the
2436
+ # supported channel.)
2437
+ #
2438
+ # Privilege split, deliberate: \`evaluate\` checks out and executes repo
2439
+ # content and therefore holds NO write permission; \`retrigger\` holds
2440
+ # \`actions: write\` but checks out and executes nothing — its only inputs
2441
+ # are the evaluator's outputs, resolved via \`gh pr view\` before any repo
2442
+ # content ran. A malicious branch can at worst fail its own evaluation.
2443
+ name: Vinaya Review Gate (on verdict)
2444
+
2445
+ on:
2446
+ issue_comment:
2447
+ types: [created]
2448
+
2449
+ jobs:
2450
+ evaluate:
2451
+ name: vinaya review gate (verdict check)
2452
+ if: github.event.issue.pull_request != null && contains(github.event.comment.body, 'VERDICT')
2453
+ runs-on: ubuntu-latest
2454
+ permissions:
2455
+ contents: read
2456
+ pull-requests: read
2457
+ issues: read
2458
+ outputs:
2459
+ branch: \${{ steps.pr.outputs.branch }}
2197
2460
  steps:
2198
2461
  # issue_comment payloads carry no PR head SHA/branch — resolve them
2199
2462
  # before checkout, and check out that exact commit (the event's default
@@ -2203,24 +2466,54 @@ jobs:
2203
2466
  env:
2204
2467
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2205
2468
  run: |
2206
- if [ "\${{ github.event_name }}" = "pull_request" ]; then
2207
- NUMBER="\${{ github.event.pull_request.number }}"
2208
- else
2209
- NUMBER="\${{ github.event.issue.number }}"
2210
- fi
2469
+ NUMBER="\${{ github.event.issue.number }}"
2470
+ BRANCH=$(gh pr view "$NUMBER" --repo "\${{ github.repository }}" --json headRefName -q .headRefName)
2211
2471
  SHA=$(gh pr view "$NUMBER" --repo "\${{ github.repository }}" --json headRefOid -q .headRefOid)
2472
+ echo "number=$NUMBER" >> "$GITHUB_OUTPUT"
2473
+ echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
2212
2474
  echo "sha=$SHA" >> "$GITHUB_OUTPUT"
2213
2475
  - uses: actions/checkout@v4
2214
2476
  with:
2215
2477
  ref: \${{ steps.pr.outputs.sha }}
2478
+ persist-credentials: false
2216
2479
  fetch-depth: 0
2217
2480
  - uses: actions/setup-node@v4
2218
2481
  with:
2219
2482
  node-version: 20
2220
- - name: Review gate
2483
+ - name: Review gate (verdict evaluation)
2221
2484
  env:
2222
2485
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2223
- run: npx --yes @attalabs/vinaya check --all
2486
+ # Same wiring as the required workflow: PR_NUMBER is what makes
2487
+ # the adapter evaluate instead of no-op'ing as "local dev".
2488
+ PR_NUMBER: \${{ steps.pr.outputs.number }}
2489
+ BRANCH: \${{ steps.pr.outputs.branch }}
2490
+ run: npx --yes @attalabs/vinaya check review-gate
2491
+
2492
+ # Executes nothing; consumes only the evaluator's outputs. Fires only on a
2493
+ # clean evaluation — a failed one leaves the standing red untouched.
2494
+ retrigger:
2495
+ name: vinaya review gate (retrigger)
2496
+ if: needs.evaluate.result == 'success'
2497
+ needs: evaluate
2498
+ runs-on: ubuntu-latest
2499
+ permissions:
2500
+ actions: write
2501
+ steps:
2502
+ - name: Re-run the required review gate for this branch
2503
+ env:
2504
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
2505
+ BRANCH: \${{ needs.evaluate.outputs.branch }}
2506
+ run: |
2507
+ RUN_ID=$(gh run list --repo "\${{ github.repository }}" \\
2508
+ --workflow vinaya-review.yml --branch "$BRANCH" \\
2509
+ --status completed \\
2510
+ --json databaseId,event \\
2511
+ --jq '[.[] | select(.event=="pull_request")][0].databaseId // empty')
2512
+ if [ -z "$RUN_ID" ]; then
2513
+ echo "No completed pull_request run of vinaya-review.yml for branch $BRANCH - nothing to re-run."
2514
+ exit 0
2515
+ fi
2516
+ gh run rerun "$RUN_ID" --repo "\${{ github.repository }}"
2224
2517
  `;
2225
2518
  }
2226
2519
  function archivistWorkflow() {
@@ -2420,6 +2713,12 @@ function buildInitOps(ctx) {
2420
2713
  const hookMode = 493;
2421
2714
  ops.push({ kind: "create-file", path: CHECKS_WORKFLOW_PATH, content: checksWorkflow(), group: "CI workflows" });
2422
2715
  ops.push({ kind: "create-file", path: REVIEW_WORKFLOW_PATH, content: reviewWorkflow(), group: "CI workflows" });
2716
+ ops.push({
2717
+ kind: "create-file",
2718
+ path: REVIEW_VERDICT_WORKFLOW_PATH,
2719
+ content: reviewVerdictWorkflow(),
2720
+ group: "CI workflows"
2721
+ });
2423
2722
  ops.push({ kind: "create-file", path: ARCHIVIST_WORKFLOW_PATH, content: archivistWorkflow(), group: "CI workflows" });
2424
2723
  ops.push({
2425
2724
  kind: "managed-block",
@@ -2477,6 +2776,31 @@ function buildInitProductOps(name) {
2477
2776
  ];
2478
2777
  }
2479
2778
 
2779
+ // src/lib/env-lint.ts
2780
+ import { existsSync as existsSync6, readFileSync as readFileSync3 } from "node:fs";
2781
+ var ENV_READ_PATTERN = /\b(?:process\.env|Bun\.env|Deno\.env)\b/;
2782
+ function checksMissingEnvDeclaration(specs) {
2783
+ const names = [];
2784
+ for (const spec of specs) {
2785
+ if (spec.env)
2786
+ continue;
2787
+ if (!existsSync6(spec.run))
2788
+ continue;
2789
+ let source;
2790
+ try {
2791
+ source = readFileSync3(spec.run, "utf8");
2792
+ } catch {
2793
+ continue;
2794
+ }
2795
+ if (ENV_READ_PATTERN.test(source))
2796
+ names.push(spec.name);
2797
+ }
2798
+ return names;
2799
+ }
2800
+ function envDeclarationWarning(name) {
2801
+ return `check "${name}" reads environment variables directly but declares no \`env\` — it will lose environment access at the next minor. Declare \`env\` for it (CheckSpec['env'] in src/checks/contract.ts, or vinaya.config.json's checks.<name>.env).`;
2802
+ }
2803
+
2480
2804
  // src/lib/ops.ts
2481
2805
  import { chmodSync, existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
2482
2806
  import { dirname as dirname3, join as join7, resolve, sep } from "node:path";
@@ -2941,6 +3265,34 @@ function diagnoseEnvDeclarations(repoRoot, config) {
2941
3265
  }
2942
3266
  return findings;
2943
3267
  }
3268
+ function diagnoseCheckClassification(config) {
3269
+ const classification = resolveChecks(coreCheckRegistry(), config?.checks);
3270
+ const findings = [];
3271
+ for (const entry2 of classification.resolved) {
3272
+ if (entry2.state === "overridden")
3273
+ findings.push(warn("checks", overriddenNextMinorWarning(entry2.name)));
3274
+ }
3275
+ for (const failure of classification.failures) {
3276
+ findings.push(warn("checks", bareKeyNextMinorWarning(failure.key)));
3277
+ }
3278
+ return findings;
3279
+ }
3280
+ function diagnoseGlobalConfigChecks() {
3281
+ if (!existsSync8(GLOBAL_CONFIG_PATH))
3282
+ return [];
3283
+ let raw;
3284
+ try {
3285
+ raw = JSON.parse(readFileSync5(GLOBAL_CONFIG_PATH, "utf-8"));
3286
+ } catch (err) {
3287
+ return [info("checks", `${GLOBAL_CONFIG_PATH} is invalid JSON — ${err.message}`)];
3288
+ }
3289
+ const parsed = VinayaConfigSchema.safeParse(raw);
3290
+ if (!parsed.success)
3291
+ return [];
3292
+ if (!parsed.data.checks || Object.keys(parsed.data.checks).length === 0)
3293
+ return [];
3294
+ return [warn("checks", globalChecksIgnoredWarning(GLOBAL_CONFIG_PATH))];
3295
+ }
2944
3296
  async function diagnoseEnvironment(deps, hasDrift) {
2945
3297
  const findings = [];
2946
3298
  const auth = await deps.ghAuthStatus();
@@ -3011,6 +3363,8 @@ async function runDoctor(args, deps) {
3011
3363
  findings.push(...diagnoseCustomChecks(repo.repoRoot, configRead.config));
3012
3364
  }
3013
3365
  findings.push(...diagnoseEnvDeclarations(repo.repoRoot, configRead.kind === "ok" ? configRead.config : null));
3366
+ findings.push(...diagnoseCheckClassification(configRead.kind === "ok" ? configRead.config : null));
3367
+ findings.push(...diagnoseGlobalConfigChecks());
3014
3368
  findings.push(...await diagnoseEnvironment(deps, hasDrift));
3015
3369
  findings.push(await diagnoseBranchProtection(deps, repo.owner, repo.repo));
3016
3370
  const healthy = findings.every((f) => f.severity === "ok" || f.severity === "info");
@@ -4297,10 +4651,15 @@ var COMMANDS = [
4297
4651
  { flag: "--all", description: "Run every registered check instead of one named check" },
4298
4652
  { flag: "--json", description: "Enveloped JSON output (schema: 1)" },
4299
4653
  { flag: "--diff-only", description: "Scope diff-declared checks to changed files" },
4300
- { flag: "--parallel[=n]", description: "Concurrency cap (default: cpu-derived)" }
4654
+ { flag: "--parallel[=n]", description: "Concurrency cap (default: cpu-derived)" },
4655
+ {
4656
+ flag: "--plan",
4657
+ description: "Print the resolved check registry (default/overridden/additive) without running anything"
4658
+ }
4301
4659
  ],
4302
4660
  details: [
4303
- "Warns (stderr, every output mode, never affecting the exit code) for any check whose executable reads `process.env`/`Bun.env`/`Deno.env` directly with no `env` declared on its `CheckSpec` those checks will lose environment access once the env allowlist is wired as the spawn default in a later minor. Declare `env` (a core check's own registration, or `vinaya.config.json`'s `checks.<name>.env` for a custom one) to silence the warning ahead of that flip."
4661
+ "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 forwardsnever 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.",
4662
+ "`--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."
4304
4663
  ],
4305
4664
  status: "shipped"
4306
4665
  },