@attalabs/vinaya 0.1.3 → 0.3.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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync11 } from "node:fs";
4
+ import { readFileSync as readFileSync12 } 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
 
@@ -1276,91 +1276,153 @@ function coreCheckRegistry() {
1276
1276
  name: "brief-shape",
1277
1277
  run: bin("check-brief-shape"),
1278
1278
  scope: "diff",
1279
- timeoutMs: 15000
1279
+ timeoutMs: 15000,
1280
+ env: { PR_BODY: { optional: true } }
1280
1281
  },
1281
1282
  {
1282
1283
  name: "doc-coverage",
1283
1284
  run: bin("check-doc-coverage"),
1284
1285
  scope: "diff",
1285
- timeoutMs: 15000
1286
+ timeoutMs: 15000,
1287
+ env: {
1288
+ BASE_SHA: { optional: true },
1289
+ PR_BODY: { optional: true },
1290
+ PR_BODY_FILE: { optional: true },
1291
+ PR_LABELS: { optional: true },
1292
+ WAIVER_LABEL_ACTOR: { optional: true }
1293
+ }
1286
1294
  },
1287
1295
  {
1288
1296
  name: "coherence",
1289
1297
  run: bin("check-coherence"),
1290
1298
  scope: "full",
1291
- timeoutMs: 30000
1299
+ timeoutMs: 30000,
1300
+ env: {
1301
+ AEG_REPO: { optional: true },
1302
+ BRANCH: { optional: true },
1303
+ GITHUB_TOKEN: { optional: true },
1304
+ GH_TOKEN: { optional: true }
1305
+ }
1292
1306
  },
1293
1307
  {
1294
1308
  name: "dispatch-readiness",
1295
1309
  run: bin("check-dispatch-readiness"),
1296
1310
  scope: "full",
1297
- timeoutMs: 30000
1311
+ timeoutMs: 30000,
1312
+ env: {
1313
+ AEG_REPO: { optional: true },
1314
+ BRANCH: { optional: true },
1315
+ GITHUB_TOKEN: { optional: true },
1316
+ GH_TOKEN: { optional: true }
1317
+ }
1298
1318
  },
1299
1319
  {
1300
1320
  name: "closes-n",
1301
1321
  run: bin("check-closes-n"),
1302
1322
  scope: "diff",
1303
- timeoutMs: 15000
1323
+ timeoutMs: 15000,
1324
+ env: {
1325
+ BRANCH: { optional: true },
1326
+ PR_BODY: { optional: true },
1327
+ AEG_REPO: { optional: true }
1328
+ }
1304
1329
  },
1305
1330
  {
1306
1331
  name: "single-plan-pr",
1307
1332
  run: bin("check-single-plan-pr"),
1308
1333
  scope: "diff",
1309
- timeoutMs: 15000
1334
+ timeoutMs: 15000,
1335
+ env: {
1336
+ BASE_SHA: { optional: true },
1337
+ PR_NUMBER: { optional: true }
1338
+ }
1310
1339
  },
1311
1340
  {
1312
1341
  name: "test-plan",
1313
1342
  run: bin("check-test-plan"),
1314
1343
  scope: "diff",
1315
- timeoutMs: 15000
1344
+ timeoutMs: 15000,
1345
+ env: {
1346
+ PR_BODY: { optional: true },
1347
+ BRANCH: { optional: true }
1348
+ }
1316
1349
  },
1317
1350
  {
1318
1351
  name: "no-disk-state",
1319
1352
  run: bin("check-no-disk-state"),
1320
1353
  scope: "diff",
1321
- timeoutMs: 15000
1354
+ timeoutMs: 15000,
1355
+ env: { BASE_SHA: { optional: true } }
1322
1356
  },
1323
1357
  {
1324
1358
  name: "registry-gates",
1325
1359
  run: bin("check-registry-gates"),
1326
1360
  scope: "full",
1327
- timeoutMs: 30000
1361
+ timeoutMs: 30000,
1362
+ env: { AEG_REPO: { optional: true } }
1328
1363
  },
1329
1364
  {
1330
1365
  name: "review-gate",
1331
1366
  run: bin("check-review-gate"),
1332
1367
  scope: "full",
1333
- timeoutMs: 30000
1368
+ timeoutMs: 30000,
1369
+ env: {
1370
+ BRANCH: { optional: true },
1371
+ PR_NUMBER: { optional: true }
1372
+ }
1334
1373
  },
1335
1374
  {
1336
1375
  name: "branch-topology",
1337
1376
  run: bin("check-branch-topology"),
1338
1377
  scope: "full",
1339
- timeoutMs: 30000
1378
+ timeoutMs: 30000,
1379
+ env: {
1380
+ BRANCH: { optional: true },
1381
+ AEG_REPO: { optional: true }
1382
+ }
1340
1383
  },
1341
1384
  {
1342
1385
  name: "dead-branch-push",
1343
1386
  run: bin("check-dead-branch-push"),
1344
1387
  scope: "full",
1345
- timeoutMs: 30000
1388
+ timeoutMs: 30000,
1389
+ env: { BRANCH: { optional: true } }
1346
1390
  },
1347
1391
  {
1348
1392
  name: "first-push-dispatch",
1349
1393
  run: bin("check-first-push-dispatch"),
1350
1394
  scope: "full",
1351
- timeoutMs: 30000
1395
+ timeoutMs: 30000,
1396
+ env: {
1397
+ BRANCH: { optional: true },
1398
+ AEG_REPO: { optional: true },
1399
+ GITHUB_TOKEN: { optional: true },
1400
+ GH_TOKEN: { optional: true }
1401
+ }
1352
1402
  },
1353
1403
  {
1354
1404
  name: "doc-coverage-push",
1355
1405
  run: bin("check-doc-coverage-push"),
1356
1406
  scope: "diff",
1357
- timeoutMs: 15000
1407
+ timeoutMs: 15000,
1408
+ env: {
1409
+ OVERRIDE_DOCS: { optional: true },
1410
+ BASE_SHA: { optional: true },
1411
+ PR_BODY: { optional: true },
1412
+ PR_BODY_FILE: { optional: true },
1413
+ PR_LABELS: { optional: true },
1414
+ WAIVER_LABEL_ACTOR: { optional: true }
1415
+ }
1358
1416
  },
1359
1417
  {
1360
1418
  name: "issue-assignment",
1361
1419
  run: bin("check-issue-assignment"),
1362
1420
  scope: "full",
1363
- timeoutMs: 30000
1421
+ timeoutMs: 30000,
1422
+ env: {
1423
+ AEG_REPO: { optional: true },
1424
+ BRANCH: { optional: true }
1425
+ }
1364
1426
  }
1365
1427
  ];
1366
1428
  }
@@ -1390,17 +1452,138 @@ function shouldSkip(spec, opts) {
1390
1452
  return !opts.changedFiles.some((f) => regexes.some((re) => re.test(f)));
1391
1453
  }
1392
1454
  var KILL_GRACE_MS = 2000;
1393
- async function runOne(spec, timeoutMs) {
1455
+ var activeKillers = new Set;
1456
+ var signalForwardingInstalled = false;
1457
+ function installSignalForwarding() {
1458
+ if (signalForwardingInstalled)
1459
+ return;
1460
+ signalForwardingInstalled = true;
1461
+ const forward = (_signal, exitCode) => {
1462
+ for (const kill of activeKillers)
1463
+ kill("SIGTERM");
1464
+ setTimeout(() => {
1465
+ for (const kill of activeKillers)
1466
+ kill("SIGKILL");
1467
+ process.exit(exitCode);
1468
+ }, KILL_GRACE_MS);
1469
+ };
1470
+ process.on("SIGINT", () => forward("SIGINT", 130));
1471
+ process.on("SIGTERM", () => forward("SIGTERM", 143));
1472
+ }
1473
+ var ENV_BASELINE_KEYS = ["PATH", "LANG", "HOME", "HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "TMPDIR"];
1474
+ function buildCheckEnv(env, callerEnv = process.env) {
1475
+ const out = {};
1476
+ for (const key of ENV_BASELINE_KEYS) {
1477
+ const value = callerEnv[key];
1478
+ if (value !== undefined)
1479
+ out[key] = value;
1480
+ }
1481
+ if (!env)
1482
+ return out;
1483
+ for (const [key, decl] of Object.entries(env)) {
1484
+ if (decl === true || typeof decl === "object" && "optional" in decl) {
1485
+ const value = callerEnv[key];
1486
+ if (value !== undefined)
1487
+ out[key] = value;
1488
+ } else if (typeof decl === "object" && "anyOf" in decl) {
1489
+ for (const member of decl.anyOf) {
1490
+ const value = callerEnv[member];
1491
+ if (value !== undefined)
1492
+ out[member] = value;
1493
+ }
1494
+ } else if (typeof decl === "string") {
1495
+ out[key] = decl;
1496
+ }
1497
+ }
1498
+ return out;
1499
+ }
1500
+ function missingEnvErrors(spec, callerEnv) {
1501
+ if (!spec.env)
1502
+ return [];
1503
+ const errors = [];
1504
+ for (const [key, decl] of Object.entries(spec.env)) {
1505
+ if (decl === true) {
1506
+ if (callerEnv[key] === undefined) {
1507
+ errors.push({
1508
+ schema: CHECK_SCHEMA_VERSION,
1509
+ check: spec.name,
1510
+ severity: "error",
1511
+ message: `Could not run check "${spec.name}": required environment variable \`${key}\` is not set.`,
1512
+ 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.`
1513
+ });
1514
+ }
1515
+ } else if (typeof decl === "object" && decl !== null && "anyOf" in decl) {
1516
+ const satisfied = decl.anyOf.some((member) => callerEnv[member] !== undefined);
1517
+ if (!satisfied) {
1518
+ errors.push({
1519
+ schema: CHECK_SCHEMA_VERSION,
1520
+ check: spec.name,
1521
+ severity: "error",
1522
+ message: `Could not run check "${spec.name}": none of its required environment variables (${decl.anyOf.join(", ")}) are set.`,
1523
+ agent_recovery_prompt: `Set one of ${decl.anyOf.join(", ")} in the environment before re-running \`vinaya check ${spec.name}\`.`
1524
+ });
1525
+ }
1526
+ }
1527
+ }
1528
+ return errors;
1529
+ }
1530
+ async function runOne(spec, timeoutMs, callerEnv) {
1394
1531
  const start = performance.now();
1532
+ const envErrors = missingEnvErrors(spec, callerEnv);
1533
+ if (envErrors.length > 0) {
1534
+ return {
1535
+ name: spec.name,
1536
+ status: "error",
1537
+ exitCode: null,
1538
+ errors: envErrors,
1539
+ durationMs: performance.now() - start
1540
+ };
1541
+ }
1395
1542
  const proc = spawn(spec.run, spec.args ?? [], {
1396
- stdio: ["ignore", "pipe", "pipe"]
1543
+ stdio: ["ignore", "pipe", "pipe"],
1544
+ detached: true,
1545
+ env: buildCheckEnv(spec.env, callerEnv)
1397
1546
  });
1547
+ function killTree(signal) {
1548
+ const pid = proc.pid;
1549
+ if (pid === undefined)
1550
+ return;
1551
+ try {
1552
+ process.kill(-pid, signal);
1553
+ } catch {
1554
+ try {
1555
+ proc.kill(signal);
1556
+ } catch {}
1557
+ }
1558
+ }
1559
+ installSignalForwarding();
1560
+ activeKillers.add(killTree);
1561
+ function groupStillAlive(pid) {
1562
+ try {
1563
+ process.kill(-pid, 0);
1564
+ return true;
1565
+ } catch {
1566
+ return false;
1567
+ }
1568
+ }
1398
1569
  let timedOut = false;
1399
- let killTimer;
1570
+ let escalationDone = Promise.resolve();
1571
+ let cancelEscalation;
1400
1572
  const timer = setTimeout(() => {
1401
1573
  timedOut = true;
1402
- proc.kill("SIGTERM");
1403
- killTimer = setTimeout(() => proc.kill("SIGKILL"), KILL_GRACE_MS);
1574
+ killTree("SIGTERM");
1575
+ escalationDone = new Promise((resolve) => {
1576
+ const graceTimer = setTimeout(() => {
1577
+ const pid = proc.pid;
1578
+ if (pid !== undefined && groupStillAlive(pid))
1579
+ killTree("SIGKILL");
1580
+ resolve();
1581
+ }, KILL_GRACE_MS);
1582
+ cancelEscalation = () => {
1583
+ clearTimeout(graceTimer);
1584
+ resolve();
1585
+ };
1586
+ });
1404
1587
  }, timeoutMs);
1405
1588
  let stderrText = "";
1406
1589
  proc.stderr.setEncoding("utf-8");
@@ -1417,7 +1600,13 @@ async function runOne(spec, timeoutMs) {
1417
1600
  });
1418
1601
  });
1419
1602
  clearTimeout(timer);
1420
- clearTimeout(killTimer);
1603
+ if (timedOut) {
1604
+ const pid = proc.pid;
1605
+ if (pid === undefined || !groupStillAlive(pid))
1606
+ cancelEscalation?.();
1607
+ await escalationDone;
1608
+ }
1609
+ activeKillers.delete(killTree);
1421
1610
  const durationMs = performance.now() - start;
1422
1611
  if (timedOut) {
1423
1612
  return { name: spec.name, status: "timeout", exitCode: null, errors: [], durationMs };
@@ -1469,6 +1658,7 @@ async function runOne(spec, timeoutMs) {
1469
1658
  return { name: spec.name, status, exitCode, errors, durationMs };
1470
1659
  }
1471
1660
  async function runChecks(specs, opts) {
1661
+ const callerEnv = opts.callerEnv ?? process.env;
1472
1662
  const results = new Array(specs.length);
1473
1663
  const toRun = [];
1474
1664
  for (let i = 0;i < specs.length; i++) {
@@ -1485,7 +1675,7 @@ async function runChecks(specs, opts) {
1485
1675
  const idx = toRun[cursor];
1486
1676
  cursor += 1;
1487
1677
  const spec = specs[idx];
1488
- results[idx] = await runOne(spec, spec.timeoutMs ?? opts.defaultTimeoutMs);
1678
+ results[idx] = await runOne(spec, spec.timeoutMs ?? opts.defaultTimeoutMs, callerEnv);
1489
1679
  }
1490
1680
  };
1491
1681
  const workerCount = Math.max(1, Math.min(opts.parallel, toRun.length));
@@ -1498,13 +1688,70 @@ import { existsSync as existsSync4, mkdirSync, readFileSync, writeFileSync } fro
1498
1688
  import { homedir } from "node:os";
1499
1689
  import { dirname as dirname2, join as join4 } from "node:path";
1500
1690
  import { z } from "zod";
1691
+ var EnvEntrySchema = z.union([
1692
+ z.literal(true),
1693
+ z.object({ optional: z.literal(true) }),
1694
+ z.object({ anyOf: z.array(z.string()).min(2) }),
1695
+ z.string()
1696
+ ]);
1501
1697
  var CheckEntrySchema = z.object({
1502
1698
  run: z.string(),
1503
1699
  scope: z.enum(["diff", "full"]),
1504
1700
  include: z.array(z.string()).optional(),
1505
1701
  args: z.array(z.string()).optional(),
1506
- timeoutMs: z.number().optional()
1702
+ timeoutMs: z.number().optional(),
1703
+ env: z.record(z.string(), EnvEntrySchema).optional()
1704
+ }).superRefine((entry2, ctx) => {
1705
+ if (!entry2.env)
1706
+ return;
1707
+ for (const [key, value] of Object.entries(entry2.env)) {
1708
+ if (typeof value !== "object" || value === null || !("anyOf" in value))
1709
+ continue;
1710
+ const members = value.anyOf;
1711
+ if (new Set(members).size !== members.length) {
1712
+ ctx.addIssue({
1713
+ code: z.ZodIssueCode.custom,
1714
+ message: `env.${key}.anyOf has duplicate members`,
1715
+ path: ["env", key, "anyOf"]
1716
+ });
1717
+ }
1718
+ if (!members.includes(key)) {
1719
+ ctx.addIssue({
1720
+ code: z.ZodIssueCode.custom,
1721
+ message: `env.${key}.anyOf must include "${key}" itself as a member`,
1722
+ path: ["env", key, "anyOf"]
1723
+ });
1724
+ }
1725
+ }
1507
1726
  });
1727
+ var HIGH_ENTROPY_MIN_LENGTH = 20;
1728
+ function looksHighEntropy(value) {
1729
+ if (value.length < HIGH_ENTROPY_MIN_LENGTH)
1730
+ return false;
1731
+ if (/\s/.test(value))
1732
+ return false;
1733
+ const classes = [/[a-z]/.test(value), /[A-Z]/.test(value), /[0-9]/.test(value)].filter(Boolean).length;
1734
+ return classes >= 2;
1735
+ }
1736
+ function lintEnvDeclarations(checks) {
1737
+ const warnings = [];
1738
+ if (!checks)
1739
+ return warnings;
1740
+ for (const [checkName, entry2] of Object.entries(checks)) {
1741
+ if (!entry2.env)
1742
+ continue;
1743
+ for (const [key, value] of Object.entries(entry2.env)) {
1744
+ if (typeof value !== "string")
1745
+ continue;
1746
+ if (value === "true" || value === "false") {
1747
+ warnings.push(`check "${checkName}" env.${key} is the literal string "${value}" — did you mean the boolean form \`${key}: true\` (forward the caller's value) rather than a hardcoded literal?`);
1748
+ } else if (looksHighEntropy(value)) {
1749
+ warnings.push(`check "${checkName}" env.${key} looks like a high-entropy literal (possible secret committed to config) — env declarations should reference variable NAMES the caller sets, not literal secret values.`);
1750
+ }
1751
+ }
1752
+ }
1753
+ return warnings;
1754
+ }
1508
1755
  var BRIEF_BUILTINS = [
1509
1756
  "tier",
1510
1757
  "testPlan",
@@ -1902,7 +2149,7 @@ async function demoBreakCommand(args) {
1902
2149
  }
1903
2150
 
1904
2151
  // src/commands/doctor.ts
1905
- import { existsSync as existsSync7, readFileSync as readFileSync4, statSync } from "node:fs";
2152
+ import { existsSync as existsSync8, readFileSync as readFileSync5, statSync } from "node:fs";
1906
2153
  import { join as join8 } from "node:path";
1907
2154
 
1908
2155
  // src/lib/artifacts.ts
@@ -2271,8 +2518,33 @@ function buildInitProductOps(name) {
2271
2518
  ];
2272
2519
  }
2273
2520
 
2521
+ // src/lib/env-lint.ts
2522
+ import { existsSync as existsSync6, readFileSync as readFileSync3 } from "node:fs";
2523
+ var ENV_READ_PATTERN = /\b(?:process\.env|Bun\.env|Deno\.env)\b/;
2524
+ function checksMissingEnvDeclaration(specs) {
2525
+ const names = [];
2526
+ for (const spec of specs) {
2527
+ if (spec.env)
2528
+ continue;
2529
+ if (!existsSync6(spec.run))
2530
+ continue;
2531
+ let source;
2532
+ try {
2533
+ source = readFileSync3(spec.run, "utf8");
2534
+ } catch {
2535
+ continue;
2536
+ }
2537
+ if (ENV_READ_PATTERN.test(source))
2538
+ names.push(spec.name);
2539
+ }
2540
+ return names;
2541
+ }
2542
+ function envDeclarationWarning(name) {
2543
+ 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).`;
2544
+ }
2545
+
2274
2546
  // src/lib/ops.ts
2275
- import { chmodSync, existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
2547
+ import { chmodSync, existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
2276
2548
  import { dirname as dirname3, join as join7, resolve, sep } from "node:path";
2277
2549
  var MARKER_NS = "vinaya:managed";
2278
2550
  function markerLines(marker, comment) {
@@ -2300,9 +2572,9 @@ function containedAbs(repoRoot, relPath) {
2300
2572
  }
2301
2573
  function fileContains(repoRoot, relPath, needle) {
2302
2574
  const p = abs(repoRoot, relPath);
2303
- if (!existsSync6(p))
2575
+ if (!existsSync7(p))
2304
2576
  return false;
2305
- return readFileSync3(p, "utf-8").includes(needle);
2577
+ return readFileSync4(p, "utf-8").includes(needle);
2306
2578
  }
2307
2579
  function planInstall(ops, repoRoot, ownedFiles = new Set) {
2308
2580
  const entries = [];
@@ -2310,7 +2582,7 @@ function planInstall(ops, repoRoot, ownedFiles = new Set) {
2310
2582
  for (const op of ops) {
2311
2583
  switch (op.kind) {
2312
2584
  case "create-file": {
2313
- const exists = existsSync6(abs(repoRoot, op.path));
2585
+ const exists = existsSync7(abs(repoRoot, op.path));
2314
2586
  let action;
2315
2587
  if (!exists)
2316
2588
  action = "create";
@@ -2328,7 +2600,7 @@ function planInstall(ops, repoRoot, ownedFiles = new Set) {
2328
2600
  let action;
2329
2601
  if (fileContains(repoRoot, op.path, begin))
2330
2602
  action = "skip-present";
2331
- else if (existsSync6(abs(repoRoot, op.path)))
2603
+ else if (existsSync7(abs(repoRoot, op.path)))
2332
2604
  action = "append";
2333
2605
  else
2334
2606
  action = "create-host";
@@ -2412,7 +2684,7 @@ function writeFileWithDirs(target, content, mode) {
2412
2684
  }
2413
2685
  function appendBlock(repoRoot, op) {
2414
2686
  const target = abs(repoRoot, op.path);
2415
- const existing = readFileSync3(target, "utf-8");
2687
+ const existing = readFileSync4(target, "utf-8");
2416
2688
  const sep2 = existing.endsWith(`
2417
2689
  `) ? `
2418
2690
  ` : `
@@ -2523,7 +2795,7 @@ function planEject(manifest, repoRoot) {
2523
2795
  escapes.push(b.path);
2524
2796
  continue;
2525
2797
  }
2526
- if (!existsSync6(p)) {
2798
+ if (!existsSync7(p)) {
2527
2799
  actions.push({
2528
2800
  kind: "strip-block",
2529
2801
  path: b.path,
@@ -2534,7 +2806,7 @@ function planEject(manifest, repoRoot) {
2534
2806
  });
2535
2807
  continue;
2536
2808
  }
2537
- const content = readFileSync3(p, "utf-8");
2809
+ const content = readFileSync4(p, "utf-8");
2538
2810
  const stripped = stripBlockFromContent(content, b.marker, b.comment);
2539
2811
  const removesHost = stripped !== null && blockStripLeavesEmpty(stripped);
2540
2812
  actions.push({
@@ -2552,7 +2824,7 @@ function planEject(manifest, repoRoot) {
2552
2824
  escapes.push(f);
2553
2825
  continue;
2554
2826
  }
2555
- actions.push({ kind: "delete-file", path: f, present: existsSync6(p) });
2827
+ actions.push({ kind: "delete-file", path: f, present: existsSync7(p) });
2556
2828
  }
2557
2829
  for (const name of manifest.labels) {
2558
2830
  actions.push({ kind: "report-label", name });
@@ -2586,7 +2858,7 @@ function applyEject(plan, repoRoot) {
2586
2858
  const p = containedAbs(repoRoot, a.path);
2587
2859
  if (p === null)
2588
2860
  continue;
2589
- const content = readFileSync3(p, "utf-8");
2861
+ const content = readFileSync4(p, "utf-8");
2590
2862
  const stripped = stripBlockFromContent(content, a.marker, a.comment);
2591
2863
  if (stripped === null)
2592
2864
  continue;
@@ -2609,7 +2881,7 @@ function applyEject(plan, repoRoot) {
2609
2881
 
2610
2882
  // src/commands/doctor.ts
2611
2883
  function readVersion() {
2612
- const pkg = JSON.parse(readFileSync4(join8(packageRoot(import.meta.url), "package.json"), "utf-8"));
2884
+ const pkg = JSON.parse(readFileSync5(join8(packageRoot(import.meta.url), "package.json"), "utf-8"));
2613
2885
  return pkg.version;
2614
2886
  }
2615
2887
  function realDeps3() {
@@ -2629,11 +2901,11 @@ var warn = (check, message) => ({ check, severity: "warn", message });
2629
2901
  var error = (check, message) => ({ check, severity: "error", message });
2630
2902
  function readConfig(repoRoot) {
2631
2903
  const p = join8(repoRoot, CONFIG_PATH);
2632
- if (!existsSync7(p))
2904
+ if (!existsSync8(p))
2633
2905
  return { kind: "missing" };
2634
2906
  let raw;
2635
2907
  try {
2636
- raw = JSON.parse(readFileSync4(p, "utf-8"));
2908
+ raw = JSON.parse(readFileSync5(p, "utf-8"));
2637
2909
  } catch (err) {
2638
2910
  return { kind: "invalid", error: `invalid JSON: ${err.message}` };
2639
2911
  }
@@ -2663,13 +2935,13 @@ function diagnoseInstall(repoRoot, ctx, manifest) {
2663
2935
  if (op.kind === "create-file") {
2664
2936
  const check = labelForPath(op.path);
2665
2937
  const abs2 = join8(repoRoot, op.path);
2666
- const exists = existsSync7(abs2);
2938
+ const exists = existsSync8(abs2);
2667
2939
  const owned = ownedFiles.has(op.path);
2668
2940
  if (!exists) {
2669
2941
  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\`.`));
2670
2942
  continue;
2671
2943
  }
2672
- const content = readFileSync4(abs2, "utf-8");
2944
+ const content = readFileSync5(abs2, "utf-8");
2673
2945
  if (!owned) {
2674
2946
  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.`));
2675
2947
  continue;
@@ -2688,11 +2960,11 @@ function diagnoseInstall(repoRoot, ctx, manifest) {
2688
2960
  const check = "hooks";
2689
2961
  const abs2 = join8(repoRoot, op.path);
2690
2962
  const owned = ownedBlocks.has(blockKey(op.path, op.marker));
2691
- if (!existsSync7(abs2)) {
2963
+ if (!existsSync8(abs2)) {
2692
2964
  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.`));
2693
2965
  continue;
2694
2966
  }
2695
- const content = readFileSync4(abs2, "utf-8");
2967
+ const content = readFileSync5(abs2, "utf-8");
2696
2968
  const { begin, end } = markerLines(op.marker, op.comment);
2697
2969
  const hasMarkers = content.includes(begin) && content.includes(end);
2698
2970
  if (!hasMarkers) {
@@ -2718,7 +2990,20 @@ function diagnoseCustomChecks(repoRoot, config) {
2718
2990
  const findings = [];
2719
2991
  for (const [name, entry2] of Object.entries(config.checks ?? {})) {
2720
2992
  const scriptAbs = join8(repoRoot, entry2.run);
2721
- findings.push(existsSync7(scriptAbs) ? ok("checks", `custom check '${name}' → ${entry2.run}`) : error("checks", `custom check '${name}' points at a missing script: ${entry2.run}`));
2993
+ findings.push(existsSync8(scriptAbs) ? ok("checks", `custom check '${name}' → ${entry2.run}`) : error("checks", `custom check '${name}' points at a missing script: ${entry2.run}`));
2994
+ }
2995
+ return findings;
2996
+ }
2997
+ function diagnoseEnvDeclarations(repoRoot, config) {
2998
+ const customSpecs = Object.entries(config?.checks ?? {}).map(([name, entry2]) => ({
2999
+ name,
3000
+ ...entry2,
3001
+ run: join8(repoRoot, entry2.run)
3002
+ }));
3003
+ const missing = checksMissingEnvDeclaration([...coreCheckRegistry(), ...customSpecs]);
3004
+ const findings = missing.map((name) => info("env", envDeclarationWarning(name)));
3005
+ for (const message of lintEnvDeclarations(config?.checks)) {
3006
+ findings.push(warn("env", message));
2722
3007
  }
2723
3008
  return findings;
2724
3009
  }
@@ -2791,6 +3076,7 @@ async function runDoctor(args, deps) {
2791
3076
  hasDrift = install.hasDrift;
2792
3077
  findings.push(...diagnoseCustomChecks(repo.repoRoot, configRead.config));
2793
3078
  }
3079
+ findings.push(...diagnoseEnvDeclarations(repo.repoRoot, configRead.kind === "ok" ? configRead.config : null));
2794
3080
  findings.push(...await diagnoseEnvironment(deps, hasDrift));
2795
3081
  findings.push(await diagnoseBranchProtection(deps, repo.owner, repo.repo));
2796
3082
  const healthy = findings.every((f) => f.severity === "ok" || f.severity === "info");
@@ -2806,7 +3092,7 @@ async function doctorCommand(args) {
2806
3092
  }
2807
3093
 
2808
3094
  // src/commands/eject.ts
2809
- import { existsSync as existsSync8, readFileSync as readFileSync5 } from "node:fs";
3095
+ import { existsSync as existsSync9, readFileSync as readFileSync6 } from "node:fs";
2810
3096
  import { join as join9 } from "node:path";
2811
3097
 
2812
3098
  // src/lib/prompt.ts
@@ -2889,11 +3175,11 @@ function parse(args) {
2889
3175
  }
2890
3176
  function readManifest(repoRoot) {
2891
3177
  const p = join9(repoRoot, CONFIG_PATH);
2892
- if (!existsSync8(p))
3178
+ if (!existsSync9(p))
2893
3179
  return { kind: "none" };
2894
3180
  let raw;
2895
3181
  try {
2896
- raw = JSON.parse(readFileSync5(p, "utf-8"));
3182
+ raw = JSON.parse(readFileSync6(p, "utf-8"));
2897
3183
  } catch (err) {
2898
3184
  return { kind: "orphan", reason: `vinaya.config.json is not valid JSON: ${err.message}` };
2899
3185
  }
@@ -2976,7 +3262,7 @@ async function ejectCommand(args) {
2976
3262
  }
2977
3263
 
2978
3264
  // src/commands/init.ts
2979
- import { existsSync as existsSync9, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
3265
+ import { existsSync as existsSync10, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
2980
3266
  import { join as join10 } from "node:path";
2981
3267
  function realDeps5() {
2982
3268
  return {
@@ -2998,17 +3284,17 @@ function flags(args) {
2998
3284
  var PRODUCT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
2999
3285
  function readManifest2(repoRoot) {
3000
3286
  const p = join10(repoRoot, CONFIG_PATH);
3001
- if (!existsSync9(p))
3287
+ if (!existsSync10(p))
3002
3288
  return null;
3003
3289
  try {
3004
- return VinayaConfigSchema.parse(JSON.parse(readFileSync6(p, "utf-8"))).managed ?? null;
3290
+ return VinayaConfigSchema.parse(JSON.parse(readFileSync7(p, "utf-8"))).managed ?? null;
3005
3291
  } catch {
3006
3292
  return null;
3007
3293
  }
3008
3294
  }
3009
3295
  function writeManifest(repoRoot, manifest) {
3010
3296
  const configAbs = join10(repoRoot, CONFIG_PATH);
3011
- const seed = JSON.parse(readFileSync6(configAbs, "utf-8"));
3297
+ const seed = JSON.parse(readFileSync7(configAbs, "utf-8"));
3012
3298
  writeFileSync4(configAbs, `${JSON.stringify({ ...seed, managed: manifest }, null, 2)}
3013
3299
  `, "utf-8");
3014
3300
  }
@@ -3148,7 +3434,7 @@ import { execFileSync as execFileSync6 } from "node:child_process";
3148
3434
 
3149
3435
  // src/lib/forge-write.ts
3150
3436
  import { mkdtempSync, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "node:fs";
3151
- import { readFileSync as readFileSync7 } from "node:fs";
3437
+ import { readFileSync as readFileSync8 } from "node:fs";
3152
3438
  import { tmpdir } from "node:os";
3153
3439
  import { join as join11 } from "node:path";
3154
3440
  class ForgeArgError extends Error {
@@ -3160,13 +3446,13 @@ function locateBody(args) {
3160
3446
  const p = args[i + 1];
3161
3447
  if (!p)
3162
3448
  throw new ForgeArgError("`--body-file` was given with no path.");
3163
- return { body: readFileSync7(p, "utf8"), source: { kind: "file", argIndex: i + 1, inlineForm: false } };
3449
+ return { body: readFileSync8(p, "utf8"), source: { kind: "file", argIndex: i + 1, inlineForm: false } };
3164
3450
  }
3165
3451
  if (a.startsWith("--body-file=")) {
3166
3452
  const p = a.slice("--body-file=".length);
3167
3453
  if (!p)
3168
3454
  throw new ForgeArgError("`--body-file=` was given with no path.");
3169
- return { body: readFileSync7(p, "utf8"), source: { kind: "file", argIndex: i, inlineForm: true } };
3455
+ return { body: readFileSync8(p, "utf8"), source: { kind: "file", argIndex: i, inlineForm: true } };
3170
3456
  }
3171
3457
  if (a === "--body" || a === "-b") {
3172
3458
  const v = args[i + 1];
@@ -3456,7 +3742,7 @@ function issueEditCommand(args) {
3456
3742
  }
3457
3743
 
3458
3744
  // src/commands/new-check.ts
3459
- import { chmodSync as chmodSync2, existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "node:fs";
3745
+ import { chmodSync as chmodSync2, existsSync as existsSync11, mkdirSync as mkdirSync3, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "node:fs";
3460
3746
  import { join as join12 } from "node:path";
3461
3747
  var TEMPLATE_PATH = join12(packageRoot(import.meta.url), "templates", "custom-check.template.ts");
3462
3748
  var CHECKS_DIR = join12("scripts", "vinaya-checks");
@@ -3468,14 +3754,14 @@ function newCheckCommand(args) {
3468
3754
  process.exit(2);
3469
3755
  }
3470
3756
  const scriptsDir = join12(process.cwd(), CHECKS_DIR);
3471
- if (!existsSync10(scriptsDir))
3757
+ if (!existsSync11(scriptsDir))
3472
3758
  mkdirSync3(scriptsDir, { recursive: true });
3473
3759
  const targetPath = join12(scriptsDir, `${name}.ts`);
3474
- if (existsSync10(targetPath)) {
3760
+ if (existsSync11(targetPath)) {
3475
3761
  console.error(`Error: ${targetPath} already exists.`);
3476
3762
  process.exit(1);
3477
3763
  }
3478
- const template = readFileSync8(TEMPLATE_PATH, "utf-8");
3764
+ const template = readFileSync9(TEMPLATE_PATH, "utf-8");
3479
3765
  const contents = template.split("{{CHECK_NAME}}").join(name);
3480
3766
  writeFileSync6(targetPath, contents, "utf-8");
3481
3767
  chmodSync2(targetPath, 493);
@@ -3639,16 +3925,16 @@ function prEditCommand(args) {
3639
3925
 
3640
3926
  // src/commands/studio.ts
3641
3927
  import { spawn as spawn2 } from "node:child_process";
3642
- import { existsSync as existsSync11, readFileSync as readFileSync9 } from "node:fs";
3928
+ import { existsSync as existsSync12, readFileSync as readFileSync10 } from "node:fs";
3643
3929
  import { dirname as dirname4, join as join13 } from "node:path";
3644
3930
  function resolveStudioTarget(cwd) {
3645
3931
  let dir = cwd;
3646
3932
  for (;; ) {
3647
3933
  const webDir = join13(dir, "apps", "vinaya", "web");
3648
3934
  const pkgPath = join13(webDir, "package.json");
3649
- if (existsSync11(pkgPath)) {
3935
+ if (existsSync12(pkgPath)) {
3650
3936
  try {
3651
- const pkg = JSON.parse(readFileSync9(pkgPath, "utf-8"));
3937
+ const pkg = JSON.parse(readFileSync10(pkgPath, "utf-8"));
3652
3938
  if (pkg.name === "@atta/vinaya-web") {
3653
3939
  return { kind: "workspace", webDir };
3654
3940
  }
@@ -3681,7 +3967,7 @@ async function runStudio(cwd, args) {
3681
3967
  }
3682
3968
 
3683
3969
  // src/commands/upgrade.ts
3684
- import { existsSync as existsSync12, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "node:fs";
3970
+ import { existsSync as existsSync13, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "node:fs";
3685
3971
  import { join as join14 } from "node:path";
3686
3972
  function realDeps6() {
3687
3973
  return {
@@ -3699,11 +3985,11 @@ function flags2(args) {
3699
3985
  }
3700
3986
  function readManifest3(repoRoot) {
3701
3987
  const p = join14(repoRoot, CONFIG_PATH);
3702
- if (!existsSync12(p))
3988
+ if (!existsSync13(p))
3703
3989
  return { kind: "missing" };
3704
3990
  let raw;
3705
3991
  try {
3706
- raw = JSON.parse(readFileSync10(p, "utf-8"));
3992
+ raw = JSON.parse(readFileSync11(p, "utf-8"));
3707
3993
  } catch (err) {
3708
3994
  return { kind: "invalid", error: `invalid JSON: ${err.message}` };
3709
3995
  }
@@ -3720,7 +4006,7 @@ function readManifest3(repoRoot) {
3720
4006
  }
3721
4007
  function writeManifestVersion(repoRoot, manifest) {
3722
4008
  const configAbs = join14(repoRoot, CONFIG_PATH);
3723
- const seed = JSON.parse(readFileSync10(configAbs, "utf-8"));
4009
+ const seed = JSON.parse(readFileSync11(configAbs, "utf-8"));
3724
4010
  const updated = { ...manifest, version: MANAGED_MANIFEST_VERSION };
3725
4011
  writeFileSync7(configAbs, `${JSON.stringify({ ...seed, managed: updated }, null, 2)}
3726
4012
  `, "utf-8");
@@ -3734,7 +4020,7 @@ function planUpgrade(ops, repoRoot, manifest) {
3734
4020
  for (const op of ops) {
3735
4021
  if (op.kind === "create-file") {
3736
4022
  const abs2 = join14(repoRoot, op.path);
3737
- const exists = existsSync12(abs2);
4023
+ const exists = existsSync13(abs2);
3738
4024
  const owned = ownedFiles.has(op.path);
3739
4025
  let action;
3740
4026
  if (op.path === CONFIG_PATH) {
@@ -3744,7 +4030,7 @@ function planUpgrade(ops, repoRoot, manifest) {
3744
4030
  } else if (!exists) {
3745
4031
  action = "recreate";
3746
4032
  hasChanges = true;
3747
- } else if (readFileSync10(abs2, "utf-8") !== op.content) {
4033
+ } else if (readFileSync11(abs2, "utf-8") !== op.content) {
3748
4034
  action = "regenerate";
3749
4035
  hasChanges = true;
3750
4036
  } else {
@@ -3757,11 +4043,11 @@ function planUpgrade(ops, repoRoot, manifest) {
3757
4043
  let action;
3758
4044
  if (!owned) {
3759
4045
  action = "not-installed";
3760
- } else if (!existsSync12(abs2)) {
4046
+ } else if (!existsSync13(abs2)) {
3761
4047
  action = "recreate-host";
3762
4048
  hasChanges = true;
3763
4049
  } else {
3764
- const content = readFileSync10(abs2, "utf-8");
4050
+ const content = readFileSync11(abs2, "utf-8");
3765
4051
  const { begin, end } = markerLines(op.marker, op.comment);
3766
4052
  if (!(content.includes(begin) && content.includes(end))) {
3767
4053
  action = "recreate-append";
@@ -3838,7 +4124,7 @@ function renderUpgradeDiff(plan) {
3838
4124
  }
3839
4125
  function regenerateBlock(repoRoot, op) {
3840
4126
  const abs2 = join14(repoRoot, op.path);
3841
- const content = readFileSync10(abs2, "utf-8");
4127
+ const content = readFileSync11(abs2, "utf-8");
3842
4128
  const stripped = stripBlockFromContent(content, op.marker, op.comment);
3843
4129
  if (stripped !== null) {
3844
4130
  writeFileSync7(abs2, stripped.endsWith(`
@@ -4079,6 +4365,9 @@ var COMMANDS = [
4079
4365
  { flag: "--diff-only", description: "Scope diff-declared checks to changed files" },
4080
4366
  { flag: "--parallel[=n]", description: "Concurrency cap (default: cpu-derived)" }
4081
4367
  ],
4368
+ 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."
4370
+ ],
4082
4371
  status: "shipped"
4083
4372
  },
4084
4373
  {
@@ -4148,6 +4437,9 @@ var COMMANDS = [
4148
4437
  name: "doctor",
4149
4438
  description: "Diagnose hook, workflow, and config health — report only, never mutates",
4150
4439
  flags: [{ flag: "--json", description: "Enveloped JSON output (schema: 1)" }],
4440
+ details: [
4441
+ 'Carries the same env-declaration diagnostic `vinaya check` warns with — permanently, at `info` severity, not just ahead of the spawn-default flip — plus a `warn`-severity lint over suspicious `env` literal forms (a stray `"true"`/`"false"` string, or a high-entropy literal that reads like a leaked secret committed to config).'
4442
+ ],
4151
4443
  status: "shipped"
4152
4444
  },
4153
4445
  {
@@ -4252,7 +4544,7 @@ function printHelp() {
4252
4544
  // src/index.ts
4253
4545
  var PACKAGE_ROOT = join15(dirname5(fileURLToPath2(import.meta.url)), "..");
4254
4546
  function readVersion2() {
4255
- const pkg = JSON.parse(readFileSync11(join15(PACKAGE_ROOT, "package.json"), "utf-8"));
4547
+ const pkg = JSON.parse(readFileSync12(join15(PACKAGE_ROOT, "package.json"), "utf-8"));
4256
4548
  return pkg.version;
4257
4549
  }
4258
4550
  var [, , command, ...args] = process.argv;