@attalabs/vinaya 0.1.0 → 0.1.2
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/LICENSE +201 -661
- package/README.md +1 -1
- package/aeg-root/contracts/archivist-tranche-archivist.md +92 -0
- package/aeg-root/contracts/brief-developer.md +140 -0
- package/aeg-root/contracts/developer-reviewer.md +106 -0
- package/aeg-root/contracts/planner-brief.md +130 -0
- package/aeg-root/contracts/reviewer-archivist.md +96 -0
- package/aeg-root/contracts/tranche-archivist-planner.md +116 -0
- package/aeg-root/coordination.md +293 -0
- package/aeg-root/enforcement.md +159 -0
- package/aeg-root/glossary.md +32 -0
- package/aeg-root/process.md +371 -0
- package/aeg-root/roles/archivist.md +167 -0
- package/aeg-root/roles/brief-author.md +108 -0
- package/aeg-root/roles/developer.md +429 -0
- package/aeg-root/roles/planner.md +285 -0
- package/aeg-root/roles/principal.md +107 -0
- package/aeg-root/roles/reviewer.md +130 -0
- package/aeg-root/roles/security.md +121 -0
- package/aeg-root/roles/tranche-archivist.md +257 -0
- package/aeg-root/skills/aeg/SKILL.md +90 -0
- package/aeg-root/skills/aeg-roles/SKILL.md +58 -0
- package/aeg-root/skills/brief-authoring/SKILL.md +497 -0
- package/aeg-root/state-machine.md +642 -0
- package/aeg-root/templates/brief-template.md +103 -0
- package/aeg-root/templates/issue-rationale-template.md +35 -0
- package/aeg-root/templates/pr-report-template.md +68 -0
- package/aeg-root/tranche-model.md +304 -0
- package/dist/checks/bin/check-branch-topology.js +2840 -0
- package/dist/checks/bin/check-brief-shape.js +2775 -0
- package/dist/checks/bin/check-closes-n.js +2824 -0
- package/dist/checks/bin/check-coherence.js +2922 -0
- package/dist/checks/bin/check-dead-branch-push.js +2799 -0
- package/dist/checks/bin/check-dispatch-readiness.js +2923 -0
- package/dist/checks/bin/check-doc-coverage-push.js +2818 -0
- package/dist/checks/bin/check-doc-coverage.js +2816 -0
- package/dist/checks/bin/check-first-push-dispatch.js +2888 -0
- package/dist/checks/bin/check-issue-assignment.js +2863 -0
- package/dist/checks/bin/check-no-disk-state.js +2811 -0
- package/dist/checks/bin/check-reader-resolvable-prose.js +2820 -0
- package/dist/checks/bin/check-registry-gates.js +2923 -0
- package/dist/checks/bin/check-review-gate.js +2826 -0
- package/dist/checks/bin/check-single-plan-pr.js +2810 -0
- package/dist/checks/bin/check-test-plan.js +2775 -0
- package/dist/index.js +1563 -446
- package/package.json +7 -4
- package/templates/custom-check.template.ts +1 -1
package/dist/index.js
CHANGED
|
@@ -1,63 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { readFileSync as
|
|
5
|
-
import { dirname as
|
|
6
|
-
import { fileURLToPath as
|
|
4
|
+
import { readFileSync as readFileSync11 } from "node:fs";
|
|
5
|
+
import { dirname as dirname5, join as join15 } from "node:path";
|
|
6
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
7
7
|
|
|
8
|
-
// src/commands/
|
|
8
|
+
// src/commands/archive.ts
|
|
9
9
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
10
|
-
|
|
11
|
-
// src/checks/contract.ts
|
|
12
|
-
var CHECK_SCHEMA_VERSION = 1;
|
|
13
|
-
function emitCheckError(error) {
|
|
14
|
-
process.stderr.write(`${JSON.stringify(error)}
|
|
15
|
-
`);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// src/checks/registry.ts
|
|
19
|
-
import { dirname, join } from "node:path";
|
|
20
|
-
import { fileURLToPath } from "node:url";
|
|
21
|
-
var CLI_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
22
|
-
var BIN_DIR = join(CLI_ROOT, "src", "checks", "bin");
|
|
23
|
-
function coreCheckRegistry() {
|
|
24
|
-
return [
|
|
25
|
-
{
|
|
26
|
-
name: "brief-shape",
|
|
27
|
-
run: join(BIN_DIR, "check-brief-shape.ts"),
|
|
28
|
-
scope: "diff",
|
|
29
|
-
timeoutMs: 15000
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
name: "doc-coverage",
|
|
33
|
-
run: join(BIN_DIR, "check-doc-coverage.ts"),
|
|
34
|
-
scope: "diff",
|
|
35
|
-
timeoutMs: 15000
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
name: "coherence",
|
|
39
|
-
run: join(BIN_DIR, "check-coherence.ts"),
|
|
40
|
-
scope: "full",
|
|
41
|
-
timeoutMs: 30000
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
name: "dispatch-readiness",
|
|
45
|
-
run: join(BIN_DIR, "check-dispatch-readiness.ts"),
|
|
46
|
-
scope: "full",
|
|
47
|
-
timeoutMs: 30000
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
name: "reader-resolvable-prose",
|
|
51
|
-
run: join(BIN_DIR, "check-reader-resolvable-prose.ts"),
|
|
52
|
-
scope: "full",
|
|
53
|
-
timeoutMs: 30000
|
|
54
|
-
}
|
|
55
|
-
];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// src/checks/runner.ts
|
|
59
|
-
import { spawn } from "node:child_process";
|
|
60
|
-
import { cpus } from "node:os";
|
|
61
10
|
// ../../../packages/aeg-core/src/anchored-region.ts
|
|
62
11
|
function maskCode(body) {
|
|
63
12
|
const fill = (line) => " ".repeat(line.length);
|
|
@@ -151,6 +100,8 @@ function anchoredRegion(body, field) {
|
|
|
151
100
|
return null;
|
|
152
101
|
return body.slice(afterStart, afterStart + end.index);
|
|
153
102
|
}
|
|
103
|
+
// ../../../packages/aeg-forge-state/src/parse-rationale-deps.ts
|
|
104
|
+
var SECTION_HEADER = /\*\*Dependency rationale\*\*/i;
|
|
154
105
|
// ../../../packages/aeg-forge-state/src/gh.ts
|
|
155
106
|
import { execFile, execFileSync } from "node:child_process";
|
|
156
107
|
import { promisify } from "node:util";
|
|
@@ -370,17 +321,12 @@ var DERIVED_STATUSES = [
|
|
|
370
321
|
"incoherent"
|
|
371
322
|
];
|
|
372
323
|
var DERIVABLE_STATUSES = DERIVED_STATUSES.filter((s) => s !== "backlog");
|
|
373
|
-
// ../../../packages/aeg-core/src/file-classify.ts
|
|
374
|
-
var FROZEN_ARCHIVES = new Set([
|
|
375
|
-
"docs/decisions-legacy.md",
|
|
376
|
-
"apps/herald-ai/docs/herald-decisions-legacy.md",
|
|
377
|
-
"apps/vada-ai/docs/vada-decisions-legacy.md"
|
|
378
|
-
]);
|
|
379
324
|
// ../../../packages/aeg-core/src/waiver-label.ts
|
|
380
325
|
var WAIVER_LABEL = label("waiver-docs");
|
|
381
326
|
var WAIVER_LABEL_REVIEW = label("waiver-review");
|
|
382
327
|
|
|
383
328
|
// ../../../packages/aeg-core/src/doc-owners.ts
|
|
329
|
+
var DOC_OWNERS_PATH = ".vinaya/doc-owners";
|
|
384
330
|
function globToRegex(pat) {
|
|
385
331
|
let re = "^";
|
|
386
332
|
let i = 0;
|
|
@@ -620,7 +566,7 @@ function checkForgeTitle(title) {
|
|
|
620
566
|
}
|
|
621
567
|
// ../../../packages/aeg-core/src/issue-validation.ts
|
|
622
568
|
function hasRationaleField(body, labelPattern) {
|
|
623
|
-
const re = new RegExp(`(?:\\*\\*|^#{1,4}\\s+)\\s
|
|
569
|
+
const re = new RegExp(`(?:\\*\\*|^#{1,4}\\s+)\\s*(?:${labelPattern})`, "im");
|
|
624
570
|
return re.test(body);
|
|
625
571
|
}
|
|
626
572
|
var RATIONALE_FIELDS = [
|
|
@@ -633,8 +579,18 @@ var RATIONALE_FIELDS = [
|
|
|
633
579
|
{ name: "Stop-and-escalate", pattern: "Stop-and-escalate" },
|
|
634
580
|
{ name: "Docs to keep coherent", pattern: "Docs to keep coherent|§7" }
|
|
635
581
|
];
|
|
582
|
+
var DEPENDENCY_RATIONALE_FIELD_NAME = "Dependency rationale";
|
|
636
583
|
function checkIssueRationale(body) {
|
|
637
|
-
const errors =
|
|
584
|
+
const errors = [];
|
|
585
|
+
for (const f of RATIONALE_FIELDS) {
|
|
586
|
+
if (!hasRationaleField(body, f.pattern)) {
|
|
587
|
+
errors.push(`issue-validation ${f.name}: rationale field not found in the Issue body — every task Issue carries the full Planner's rationale (aeg-root/contracts/planner-brief.md).`);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (f.name === DEPENDENCY_RATIONALE_FIELD_NAME && !SECTION_HEADER.test(body)) {
|
|
591
|
+
errors.push(`issue-validation ${f.name}: rationale field found, but not in the form amend-deps requires — ` + "write `**Dependency rationale** — …`, not `**Dependency rationale:** …`. " + "`amendRationaleDeps` (the only sanctioned way to edit Depends-on/Conflicts-with) locates this " + "section by the exact anchor `**Dependency rationale**`; a colon inside the bold breaks that match " + "and the Issue becomes unamendable.");
|
|
592
|
+
}
|
|
593
|
+
}
|
|
638
594
|
return { status: errors.length > 0 ? "fail" : "pass", errors };
|
|
639
595
|
}
|
|
640
596
|
function isTaskIssueLabelSet(labels) {
|
|
@@ -643,116 +599,880 @@ function isTaskIssueLabelSet(labels) {
|
|
|
643
599
|
|
|
644
600
|
// ../../../packages/aeg-core/src/coherence-checks.ts
|
|
645
601
|
var R1_GRANDFATHERED_ISSUES = new Set([279, 280, 281, 282]);
|
|
602
|
+
// ../../../packages/aeg-core/src/verdict-extraction.ts
|
|
603
|
+
function extractVerdict(comments, valuePattern, missingLabel) {
|
|
604
|
+
const clearHits = comments.filter((c) => valuePattern.test(c));
|
|
605
|
+
if (clearHits.length > 0) {
|
|
606
|
+
const latest = clearHits[clearHits.length - 1];
|
|
607
|
+
const m = latest.match(valuePattern);
|
|
608
|
+
return { value: m[1].toUpperCase().replace(/[_-]/g, " "), danglingNote: null };
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
value: `no ${missingLabel} pass was run before merge — DANGLING, see below`,
|
|
612
|
+
danglingNote: `no ${missingLabel} verdict comment found on this PR`
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
function extractCodeReviewVerdict(comments) {
|
|
616
|
+
return extractVerdict(comments, /^[ \t]*(?:\*{1,3}|_{1,3})?VERDICT:\s*(APPROVE|REQUEST[ _-]?CHANGES|LGTM)(?![A-Za-z0-9])/im, "code-reviewer");
|
|
617
|
+
}
|
|
618
|
+
function extractSecurityReviewVerdict(comments) {
|
|
619
|
+
return extractVerdict(comments, /^[ \t]*(?:\*{1,3}|_{1,3})?VERDICT:\s*(PASS|FAIL)(?![A-Za-z0-9])/im, "security-review");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ../../../packages/aeg-core/src/archive-task.ts
|
|
623
|
+
var TASK_BRANCH_PATTERN = /^task\/([^/]+)\/([^/]+)$/;
|
|
624
|
+
function taskRefFromBranch(branch) {
|
|
625
|
+
const m = branch.match(TASK_BRANCH_PATTERN);
|
|
626
|
+
if (!m)
|
|
627
|
+
return null;
|
|
628
|
+
return { tranche: m[1], taskId: m[2] };
|
|
629
|
+
}
|
|
630
|
+
function isEligibleForProvenance(ref, issueLabels) {
|
|
631
|
+
if (ref !== null)
|
|
632
|
+
return true;
|
|
633
|
+
return hasLabel("tranche", issueLabels);
|
|
634
|
+
}
|
|
635
|
+
var PROVENANCE_HEADING = "### AEG provenance";
|
|
636
|
+
function hasProvenance(comments) {
|
|
637
|
+
return comments.some((c) => c.includes(PROVENANCE_HEADING));
|
|
638
|
+
}
|
|
639
|
+
function extractField(body, labelPattern, anchor) {
|
|
640
|
+
const anchored = anchor !== undefined ? anchoredRegion(body, anchor) : null;
|
|
641
|
+
const region = anchored ?? headerRegion(body);
|
|
642
|
+
const re = new RegExp(`^(?:\\*\\*)?\\s*${labelPattern}\\s*(?:\\*\\*)?\\s*:\\s*(?:\\*\\*)?\\s*([^\\n·]+)`, "im");
|
|
643
|
+
const m = region.match(re);
|
|
644
|
+
if (!m)
|
|
645
|
+
return null;
|
|
646
|
+
const value = m[1].trim();
|
|
647
|
+
return value.length > 0 ? value : null;
|
|
648
|
+
}
|
|
649
|
+
function closesRefs(text) {
|
|
650
|
+
return [...new Set([...text.matchAll(/Closes #(\d+)/gi)].map((m) => Number(m[1])))];
|
|
651
|
+
}
|
|
652
|
+
function extractIssue(body) {
|
|
653
|
+
const stripped = stripCode(body);
|
|
654
|
+
const bodyNums = closesRefs(stripped);
|
|
655
|
+
const anchored = anchoredRegion(stripped, "CLOSES");
|
|
656
|
+
if (anchored !== null) {
|
|
657
|
+
const anchorNums = closesRefs(anchored);
|
|
658
|
+
if (anchorNums.length === 0)
|
|
659
|
+
return { issue: null, extraIssues: bodyNums, outsideHeader: false };
|
|
660
|
+
const issue2 = anchorNums[0];
|
|
661
|
+
return { issue: issue2, extraIssues: bodyNums.filter((n) => n !== issue2), outsideHeader: false };
|
|
662
|
+
}
|
|
663
|
+
const headerNums = closesRefs(headerRegion(stripped));
|
|
664
|
+
if (bodyNums.length === 0)
|
|
665
|
+
return { issue: null, extraIssues: [], outsideHeader: false };
|
|
666
|
+
const issue = headerNums.length > 0 ? headerNums[0] : bodyNums[0];
|
|
667
|
+
return { issue, extraIssues: bodyNums.filter((n) => n !== issue), outsideHeader: headerNums.length === 0 };
|
|
668
|
+
}
|
|
669
|
+
function buildProvenanceBlock(facts) {
|
|
670
|
+
const ref = taskRefFromBranch(facts.headRefName);
|
|
671
|
+
const dangling = [];
|
|
672
|
+
const { issue, extraIssues, outsideHeader } = extractIssue(facts.body);
|
|
673
|
+
if (issue === null) {
|
|
674
|
+
dangling.push("no `Closes #N` found in PR body — Issue field is DANGLING, no Issue will be closed");
|
|
675
|
+
}
|
|
676
|
+
if (outsideHeader) {
|
|
677
|
+
dangling.push(`\`Closes #${issue}\` was found outside the PR body's header block — the canonical form places it before the first \`##\` heading`);
|
|
678
|
+
}
|
|
679
|
+
if (extraIssues.length > 0) {
|
|
680
|
+
dangling.push(`PR body references additional Issues (${extraIssues.map((n) => `#${n}`).join(", ")}) beyond the first — closing only #${issue}, the rest are flagged, not closed`);
|
|
681
|
+
}
|
|
682
|
+
const tier = readTierFromPrBody(facts.body);
|
|
683
|
+
if (tier === null)
|
|
684
|
+
dangling.push("no `Tier:` field found in PR body — Tier field is DANGLING");
|
|
685
|
+
const project = extractField(facts.body, "Project(?:\\(s\\))?", "PROJECT");
|
|
686
|
+
if (!project)
|
|
687
|
+
dangling.push("no `Project:` field found in PR body — Project(s) field is DANGLING");
|
|
688
|
+
const forField = extractField(facts.body, "For");
|
|
689
|
+
if (!forField)
|
|
690
|
+
dangling.push("no `For:` field found in PR body — Model/agent field is DANGLING");
|
|
691
|
+
const codeReview = extractCodeReviewVerdict(facts.comments);
|
|
692
|
+
if (codeReview.danglingNote)
|
|
693
|
+
dangling.push(codeReview.danglingNote);
|
|
694
|
+
const security = extractSecurityReviewVerdict(facts.comments);
|
|
695
|
+
if (security.danglingNote)
|
|
696
|
+
dangling.push(security.danglingNote);
|
|
697
|
+
const ticket = extractField(facts.body, "Ticket") ?? "none";
|
|
698
|
+
const taskLabel = ref ? `task ${ref.taskId} (tranche ${ref.tranche})` : `task (branch ${facts.headRefName})`;
|
|
699
|
+
const lines = [
|
|
700
|
+
`${PROVENANCE_HEADING} — ${taskLabel}`,
|
|
701
|
+
`- Issue: ${issue !== null ? `#${issue} (closed by merge)` : "DANGLING — no Closes #N in PR body"}`,
|
|
702
|
+
`- Tier: ${tier !== null ? tier : "DANGLING — no Tier field in PR body"}`,
|
|
703
|
+
"- Brief: in this PR body (the frozen intent)",
|
|
704
|
+
`- Project(s): ${project ?? "DANGLING — no Project field in PR body"}`,
|
|
705
|
+
`- Model/agent: ${forField ?? "DANGLING — no For field in PR body"}`,
|
|
706
|
+
`- Code review: ${codeReview.value}`,
|
|
707
|
+
`- Security: ${security.value}`,
|
|
708
|
+
`- Ticket: ${ticket}`,
|
|
709
|
+
`- Merged: ${facts.mergeSha} at ${facts.mergedAt}`
|
|
710
|
+
];
|
|
711
|
+
if (dangling.length > 0) {
|
|
712
|
+
lines.push("", `DANGLING: ${dangling.join("; ")}`);
|
|
713
|
+
}
|
|
714
|
+
return { block: lines.join(`
|
|
715
|
+
`), issue, dangling };
|
|
716
|
+
}
|
|
646
717
|
// ../../../packages/aeg-core/src/diagram-model.ts
|
|
647
718
|
import matter from "gray-matter";
|
|
648
719
|
// ../../../packages/aeg-core/src/reader-resolvable-prose.ts
|
|
649
720
|
var SWEPT_CLASSES = new Set(["ships", "reader-facing"]);
|
|
650
|
-
// src/
|
|
651
|
-
function
|
|
652
|
-
|
|
721
|
+
// ../../../packages/aeg-core/src/direct-main-push.ts
|
|
722
|
+
function checkDirectMainPush(fact) {
|
|
723
|
+
const [first] = fact.associatedMergedPrNumbers;
|
|
724
|
+
if (first !== undefined)
|
|
725
|
+
return { verdict: "legitimate", mergedPrNumber: first };
|
|
726
|
+
return { verdict: "direct-push", sha: fact.sha };
|
|
727
|
+
}
|
|
728
|
+
// ../../../packages/aeg-core/src/dead-branch-push-audit.ts
|
|
729
|
+
function findDeadBranchPushes(facts) {
|
|
730
|
+
return facts.filter((f) => new Date(f.latestCommitAt).getTime() > new Date(f.resolvedAt).getTime()).map(({ branch, prNumber, prState, resolvedAt, latestCommitAt }) => ({
|
|
731
|
+
branch,
|
|
732
|
+
prNumber,
|
|
733
|
+
prState,
|
|
734
|
+
resolvedAt,
|
|
735
|
+
latestCommitAt
|
|
736
|
+
}));
|
|
653
737
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
738
|
+
// src/lib/detect.ts
|
|
739
|
+
import { execFile as execFile4 } from "node:child_process";
|
|
740
|
+
import { existsSync } from "node:fs";
|
|
741
|
+
import { join } from "node:path";
|
|
742
|
+
import { promisify as promisify4 } from "node:util";
|
|
743
|
+
var execFileAsync4 = promisify4(execFile4);
|
|
744
|
+
async function detectGitRepo() {
|
|
745
|
+
try {
|
|
746
|
+
const { stdout: root } = await execFileAsync4("git", ["rev-parse", "--show-toplevel"]);
|
|
747
|
+
const repoRoot = root.trim();
|
|
748
|
+
let owner = "";
|
|
749
|
+
let repo = "";
|
|
750
|
+
try {
|
|
751
|
+
const { stdout: url } = await execFileAsync4("git", ["remote", "get-url", "origin"]);
|
|
752
|
+
const m = url.trim().match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
753
|
+
if (m) {
|
|
754
|
+
owner = m[1] ?? "";
|
|
755
|
+
repo = m[2] ?? "";
|
|
756
|
+
}
|
|
757
|
+
} catch {}
|
|
758
|
+
return { repoRoot, owner, repo };
|
|
759
|
+
} catch {
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
659
762
|
}
|
|
660
|
-
function
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
if (opts.changedFiles === null)
|
|
666
|
-
return false;
|
|
667
|
-
if (!spec.include || spec.include.length === 0)
|
|
763
|
+
async function checkGhAuth() {
|
|
764
|
+
try {
|
|
765
|
+
await execFileAsync4("gh", ["auth", "status"]);
|
|
766
|
+
return true;
|
|
767
|
+
} catch {
|
|
668
768
|
return false;
|
|
669
|
-
|
|
670
|
-
return !opts.changedFiles.some((f) => regexes.some((re) => re.test(f)));
|
|
769
|
+
}
|
|
671
770
|
}
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
let killTimer;
|
|
680
|
-
const timer = setTimeout(() => {
|
|
681
|
-
timedOut = true;
|
|
682
|
-
proc.kill("SIGTERM");
|
|
683
|
-
killTimer = setTimeout(() => proc.kill("SIGKILL"), KILL_GRACE_MS);
|
|
684
|
-
}, timeoutMs);
|
|
685
|
-
let stderrText = "";
|
|
686
|
-
proc.stderr.setEncoding("utf-8");
|
|
687
|
-
proc.stderr.on("data", (chunk) => {
|
|
688
|
-
stderrText += chunk;
|
|
689
|
-
});
|
|
690
|
-
proc.stdout.resume();
|
|
691
|
-
let spawnError;
|
|
692
|
-
const exitCode = await new Promise((resolve) => {
|
|
693
|
-
proc.on("close", (code) => resolve(code));
|
|
694
|
-
proc.on("error", (err) => {
|
|
695
|
-
spawnError = err;
|
|
696
|
-
resolve(null);
|
|
697
|
-
});
|
|
698
|
-
});
|
|
699
|
-
clearTimeout(timer);
|
|
700
|
-
clearTimeout(killTimer);
|
|
701
|
-
const durationMs = performance.now() - start;
|
|
702
|
-
if (timedOut) {
|
|
703
|
-
return { name: spec.name, status: "timeout", exitCode: null, errors: [], durationMs };
|
|
771
|
+
async function ghAuthStatus() {
|
|
772
|
+
try {
|
|
773
|
+
const { stderr, stdout } = await execFileAsync4("gh", ["auth", "status"]);
|
|
774
|
+
return { authenticated: true, detail: (stderr || stdout).trim() };
|
|
775
|
+
} catch (err) {
|
|
776
|
+
const stderr = err.stderr;
|
|
777
|
+
return { authenticated: false, detail: stderr?.trim() || "gh is not authenticated" };
|
|
704
778
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
agent_recovery_prompt: code === "ENOENT" ? `Install \`${spec.run}\` or correct the \`run\` field for check "${spec.name}" in vinaya.config.json, then re-run.` : `Inspect the \`run\` and \`args\` fields for check "${spec.name}" in vinaya.config.json, then re-run.`
|
|
718
|
-
}
|
|
719
|
-
],
|
|
720
|
-
durationMs
|
|
721
|
-
};
|
|
779
|
+
}
|
|
780
|
+
async function branchProtectionConfigured(owner, repo) {
|
|
781
|
+
if (!owner || !repo)
|
|
782
|
+
return null;
|
|
783
|
+
try {
|
|
784
|
+
await execFileAsync4("gh", ["api", `repos/${owner}/${repo}/branches/main/protection`]);
|
|
785
|
+
return true;
|
|
786
|
+
} catch (err) {
|
|
787
|
+
const stderr = err.stderr ?? "";
|
|
788
|
+
if (/\b404\b/.test(stderr))
|
|
789
|
+
return false;
|
|
790
|
+
return null;
|
|
722
791
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
792
|
+
}
|
|
793
|
+
function resolveHookDir(repoRoot) {
|
|
794
|
+
return existsSync(join(repoRoot, ".husky")) ? ".husky" : ".git/hooks";
|
|
795
|
+
}
|
|
796
|
+
function hookDirFromManifest(manifest, fallback) {
|
|
797
|
+
const block = manifest.blocks.find((b) => b.path.startsWith(".husky/") || b.path.startsWith(".git/hooks/"));
|
|
798
|
+
if (block?.path.startsWith(".husky/"))
|
|
799
|
+
return ".husky";
|
|
800
|
+
if (block?.path.startsWith(".git/hooks/"))
|
|
801
|
+
return ".git/hooks";
|
|
802
|
+
return fallback;
|
|
803
|
+
}
|
|
804
|
+
async function customHooksPath(repoRoot) {
|
|
805
|
+
try {
|
|
806
|
+
const { stdout } = await execFileAsync4("git", ["-C", repoRoot, "config", "--get", "core.hooksPath"]);
|
|
807
|
+
const v = stdout.trim();
|
|
808
|
+
if (!v)
|
|
809
|
+
return null;
|
|
810
|
+
if (v === ".husky" || v === ".husky/_")
|
|
811
|
+
return null;
|
|
812
|
+
return v;
|
|
813
|
+
} catch {
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function ghLabelGateway(repoRoot) {
|
|
818
|
+
return {
|
|
819
|
+
async exists(name) {
|
|
820
|
+
try {
|
|
821
|
+
const { stdout } = await execFileAsync4("gh", ["label", "list", "--json", "name"], { cwd: repoRoot });
|
|
822
|
+
const names = JSON.parse(stdout);
|
|
823
|
+
return names.some((l) => l.name === name);
|
|
824
|
+
} catch {
|
|
825
|
+
return false;
|
|
734
826
|
}
|
|
735
|
-
}
|
|
736
|
-
|
|
827
|
+
},
|
|
828
|
+
async create(name, color, description) {
|
|
829
|
+
await execFileAsync4("gh", ["label", "create", name, "--color", color, "--description", description], {
|
|
830
|
+
cwd: repoRoot
|
|
831
|
+
});
|
|
737
832
|
}
|
|
738
|
-
}
|
|
739
|
-
let status;
|
|
740
|
-
if (malformed) {
|
|
741
|
-
status = "error";
|
|
742
|
-
} else if (exitCode === 0) {
|
|
743
|
-
status = "pass";
|
|
744
|
-
} else if (exitCode === 1) {
|
|
745
|
-
status = "fail";
|
|
746
|
-
} else {
|
|
747
|
-
status = "error";
|
|
748
|
-
}
|
|
749
|
-
return { name: spec.name, status, exitCode, errors, durationMs };
|
|
833
|
+
};
|
|
750
834
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
835
|
+
|
|
836
|
+
// src/commands/archive.ts
|
|
837
|
+
function realDeps() {
|
|
838
|
+
return { detectRepo: detectGitRepo };
|
|
839
|
+
}
|
|
840
|
+
function sh(args, input) {
|
|
841
|
+
return execFileSync2(args[0], args.slice(1), { encoding: "utf8", input }).trim();
|
|
842
|
+
}
|
|
843
|
+
function shJson(args) {
|
|
844
|
+
return JSON.parse(sh(args));
|
|
845
|
+
}
|
|
846
|
+
function parseMergeSha(args) {
|
|
847
|
+
for (const a of args) {
|
|
848
|
+
const m = a.match(/^--merge-sha=(.+)$/);
|
|
849
|
+
if (m)
|
|
850
|
+
return m[1];
|
|
851
|
+
}
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
async function runArchive(args, deps) {
|
|
855
|
+
const repo = await deps.detectRepo();
|
|
856
|
+
if (!repo) {
|
|
857
|
+
console.error("Error: not a git repository. Run `vinaya archive` from inside your repo.");
|
|
858
|
+
return 1;
|
|
859
|
+
}
|
|
860
|
+
if (!repo.owner || !repo.repo) {
|
|
861
|
+
console.error("Error: could not resolve a GitHub owner/repo from the `origin` remote.");
|
|
862
|
+
return 1;
|
|
863
|
+
}
|
|
864
|
+
const repoFlag = `${repo.owner}/${repo.repo}`;
|
|
865
|
+
const mergeSha = parseMergeSha(args) ?? sh(["git", "rev-parse", "HEAD"]);
|
|
866
|
+
const associated = shJson(["gh", "api", `repos/${repoFlag}/commits/${mergeSha}/pulls`]);
|
|
867
|
+
if (associated.length === 0) {
|
|
868
|
+
console.log(`[vinaya archive] no associated PR for merge ${mergeSha} — skip.`);
|
|
869
|
+
return 0;
|
|
870
|
+
}
|
|
871
|
+
const prNumber = associated[0].number;
|
|
872
|
+
const pr = shJson([
|
|
873
|
+
"gh",
|
|
874
|
+
"pr",
|
|
875
|
+
"view",
|
|
876
|
+
String(prNumber),
|
|
877
|
+
"-R",
|
|
878
|
+
repoFlag,
|
|
879
|
+
"--json",
|
|
880
|
+
"number,headRefName,body,mergedAt,comments"
|
|
881
|
+
]);
|
|
882
|
+
const ref = taskRefFromBranch(pr.headRefName);
|
|
883
|
+
let issueLabels = [];
|
|
884
|
+
if (ref === null) {
|
|
885
|
+
const { issue: candidateIssue } = extractIssue(pr.body);
|
|
886
|
+
if (candidateIssue !== null) {
|
|
887
|
+
issueLabels = shJson([
|
|
888
|
+
"gh",
|
|
889
|
+
"issue",
|
|
890
|
+
"view",
|
|
891
|
+
String(candidateIssue),
|
|
892
|
+
"-R",
|
|
893
|
+
repoFlag,
|
|
894
|
+
"--json",
|
|
895
|
+
"labels"
|
|
896
|
+
]).labels.map((l) => l.name);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (!isEligibleForProvenance(ref, issueLabels)) {
|
|
900
|
+
console.log(`[vinaya archive] non-task branch (${pr.headRefName}) and closing Issue carries no vinaya/tranche:* label — skip.`);
|
|
901
|
+
return 0;
|
|
902
|
+
}
|
|
903
|
+
const comments = pr.comments.map((c) => c.body);
|
|
904
|
+
if (hasProvenance(comments)) {
|
|
905
|
+
console.log(`[vinaya archive] provenance already present on PR #${pr.number} — skip (idempotent).`);
|
|
906
|
+
return 0;
|
|
907
|
+
}
|
|
908
|
+
const facts = {
|
|
909
|
+
number: pr.number,
|
|
910
|
+
headRefName: pr.headRefName,
|
|
911
|
+
body: pr.body,
|
|
912
|
+
mergedAt: pr.mergedAt,
|
|
913
|
+
mergeSha,
|
|
914
|
+
comments
|
|
915
|
+
};
|
|
916
|
+
const { block, issue, dangling } = buildProvenanceBlock(facts);
|
|
917
|
+
console.log(`[vinaya archive] posting provenance block to PR #${pr.number}...`);
|
|
918
|
+
sh(["gh", "pr", "comment", String(pr.number), "-R", repoFlag, "--body-file", "-"], block);
|
|
919
|
+
console.log(`[vinaya archive] provenance block posted to PR #${pr.number}.`);
|
|
920
|
+
if (dangling.length > 0) {
|
|
921
|
+
console.log(`[vinaya archive] DANGLING (${dangling.length}): ${dangling.join("; ")}`);
|
|
922
|
+
}
|
|
923
|
+
if (issue !== null) {
|
|
924
|
+
console.log(`[vinaya archive] closing Issue #${issue}...`);
|
|
925
|
+
sh(["gh", "issue", "close", String(issue), "-R", repoFlag]);
|
|
926
|
+
const state = shJson([
|
|
927
|
+
"gh",
|
|
928
|
+
"issue",
|
|
929
|
+
"view",
|
|
930
|
+
String(issue),
|
|
931
|
+
"-R",
|
|
932
|
+
repoFlag,
|
|
933
|
+
"--json",
|
|
934
|
+
"state"
|
|
935
|
+
]).state;
|
|
936
|
+
if (state !== "CLOSED") {
|
|
937
|
+
console.error(`[vinaya archive] FAILED — Issue #${issue} did not confirm CLOSED (state: ${state}).`);
|
|
938
|
+
return 1;
|
|
939
|
+
}
|
|
940
|
+
console.log(`[vinaya archive] Issue #${issue} confirmed CLOSED.`);
|
|
941
|
+
} else {
|
|
942
|
+
console.log("[vinaya archive] no Issue to close — Closes #N absent from PR body.");
|
|
943
|
+
}
|
|
944
|
+
console.log("[vinaya archive] PASS.");
|
|
945
|
+
return 0;
|
|
946
|
+
}
|
|
947
|
+
async function archiveCommand(args) {
|
|
948
|
+
process.exit(await runArchive(args, realDeps()));
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// src/commands/audit.ts
|
|
952
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
953
|
+
|
|
954
|
+
// src/lib/envelope.ts
|
|
955
|
+
var ENVELOPE_SCHEMA_VERSION = 1;
|
|
956
|
+
function toEnvelope(data) {
|
|
957
|
+
return { schema: ENVELOPE_SCHEMA_VERSION, data };
|
|
958
|
+
}
|
|
959
|
+
function printJson(data) {
|
|
960
|
+
process.stdout.write(`${JSON.stringify(toEnvelope(data), null, 2)}
|
|
961
|
+
`);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// src/commands/audit.ts
|
|
965
|
+
function realDeps2() {
|
|
966
|
+
return { detectRepo: detectGitRepo };
|
|
967
|
+
}
|
|
968
|
+
var COMMAND_TIMEOUT_MS = 20000;
|
|
969
|
+
var DEAD_BRANCH_LABEL = label("dead-branch-push");
|
|
970
|
+
var DEAD_BRANCH_MARKER = `<!-- ${DEAD_BRANCH_LABEL} -->`;
|
|
971
|
+
var DEAD_BRANCH_LABEL_DESCRIPTION = "daily-drift: commits landed on this branch after its PR already resolved";
|
|
972
|
+
var DIRECT_PUSH_LABEL = label("direct-main-push");
|
|
973
|
+
var DIRECT_PUSH_MARKER = `<!-- ${DIRECT_PUSH_LABEL} -->`;
|
|
974
|
+
var DIRECT_PUSH_LABEL_DESCRIPTION = "A commit landed on main with no associated merged PR (ring-2 detection)";
|
|
975
|
+
function shSoft(args, input) {
|
|
976
|
+
try {
|
|
977
|
+
return execFileSync3(args[0], args.slice(1), {
|
|
978
|
+
encoding: "utf8",
|
|
979
|
+
input,
|
|
980
|
+
timeout: COMMAND_TIMEOUT_MS
|
|
981
|
+
}).trim();
|
|
982
|
+
} catch (err) {
|
|
983
|
+
const stderr = err.stderr;
|
|
984
|
+
const message = stderr ? stderr.toString().trim() : err.message;
|
|
985
|
+
console.log(`[vinaya audit] non-fatal: command failed — ${message}`);
|
|
986
|
+
return "";
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
function shSoftJson(args) {
|
|
990
|
+
const out = shSoft(args);
|
|
991
|
+
if (!out)
|
|
992
|
+
return null;
|
|
993
|
+
try {
|
|
994
|
+
return JSON.parse(out);
|
|
995
|
+
} catch {
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
function sh2(args, input) {
|
|
1000
|
+
return execFileSync3(args[0], args.slice(1), { encoding: "utf8", input }).trim();
|
|
1001
|
+
}
|
|
1002
|
+
function listTaskBranches() {
|
|
1003
|
+
const out = shSoft(["git", "ls-remote", "--heads", "origin", "task/*"]);
|
|
1004
|
+
if (!out)
|
|
1005
|
+
return [];
|
|
1006
|
+
return out.split(`
|
|
1007
|
+
`).map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
1008
|
+
const [sha, ref] = line.split("\t");
|
|
1009
|
+
return { name: ref.replace(/^refs\/heads\//, ""), sha };
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
function mostRecentPr(branch, repoFlag) {
|
|
1013
|
+
const entries = shSoftJson([
|
|
1014
|
+
"gh",
|
|
1015
|
+
"pr",
|
|
1016
|
+
"list",
|
|
1017
|
+
"--head",
|
|
1018
|
+
branch,
|
|
1019
|
+
"-R",
|
|
1020
|
+
repoFlag,
|
|
1021
|
+
"--state",
|
|
1022
|
+
"all",
|
|
1023
|
+
"--json",
|
|
1024
|
+
"number,state,mergedAt,closedAt",
|
|
1025
|
+
"--limit",
|
|
1026
|
+
"1"
|
|
1027
|
+
]);
|
|
1028
|
+
return entries?.[0] ?? null;
|
|
1029
|
+
}
|
|
1030
|
+
function commitDate(sha, repoFlag) {
|
|
1031
|
+
const json = shSoftJson([
|
|
1032
|
+
"gh",
|
|
1033
|
+
"api",
|
|
1034
|
+
`repos/${repoFlag}/commits/${sha}`
|
|
1035
|
+
]);
|
|
1036
|
+
return json?.commit?.committer?.date ?? null;
|
|
1037
|
+
}
|
|
1038
|
+
function gatherDeadBranchFacts(repoFlag) {
|
|
1039
|
+
const facts = [];
|
|
1040
|
+
for (const { name, sha } of listTaskBranches()) {
|
|
1041
|
+
const pr = mostRecentPr(name, repoFlag);
|
|
1042
|
+
if (!pr)
|
|
1043
|
+
continue;
|
|
1044
|
+
if (pr.state !== "MERGED" && pr.state !== "CLOSED")
|
|
1045
|
+
continue;
|
|
1046
|
+
const resolvedAt = pr.state === "MERGED" ? pr.mergedAt : pr.closedAt;
|
|
1047
|
+
if (!resolvedAt)
|
|
1048
|
+
continue;
|
|
1049
|
+
const latestCommitAt = commitDate(sha, repoFlag);
|
|
1050
|
+
if (!latestCommitAt)
|
|
1051
|
+
continue;
|
|
1052
|
+
facts.push({ branch: name, prNumber: pr.number, prState: pr.state, resolvedAt, latestCommitAt });
|
|
1053
|
+
}
|
|
1054
|
+
return facts;
|
|
1055
|
+
}
|
|
1056
|
+
function ensureLabelExists(repoFlag, name, description) {
|
|
1057
|
+
const existing = shSoftJson(["gh", "label", "list", "-R", repoFlag, "--json", "name", "--limit", "200"]) ?? [];
|
|
1058
|
+
if (existing.some((l) => l.name === name))
|
|
1059
|
+
return;
|
|
1060
|
+
shSoft(["gh", "label", "create", name, "-R", repoFlag, "--color", "B60205", "--description", description]);
|
|
1061
|
+
}
|
|
1062
|
+
function commentIdFromUrl(url) {
|
|
1063
|
+
const match = url.match(/issuecomment-(\d+)/);
|
|
1064
|
+
return match ? match[1] : null;
|
|
1065
|
+
}
|
|
1066
|
+
function flagDeadBranchPush(finding, repoFlag) {
|
|
1067
|
+
shSoft(["gh", "pr", "edit", String(finding.prNumber), "-R", repoFlag, "--add-label", DEAD_BRANCH_LABEL]);
|
|
1068
|
+
const body = [
|
|
1069
|
+
DEAD_BRANCH_MARKER,
|
|
1070
|
+
"**Dead-branch push detected by `vinaya audit`.**",
|
|
1071
|
+
"",
|
|
1072
|
+
`Branch \`${finding.branch}\` has a ${finding.prState} PR (#${finding.prNumber}, resolved ${finding.resolvedAt}), ` + `but its remote tip commit is dated ${finding.latestCommitAt} — after that resolution. ` + "This is a notification, not a merge gate — nothing was reverted or mutated."
|
|
1073
|
+
].join(`
|
|
1074
|
+
`);
|
|
1075
|
+
const prJson = shSoftJson([
|
|
1076
|
+
"gh",
|
|
1077
|
+
"pr",
|
|
1078
|
+
"view",
|
|
1079
|
+
String(finding.prNumber),
|
|
1080
|
+
"-R",
|
|
1081
|
+
repoFlag,
|
|
1082
|
+
"--json",
|
|
1083
|
+
"comments"
|
|
1084
|
+
]);
|
|
1085
|
+
const existing = prJson?.comments.find((c) => c.body.includes(DEAD_BRANCH_MARKER));
|
|
1086
|
+
const existingId = existing ? commentIdFromUrl(existing.url) : null;
|
|
1087
|
+
if (existingId) {
|
|
1088
|
+
shSoft(["gh", "api", `repos/${repoFlag}/issues/comments/${existingId}`, "-X", "PATCH", "-F", "body=@-"], body);
|
|
1089
|
+
} else {
|
|
1090
|
+
shSoft(["gh", "pr", "comment", String(finding.prNumber), "-R", repoFlag, "--body-file", "-"], body);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
function runDeadBranchAudit(repoFlag) {
|
|
1094
|
+
try {
|
|
1095
|
+
const facts = gatherDeadBranchFacts(repoFlag);
|
|
1096
|
+
const findings = findDeadBranchPushes(facts);
|
|
1097
|
+
if (findings.length > 0) {
|
|
1098
|
+
ensureLabelExists(repoFlag, DEAD_BRANCH_LABEL, DEAD_BRANCH_LABEL_DESCRIPTION);
|
|
1099
|
+
for (const finding of findings)
|
|
1100
|
+
flagDeadBranchPush(finding, repoFlag);
|
|
1101
|
+
}
|
|
1102
|
+
return { scanned: facts.length, findings };
|
|
1103
|
+
} catch (err) {
|
|
1104
|
+
console.log(`[vinaya audit] dead-branch scan error (never-red, reporting only): ${err.message}`);
|
|
1105
|
+
return { scanned: 0, findings: [] };
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
function fetchAssociatedMergedPrs(sha, repoFlag) {
|
|
1109
|
+
const out = sh2(["gh", "api", `repos/${repoFlag}/commits/${sha}/pulls`]);
|
|
1110
|
+
const prs = JSON.parse(out);
|
|
1111
|
+
return prs.filter((pr) => pr.merged_at !== null).map((pr) => pr.number);
|
|
1112
|
+
}
|
|
1113
|
+
function incidentAlreadyOpen(sha, repoFlag) {
|
|
1114
|
+
const out = sh2([
|
|
1115
|
+
"gh",
|
|
1116
|
+
"issue",
|
|
1117
|
+
"list",
|
|
1118
|
+
"-R",
|
|
1119
|
+
repoFlag,
|
|
1120
|
+
"--label",
|
|
1121
|
+
DIRECT_PUSH_LABEL,
|
|
1122
|
+
"--state",
|
|
1123
|
+
"open",
|
|
1124
|
+
"--search",
|
|
1125
|
+
sha,
|
|
1126
|
+
"--json",
|
|
1127
|
+
"number"
|
|
1128
|
+
]);
|
|
1129
|
+
const issues = JSON.parse(out || "[]");
|
|
1130
|
+
return issues.length > 0;
|
|
1131
|
+
}
|
|
1132
|
+
function openDirectPushIncident(sha, repoFlag) {
|
|
1133
|
+
ensureLabelExists(repoFlag, DIRECT_PUSH_LABEL, DIRECT_PUSH_LABEL_DESCRIPTION);
|
|
1134
|
+
if (incidentAlreadyOpen(sha, repoFlag)) {
|
|
1135
|
+
console.error(`[vinaya audit] incident already open for ${sha} — not duplicating.`);
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
const body = [
|
|
1139
|
+
DIRECT_PUSH_MARKER,
|
|
1140
|
+
"**Direct push to `main` detected — no associated merged PR.**",
|
|
1141
|
+
"",
|
|
1142
|
+
`Commit \`${sha}\` landed on \`main\` but the commits→pulls association API reports no merged pull request that introduced it.`,
|
|
1143
|
+
"",
|
|
1144
|
+
"This is a detection-only finding — nothing was reverted or mutated."
|
|
1145
|
+
].join(`
|
|
1146
|
+
`);
|
|
1147
|
+
sh2([
|
|
1148
|
+
"gh",
|
|
1149
|
+
"issue",
|
|
1150
|
+
"create",
|
|
1151
|
+
"-R",
|
|
1152
|
+
repoFlag,
|
|
1153
|
+
"--title",
|
|
1154
|
+
`Direct push to main detected: ${sha.slice(0, 12)}`,
|
|
1155
|
+
"--body",
|
|
1156
|
+
body,
|
|
1157
|
+
"--label",
|
|
1158
|
+
DIRECT_PUSH_LABEL
|
|
1159
|
+
]);
|
|
1160
|
+
}
|
|
1161
|
+
function runDirectMainPushCheck(sha, repoFlag) {
|
|
1162
|
+
const associatedMergedPrNumbers = fetchAssociatedMergedPrs(sha, repoFlag);
|
|
1163
|
+
const result = checkDirectMainPush({ sha, associatedMergedPrNumbers });
|
|
1164
|
+
if (result.verdict === "direct-push") {
|
|
1165
|
+
openDirectPushIncident(sha, repoFlag);
|
|
1166
|
+
return { verdict: "direct-push" };
|
|
1167
|
+
}
|
|
1168
|
+
return { verdict: "legitimate", mergedPrNumber: result.mergedPrNumber };
|
|
1169
|
+
}
|
|
1170
|
+
function parseSha(args) {
|
|
1171
|
+
for (const a of args) {
|
|
1172
|
+
const m = a.match(/^--sha=(.+)$/);
|
|
1173
|
+
if (m)
|
|
1174
|
+
return m[1];
|
|
1175
|
+
}
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
function parseOnly(args) {
|
|
1179
|
+
for (const a of args) {
|
|
1180
|
+
const m = a.match(/^--only=(dead-branches|direct-push)$/);
|
|
1181
|
+
if (m)
|
|
1182
|
+
return m[1];
|
|
1183
|
+
}
|
|
1184
|
+
return null;
|
|
1185
|
+
}
|
|
1186
|
+
async function runAudit(args, deps) {
|
|
1187
|
+
const jsonOutput = args.includes("--json");
|
|
1188
|
+
const only = parseOnly(args);
|
|
1189
|
+
const repo = await deps.detectRepo();
|
|
1190
|
+
if (!repo) {
|
|
1191
|
+
console.error("Error: not a git repository. Run `vinaya audit` from inside your repo.");
|
|
1192
|
+
return 1;
|
|
1193
|
+
}
|
|
1194
|
+
if (!repo.owner || !repo.repo) {
|
|
1195
|
+
console.error("Error: could not resolve a GitHub owner/repo from the `origin` remote.");
|
|
1196
|
+
return 1;
|
|
1197
|
+
}
|
|
1198
|
+
const repoFlag = `${repo.owner}/${repo.repo}`;
|
|
1199
|
+
const sha = parseSha(args) ?? sh2(["git", "rev-parse", "HEAD"]);
|
|
1200
|
+
const deadBranch = only === "direct-push" ? { scanned: 0, findings: [] } : runDeadBranchAudit(repoFlag);
|
|
1201
|
+
const directPush = only === "dead-branches" ? null : runDirectMainPushCheck(sha, repoFlag);
|
|
1202
|
+
const failed = directPush?.verdict === "direct-push";
|
|
1203
|
+
if (jsonOutput) {
|
|
1204
|
+
printJson({
|
|
1205
|
+
deadBranchAudit: only === "direct-push" ? null : { scanned: deadBranch.scanned, findings: deadBranch.findings },
|
|
1206
|
+
directMainPush: directPush ? { sha, ...directPush } : null
|
|
1207
|
+
});
|
|
1208
|
+
} else {
|
|
1209
|
+
process.stdout.write(`vinaya audit
|
|
1210
|
+
|
|
1211
|
+
`);
|
|
1212
|
+
if (only !== "direct-push") {
|
|
1213
|
+
process.stdout.write(`· [dead-branch-push] task branches scanned: ${deadBranch.scanned}, flagged: ${deadBranch.findings.length}
|
|
1214
|
+
`);
|
|
1215
|
+
for (const f of deadBranch.findings) {
|
|
1216
|
+
process.stdout.write(` ⚠ ${f.branch} — PR #${f.prNumber} (${f.prState}) resolved ${f.resolvedAt}, tip commit ${f.latestCommitAt}
|
|
1217
|
+
`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (directPush) {
|
|
1221
|
+
if (directPush.verdict === "legitimate") {
|
|
1222
|
+
process.stdout.write(`✓ [direct-main-push] ${sha} is legitimate — introduced by merged PR #${directPush.mergedPrNumber}.
|
|
1223
|
+
`);
|
|
1224
|
+
} else {
|
|
1225
|
+
process.stdout.write(`✗ [direct-main-push] VIOLATION: ${sha} has no associated merged PR.
|
|
1226
|
+
`);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return failed ? 1 : 0;
|
|
1231
|
+
}
|
|
1232
|
+
async function auditCommand(args) {
|
|
1233
|
+
process.exit(await runAudit(args, realDeps2()));
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// src/commands/check.ts
|
|
1237
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
1238
|
+
|
|
1239
|
+
// src/checks/contract.ts
|
|
1240
|
+
var CHECK_SCHEMA_VERSION = 1;
|
|
1241
|
+
function emitCheckError(error) {
|
|
1242
|
+
process.stderr.write(`${JSON.stringify(error)}
|
|
1243
|
+
`);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// src/checks/registry.ts
|
|
1247
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
1248
|
+
import { join as join3 } from "node:path";
|
|
1249
|
+
|
|
1250
|
+
// src/lib/package-root.ts
|
|
1251
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
1252
|
+
import { dirname, join as join2 } from "node:path";
|
|
1253
|
+
import { fileURLToPath } from "node:url";
|
|
1254
|
+
function packageRoot(moduleUrl) {
|
|
1255
|
+
let dir = dirname(fileURLToPath(moduleUrl));
|
|
1256
|
+
while (!existsSync2(join2(dir, "package.json"))) {
|
|
1257
|
+
const parent = dirname(dir);
|
|
1258
|
+
if (parent === dir)
|
|
1259
|
+
break;
|
|
1260
|
+
dir = parent;
|
|
1261
|
+
}
|
|
1262
|
+
return dir;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// src/checks/registry.ts
|
|
1266
|
+
var DIST_BIN_DIR = join3(packageRoot(import.meta.url), "dist", "checks", "bin");
|
|
1267
|
+
var SRC_BIN_DIR = join3(packageRoot(import.meta.url), "src", "checks", "bin");
|
|
1268
|
+
var BIN_DIR = existsSync3(DIST_BIN_DIR) ? DIST_BIN_DIR : SRC_BIN_DIR;
|
|
1269
|
+
var BIN_EXT = BIN_DIR === DIST_BIN_DIR ? ".js" : ".ts";
|
|
1270
|
+
function bin(name) {
|
|
1271
|
+
return join3(BIN_DIR, `${name}${BIN_EXT}`);
|
|
1272
|
+
}
|
|
1273
|
+
function coreCheckRegistry() {
|
|
1274
|
+
return [
|
|
1275
|
+
{
|
|
1276
|
+
name: "brief-shape",
|
|
1277
|
+
run: bin("check-brief-shape"),
|
|
1278
|
+
scope: "diff",
|
|
1279
|
+
timeoutMs: 15000
|
|
1280
|
+
},
|
|
1281
|
+
{
|
|
1282
|
+
name: "doc-coverage",
|
|
1283
|
+
run: bin("check-doc-coverage"),
|
|
1284
|
+
scope: "diff",
|
|
1285
|
+
timeoutMs: 15000
|
|
1286
|
+
},
|
|
1287
|
+
{
|
|
1288
|
+
name: "coherence",
|
|
1289
|
+
run: bin("check-coherence"),
|
|
1290
|
+
scope: "full",
|
|
1291
|
+
timeoutMs: 30000
|
|
1292
|
+
},
|
|
1293
|
+
{
|
|
1294
|
+
name: "dispatch-readiness",
|
|
1295
|
+
run: bin("check-dispatch-readiness"),
|
|
1296
|
+
scope: "full",
|
|
1297
|
+
timeoutMs: 30000
|
|
1298
|
+
},
|
|
1299
|
+
{
|
|
1300
|
+
name: "closes-n",
|
|
1301
|
+
run: bin("check-closes-n"),
|
|
1302
|
+
scope: "diff",
|
|
1303
|
+
timeoutMs: 15000
|
|
1304
|
+
},
|
|
1305
|
+
{
|
|
1306
|
+
name: "single-plan-pr",
|
|
1307
|
+
run: bin("check-single-plan-pr"),
|
|
1308
|
+
scope: "diff",
|
|
1309
|
+
timeoutMs: 15000
|
|
1310
|
+
},
|
|
1311
|
+
{
|
|
1312
|
+
name: "test-plan",
|
|
1313
|
+
run: bin("check-test-plan"),
|
|
1314
|
+
scope: "diff",
|
|
1315
|
+
timeoutMs: 15000
|
|
1316
|
+
},
|
|
1317
|
+
{
|
|
1318
|
+
name: "no-disk-state",
|
|
1319
|
+
run: bin("check-no-disk-state"),
|
|
1320
|
+
scope: "diff",
|
|
1321
|
+
timeoutMs: 15000
|
|
1322
|
+
},
|
|
1323
|
+
{
|
|
1324
|
+
name: "registry-gates",
|
|
1325
|
+
run: bin("check-registry-gates"),
|
|
1326
|
+
scope: "full",
|
|
1327
|
+
timeoutMs: 30000
|
|
1328
|
+
},
|
|
1329
|
+
{
|
|
1330
|
+
name: "review-gate",
|
|
1331
|
+
run: bin("check-review-gate"),
|
|
1332
|
+
scope: "full",
|
|
1333
|
+
timeoutMs: 30000
|
|
1334
|
+
},
|
|
1335
|
+
{
|
|
1336
|
+
name: "branch-topology",
|
|
1337
|
+
run: bin("check-branch-topology"),
|
|
1338
|
+
scope: "full",
|
|
1339
|
+
timeoutMs: 30000
|
|
1340
|
+
},
|
|
1341
|
+
{
|
|
1342
|
+
name: "dead-branch-push",
|
|
1343
|
+
run: bin("check-dead-branch-push"),
|
|
1344
|
+
scope: "full",
|
|
1345
|
+
timeoutMs: 30000
|
|
1346
|
+
},
|
|
1347
|
+
{
|
|
1348
|
+
name: "first-push-dispatch",
|
|
1349
|
+
run: bin("check-first-push-dispatch"),
|
|
1350
|
+
scope: "full",
|
|
1351
|
+
timeoutMs: 30000
|
|
1352
|
+
},
|
|
1353
|
+
{
|
|
1354
|
+
name: "doc-coverage-push",
|
|
1355
|
+
run: bin("check-doc-coverage-push"),
|
|
1356
|
+
scope: "diff",
|
|
1357
|
+
timeoutMs: 15000
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
name: "issue-assignment",
|
|
1361
|
+
run: bin("check-issue-assignment"),
|
|
1362
|
+
scope: "full",
|
|
1363
|
+
timeoutMs: 30000
|
|
1364
|
+
}
|
|
1365
|
+
];
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// src/checks/runner.ts
|
|
1369
|
+
import { spawn } from "node:child_process";
|
|
1370
|
+
import { cpus } from "node:os";
|
|
1371
|
+
function defaultParallelism() {
|
|
1372
|
+
return Math.max(1, cpus().length);
|
|
1373
|
+
}
|
|
1374
|
+
function isCheckError(value) {
|
|
1375
|
+
if (typeof value !== "object" || value === null)
|
|
1376
|
+
return false;
|
|
1377
|
+
const v = value;
|
|
1378
|
+
return v.schema === CHECK_SCHEMA_VERSION && typeof v.check === "string" && (v.severity === "error" || v.severity === "warning") && typeof v.message === "string" && typeof v.agent_recovery_prompt === "string";
|
|
1379
|
+
}
|
|
1380
|
+
function shouldSkip(spec, opts) {
|
|
1381
|
+
if (spec.scope !== "diff")
|
|
1382
|
+
return false;
|
|
1383
|
+
if (!opts.diffOnly)
|
|
1384
|
+
return false;
|
|
1385
|
+
if (opts.changedFiles === null)
|
|
1386
|
+
return false;
|
|
1387
|
+
if (!spec.include || spec.include.length === 0)
|
|
1388
|
+
return false;
|
|
1389
|
+
const regexes = spec.include.map(globToRegex);
|
|
1390
|
+
return !opts.changedFiles.some((f) => regexes.some((re) => re.test(f)));
|
|
1391
|
+
}
|
|
1392
|
+
var KILL_GRACE_MS = 2000;
|
|
1393
|
+
async function runOne(spec, timeoutMs) {
|
|
1394
|
+
const start = performance.now();
|
|
1395
|
+
const proc = spawn(spec.run, spec.args ?? [], {
|
|
1396
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1397
|
+
});
|
|
1398
|
+
let timedOut = false;
|
|
1399
|
+
let killTimer;
|
|
1400
|
+
const timer = setTimeout(() => {
|
|
1401
|
+
timedOut = true;
|
|
1402
|
+
proc.kill("SIGTERM");
|
|
1403
|
+
killTimer = setTimeout(() => proc.kill("SIGKILL"), KILL_GRACE_MS);
|
|
1404
|
+
}, timeoutMs);
|
|
1405
|
+
let stderrText = "";
|
|
1406
|
+
proc.stderr.setEncoding("utf-8");
|
|
1407
|
+
proc.stderr.on("data", (chunk) => {
|
|
1408
|
+
stderrText += chunk;
|
|
1409
|
+
});
|
|
1410
|
+
proc.stdout.resume();
|
|
1411
|
+
let spawnError;
|
|
1412
|
+
const exitCode = await new Promise((resolve) => {
|
|
1413
|
+
proc.on("close", (code) => resolve(code));
|
|
1414
|
+
proc.on("error", (err) => {
|
|
1415
|
+
spawnError = err;
|
|
1416
|
+
resolve(null);
|
|
1417
|
+
});
|
|
1418
|
+
});
|
|
1419
|
+
clearTimeout(timer);
|
|
1420
|
+
clearTimeout(killTimer);
|
|
1421
|
+
const durationMs = performance.now() - start;
|
|
1422
|
+
if (timedOut) {
|
|
1423
|
+
return { name: spec.name, status: "timeout", exitCode: null, errors: [], durationMs };
|
|
1424
|
+
}
|
|
1425
|
+
if (spawnError) {
|
|
1426
|
+
const code = spawnError.code;
|
|
1427
|
+
return {
|
|
1428
|
+
name: spec.name,
|
|
1429
|
+
status: "error",
|
|
1430
|
+
exitCode: null,
|
|
1431
|
+
errors: [
|
|
1432
|
+
{
|
|
1433
|
+
schema: 1,
|
|
1434
|
+
check: spec.name,
|
|
1435
|
+
severity: "error",
|
|
1436
|
+
message: code === "ENOENT" ? `Could not run check "${spec.name}": executable \`${spec.run}\` was not found on PATH.` : `Could not run check "${spec.name}": ${spawnError.message}`,
|
|
1437
|
+
agent_recovery_prompt: code === "ENOENT" ? `Install \`${spec.run}\` or correct the \`run\` field for check "${spec.name}" in vinaya.config.json, then re-run.` : `Inspect the \`run\` and \`args\` fields for check "${spec.name}" in vinaya.config.json, then re-run.`
|
|
1438
|
+
}
|
|
1439
|
+
],
|
|
1440
|
+
durationMs
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
const lines = stderrText.split(`
|
|
1444
|
+
`).map((l) => l.trim()).filter(Boolean);
|
|
1445
|
+
const errors = [];
|
|
1446
|
+
let malformed = false;
|
|
1447
|
+
for (const line of lines) {
|
|
1448
|
+
try {
|
|
1449
|
+
const parsed = JSON.parse(line);
|
|
1450
|
+
if (isCheckError(parsed)) {
|
|
1451
|
+
errors.push(parsed);
|
|
1452
|
+
} else {
|
|
1453
|
+
malformed = true;
|
|
1454
|
+
}
|
|
1455
|
+
} catch {
|
|
1456
|
+
malformed = true;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
let status;
|
|
1460
|
+
if (malformed) {
|
|
1461
|
+
status = "error";
|
|
1462
|
+
} else if (exitCode === 0) {
|
|
1463
|
+
status = "pass";
|
|
1464
|
+
} else if (exitCode === 1) {
|
|
1465
|
+
status = "fail";
|
|
1466
|
+
} else {
|
|
1467
|
+
status = "error";
|
|
1468
|
+
}
|
|
1469
|
+
return { name: spec.name, status, exitCode, errors, durationMs };
|
|
1470
|
+
}
|
|
1471
|
+
async function runChecks(specs, opts) {
|
|
1472
|
+
const results = new Array(specs.length);
|
|
1473
|
+
const toRun = [];
|
|
1474
|
+
for (let i = 0;i < specs.length; i++) {
|
|
1475
|
+
const spec = specs[i];
|
|
756
1476
|
if (shouldSkip(spec, opts)) {
|
|
757
1477
|
results[i] = { name: spec.name, status: "skipped", exitCode: null, errors: [], durationMs: 0 };
|
|
758
1478
|
} else {
|
|
@@ -774,9 +1494,9 @@ async function runChecks(specs, opts) {
|
|
|
774
1494
|
}
|
|
775
1495
|
|
|
776
1496
|
// src/lib/config.ts
|
|
777
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1497
|
+
import { existsSync as existsSync4, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
778
1498
|
import { homedir } from "node:os";
|
|
779
|
-
import { dirname as dirname2, join as
|
|
1499
|
+
import { dirname as dirname2, join as join4 } from "node:path";
|
|
780
1500
|
import { z } from "zod";
|
|
781
1501
|
var CheckEntrySchema = z.object({
|
|
782
1502
|
run: z.string(),
|
|
@@ -842,14 +1562,14 @@ var VinayaConfigSchema = z.object({
|
|
|
842
1562
|
briefSchema: BriefSchemaSchema.optional(),
|
|
843
1563
|
managed: ManagedManifestSchema.optional()
|
|
844
1564
|
});
|
|
845
|
-
var GLOBAL_VINAYA_HOME =
|
|
846
|
-
var GLOBAL_CONFIG_PATH =
|
|
1565
|
+
var GLOBAL_VINAYA_HOME = join4(homedir(), ".vinaya");
|
|
1566
|
+
var GLOBAL_CONFIG_PATH = join4(GLOBAL_VINAYA_HOME, "config.json");
|
|
847
1567
|
var LOCAL_CONFIG_FILENAME = "vinaya.config.json";
|
|
848
1568
|
function findLocalConfig() {
|
|
849
1569
|
let dir = process.cwd();
|
|
850
1570
|
while (true) {
|
|
851
|
-
const candidate =
|
|
852
|
-
if (
|
|
1571
|
+
const candidate = join4(dir, LOCAL_CONFIG_FILENAME);
|
|
1572
|
+
if (existsSync4(candidate))
|
|
853
1573
|
return candidate;
|
|
854
1574
|
const parent = dirname2(dir);
|
|
855
1575
|
if (parent === dir)
|
|
@@ -861,7 +1581,7 @@ function configPath() {
|
|
|
861
1581
|
const local = findLocalConfig();
|
|
862
1582
|
if (local)
|
|
863
1583
|
return local;
|
|
864
|
-
if (
|
|
1584
|
+
if (existsSync4(GLOBAL_CONFIG_PATH))
|
|
865
1585
|
return GLOBAL_CONFIG_PATH;
|
|
866
1586
|
return null;
|
|
867
1587
|
}
|
|
@@ -883,20 +1603,10 @@ function loadConfigChecked() {
|
|
|
883
1603
|
return { ok: true, config: parsed.data };
|
|
884
1604
|
}
|
|
885
1605
|
|
|
886
|
-
// src/lib/envelope.ts
|
|
887
|
-
var ENVELOPE_SCHEMA_VERSION = 1;
|
|
888
|
-
function toEnvelope(data) {
|
|
889
|
-
return { schema: ENVELOPE_SCHEMA_VERSION, data };
|
|
890
|
-
}
|
|
891
|
-
function printJson(data) {
|
|
892
|
-
process.stdout.write(`${JSON.stringify(toEnvelope(data), null, 2)}
|
|
893
|
-
`);
|
|
894
|
-
}
|
|
895
|
-
|
|
896
1606
|
// src/commands/check.ts
|
|
897
1607
|
function git(args) {
|
|
898
1608
|
try {
|
|
899
|
-
return
|
|
1609
|
+
return execFileSync4("git", args, { encoding: "utf8" }).trim();
|
|
900
1610
|
} catch {
|
|
901
1611
|
return "";
|
|
902
1612
|
}
|
|
@@ -983,16 +1693,225 @@ async function checkCommand(args) {
|
|
|
983
1693
|
process.exit(failed ? 1 : 0);
|
|
984
1694
|
}
|
|
985
1695
|
|
|
1696
|
+
// src/commands/demo.ts
|
|
1697
|
+
import { execFileSync as execFileSync5, spawnSync } from "node:child_process";
|
|
1698
|
+
import { existsSync as existsSync5, readFileSync as readFileSync2, rmSync, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1699
|
+
import { isAbsolute, join as join5 } from "node:path";
|
|
1700
|
+
var DEMO_BRANCH_PREFIX = "vinaya/demo-break-";
|
|
1701
|
+
var FIXTURE_PATH = ".vinaya-demo-brief.md";
|
|
1702
|
+
var STATE_FILENAME = "vinaya-demo-state.json";
|
|
1703
|
+
function gitCapture(repoRoot, args) {
|
|
1704
|
+
return execFileSync5("git", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
1705
|
+
}
|
|
1706
|
+
function currentBranchName(repoRoot) {
|
|
1707
|
+
try {
|
|
1708
|
+
return gitCapture(repoRoot, ["symbolic-ref", "--short", "HEAD"]);
|
|
1709
|
+
} catch {
|
|
1710
|
+
return "HEAD";
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
function branchExists(repoRoot, name) {
|
|
1714
|
+
try {
|
|
1715
|
+
execFileSync5("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`], {
|
|
1716
|
+
cwd: repoRoot,
|
|
1717
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1718
|
+
});
|
|
1719
|
+
return true;
|
|
1720
|
+
} catch {
|
|
1721
|
+
return false;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
function createDemoBranch(repoRoot) {
|
|
1725
|
+
for (let attempt = 0;attempt < 5; attempt++) {
|
|
1726
|
+
const candidate = `${DEMO_BRANCH_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1727
|
+
if (!branchExists(repoRoot, candidate)) {
|
|
1728
|
+
gitCapture(repoRoot, ["checkout", "-b", candidate]);
|
|
1729
|
+
return candidate;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
throw new Error("Could not generate a collision-free demo branch name after 5 attempts.");
|
|
1733
|
+
}
|
|
1734
|
+
function gitDirAbs(repoRoot) {
|
|
1735
|
+
const raw = gitCapture(repoRoot, ["rev-parse", "--git-dir"]);
|
|
1736
|
+
return isAbsolute(raw) ? raw : join5(repoRoot, raw);
|
|
1737
|
+
}
|
|
1738
|
+
function recoverFromCrash(repoRoot, statePath) {
|
|
1739
|
+
const currentBranch = currentBranchName(repoRoot);
|
|
1740
|
+
let state = null;
|
|
1741
|
+
if (existsSync5(statePath)) {
|
|
1742
|
+
try {
|
|
1743
|
+
state = JSON.parse(readFileSync2(statePath, "utf-8"));
|
|
1744
|
+
} catch {
|
|
1745
|
+
state = null;
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
const onStrayBranch = currentBranch.startsWith(DEMO_BRANCH_PREFIX);
|
|
1749
|
+
if (!state) {
|
|
1750
|
+
if (onStrayBranch) {
|
|
1751
|
+
throw new Error(`On a leftover demo branch \`${currentBranch}\` with no recoverable state file. ` + "Checkout the branch you want manually, then re-run `vinaya demo break`.");
|
|
1752
|
+
}
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
process.stdout.write(`→ Found a leftover demo branch from a previous run — cleaning it up first.
|
|
1756
|
+
`);
|
|
1757
|
+
if (onStrayBranch) {
|
|
1758
|
+
gitCapture(repoRoot, ["reset", "--hard"]);
|
|
1759
|
+
gitCapture(repoRoot, ["clean", "-fd"]);
|
|
1760
|
+
if (!branchExists(repoRoot, state.originalBranch)) {
|
|
1761
|
+
throw new Error(`Recorded original branch \`${state.originalBranch}\` no longer exists. ` + "Checkout the branch you want manually, then re-run `vinaya demo break`.");
|
|
1762
|
+
}
|
|
1763
|
+
gitCapture(repoRoot, ["checkout", state.originalBranch]);
|
|
1764
|
+
}
|
|
1765
|
+
if (branchExists(repoRoot, state.demoBranch)) {
|
|
1766
|
+
gitCapture(repoRoot, ["branch", "-D", state.demoBranch]);
|
|
1767
|
+
}
|
|
1768
|
+
rmSync(statePath, { force: true });
|
|
1769
|
+
}
|
|
1770
|
+
function attemptCommit(repoRoot, demoBranch, prBody, message) {
|
|
1771
|
+
const result = spawnSync("git", ["commit", "-m", message], {
|
|
1772
|
+
cwd: repoRoot,
|
|
1773
|
+
encoding: "utf8",
|
|
1774
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1775
|
+
env: { ...process.env, PR_BODY: prBody, BRANCH: demoBranch }
|
|
1776
|
+
});
|
|
1777
|
+
return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
1778
|
+
}
|
|
1779
|
+
function briefFixture(opts) {
|
|
1780
|
+
const tierLine = opts.includeTier ? `Tier: 1
|
|
1781
|
+
` : "";
|
|
1782
|
+
return `For: vinaya demo
|
|
1783
|
+
Project: vinaya-demo-fixture
|
|
1784
|
+
${tierLine}
|
|
1785
|
+
## Summary
|
|
1786
|
+
Fixture brief staged only by \`vinaya demo break\` — discarded with the demo branch.
|
|
1787
|
+
|
|
1788
|
+
## Technical surface map
|
|
1789
|
+
None — fixture content only; no real files change.
|
|
1790
|
+
|
|
1791
|
+
## Documentation-update list
|
|
1792
|
+
None — fixture content only.
|
|
1793
|
+
|
|
1794
|
+
## Test Plan
|
|
1795
|
+
Test Plan: unit-tests-only
|
|
1796
|
+
|
|
1797
|
+
## Stop Conditions
|
|
1798
|
+
Autonomy: Do not stop to ask clarifying questions — this is a scripted demo; proceed and clean up automatically.
|
|
1799
|
+
|
|
1800
|
+
Illustrative Step 0 (never actually run by this demo):
|
|
1801
|
+
\`\`\`
|
|
1802
|
+
git worktree add .worktrees/demo-fixture -b demo/fixture origin/main
|
|
1803
|
+
\`\`\`
|
|
1804
|
+
|
|
1805
|
+
Closes #0 (fixture value — no real Issue; discarded with the demo branch)
|
|
1806
|
+
`;
|
|
1807
|
+
}
|
|
1808
|
+
function cleanup(repoRoot, originalBranch, demoBranch, statePath) {
|
|
1809
|
+
gitCapture(repoRoot, ["reset", "--hard"]);
|
|
1810
|
+
gitCapture(repoRoot, ["clean", "-fd"]);
|
|
1811
|
+
gitCapture(repoRoot, ["checkout", originalBranch]);
|
|
1812
|
+
gitCapture(repoRoot, ["branch", "-D", demoBranch]);
|
|
1813
|
+
if (existsSync5(statePath))
|
|
1814
|
+
rmSync(statePath, { force: true });
|
|
1815
|
+
}
|
|
1816
|
+
async function runDemoBreak(repoRoot, args) {
|
|
1817
|
+
const keep = args.includes("--keep");
|
|
1818
|
+
const hookDir = resolveHookDir(repoRoot);
|
|
1819
|
+
const hookPath = join5(repoRoot, hookDir, "pre-commit");
|
|
1820
|
+
if (!existsSync5(hookPath)) {
|
|
1821
|
+
console.error("Vinaya hooks are not installed in this repo. Run `vinaya init` first.");
|
|
1822
|
+
return 1;
|
|
1823
|
+
}
|
|
1824
|
+
const statePath = join5(gitDirAbs(repoRoot), STATE_FILENAME);
|
|
1825
|
+
recoverFromCrash(repoRoot, statePath);
|
|
1826
|
+
const dirty = gitCapture(repoRoot, ["status", "--porcelain"]);
|
|
1827
|
+
if (dirty.length > 0) {
|
|
1828
|
+
console.error("Working tree has uncommitted changes. Commit or stash them before running `vinaya demo break` — " + "the demo never touches a dirty tree.");
|
|
1829
|
+
return 1;
|
|
1830
|
+
}
|
|
1831
|
+
const originalBranch = currentBranchName(repoRoot);
|
|
1832
|
+
if (originalBranch === "HEAD") {
|
|
1833
|
+
console.error("Cannot run `vinaya demo break` from a detached HEAD — checkout a branch first.");
|
|
1834
|
+
return 1;
|
|
1835
|
+
}
|
|
1836
|
+
const demoBranch = createDemoBranch(repoRoot);
|
|
1837
|
+
writeFileSync2(statePath, JSON.stringify({ originalBranch, demoBranch }), "utf-8");
|
|
1838
|
+
process.stdout.write(`
|
|
1839
|
+
→ Created isolated branch \`${demoBranch}\` off \`${originalBranch}\`.
|
|
1840
|
+
|
|
1841
|
+
`);
|
|
1842
|
+
const fixturePath = join5(repoRoot, FIXTURE_PATH);
|
|
1843
|
+
writeFileSync2(fixturePath, briefFixture({ includeTier: false }), "utf-8");
|
|
1844
|
+
gitCapture(repoRoot, ["add", FIXTURE_PATH]);
|
|
1845
|
+
process.stdout.write(`Staged \`${FIXTURE_PATH}\` — a brief missing its required \`Tier:\` field.
|
|
1846
|
+
Attempting to commit…
|
|
1847
|
+
|
|
1848
|
+
`);
|
|
1849
|
+
const badResult = attemptCommit(repoRoot, demoBranch, briefFixture({ includeTier: false }), "Chore(demo): stage a malformed brief");
|
|
1850
|
+
if (badResult.status === 0) {
|
|
1851
|
+
console.error("Expected the installed hook to refuse this commit, but it passed — the demo cannot show a real refusal here.");
|
|
1852
|
+
cleanup(repoRoot, originalBranch, demoBranch, statePath);
|
|
1853
|
+
return 1;
|
|
1854
|
+
}
|
|
1855
|
+
process.stdout.write(`✗ Commit refused — the real installed hook output:
|
|
1856
|
+
|
|
1857
|
+
`);
|
|
1858
|
+
process.stdout.write(badResult.stdout);
|
|
1859
|
+
process.stdout.write(badResult.stderr);
|
|
1860
|
+
process.stdout.write(`
|
|
1861
|
+
→ Fixing the brief: adding the missing \`Tier:\` field…
|
|
1862
|
+
|
|
1863
|
+
`);
|
|
1864
|
+
writeFileSync2(fixturePath, briefFixture({ includeTier: true }), "utf-8");
|
|
1865
|
+
gitCapture(repoRoot, ["add", FIXTURE_PATH]);
|
|
1866
|
+
const goodResult = attemptCommit(repoRoot, demoBranch, briefFixture({ includeTier: true }), "Chore(demo): fix the brief and commit");
|
|
1867
|
+
if (goodResult.status !== 0) {
|
|
1868
|
+
console.error(`The fixed brief still failed the hook — this demo cannot proceed:
|
|
1869
|
+
`);
|
|
1870
|
+
console.error(goodResult.stdout);
|
|
1871
|
+
console.error(goodResult.stderr);
|
|
1872
|
+
cleanup(repoRoot, originalBranch, demoBranch, statePath);
|
|
1873
|
+
return 1;
|
|
1874
|
+
}
|
|
1875
|
+
process.stdout.write(`✓ Commit passed — the fix worked.
|
|
1876
|
+
`);
|
|
1877
|
+
if (keep) {
|
|
1878
|
+
if (existsSync5(statePath))
|
|
1879
|
+
unlinkSync(statePath);
|
|
1880
|
+
process.stdout.write(`
|
|
1881
|
+
--keep: leaving \`${demoBranch}\` checked out for you to inspect. Clean up with:
|
|
1882
|
+
|
|
1883
|
+
`);
|
|
1884
|
+
process.stdout.write(` git checkout ${originalBranch} && git branch -D ${demoBranch}
|
|
1885
|
+
`);
|
|
1886
|
+
return 0;
|
|
1887
|
+
}
|
|
1888
|
+
cleanup(repoRoot, originalBranch, demoBranch, statePath);
|
|
1889
|
+
process.stdout.write(`
|
|
1890
|
+
✓ Cleaned up — back on \`${originalBranch}\`, \`${demoBranch}\` deleted.
|
|
1891
|
+
`);
|
|
1892
|
+
return 0;
|
|
1893
|
+
}
|
|
1894
|
+
async function demoBreakCommand(args) {
|
|
1895
|
+
const repo = await detectGitRepo();
|
|
1896
|
+
if (!repo) {
|
|
1897
|
+
console.error("Error: not a git repository. Run `vinaya demo break` from inside your repo.");
|
|
1898
|
+
process.exit(1);
|
|
1899
|
+
}
|
|
1900
|
+
const code = await runDemoBreak(repo.repoRoot, args);
|
|
1901
|
+
process.exit(code);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
986
1904
|
// src/commands/doctor.ts
|
|
987
|
-
import { existsSync as
|
|
988
|
-
import {
|
|
989
|
-
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1905
|
+
import { existsSync as existsSync7, readFileSync as readFileSync4, statSync } from "node:fs";
|
|
1906
|
+
import { join as join8 } from "node:path";
|
|
990
1907
|
|
|
991
1908
|
// src/lib/artifacts.ts
|
|
1909
|
+
import { join as join6 } from "node:path";
|
|
992
1910
|
var CONFIG_PATH = "vinaya.config.json";
|
|
993
1911
|
var DOCTRINE_POINTER_PATH = "VINAYA.md";
|
|
994
1912
|
var CHECKS_WORKFLOW_PATH = ".github/workflows/vinaya-checks.yml";
|
|
995
1913
|
var REVIEW_WORKFLOW_PATH = ".github/workflows/vinaya-review.yml";
|
|
1914
|
+
var ARCHIVIST_WORKFLOW_PATH = ".github/workflows/vinaya-archivist.yml";
|
|
996
1915
|
var MANAGED_NOTE = "Managed by Vinaya — created by `vinaya init`. `vinaya upgrade` regenerates it; `vinaya eject` removes it.";
|
|
997
1916
|
function starterConfig() {
|
|
998
1917
|
return {
|
|
@@ -1039,7 +1958,7 @@ jobs:
|
|
|
1039
1958
|
with:
|
|
1040
1959
|
node-version: 20
|
|
1041
1960
|
- name: Run checks
|
|
1042
|
-
run: npx --yes vinaya check --all --diff-only
|
|
1961
|
+
run: npx --yes @attalabs/vinaya check --all --diff-only
|
|
1043
1962
|
`;
|
|
1044
1963
|
}
|
|
1045
1964
|
function reviewWorkflow() {
|
|
@@ -1058,44 +1977,123 @@ on:
|
|
|
1058
1977
|
issue_comment:
|
|
1059
1978
|
types: [created]
|
|
1060
1979
|
|
|
1061
|
-
jobs:
|
|
1062
|
-
vinaya-review:
|
|
1063
|
-
name: vinaya review gate
|
|
1064
|
-
if: >
|
|
1065
|
-
github.event_name == 'pull_request' ||
|
|
1066
|
-
(github.event.issue.pull_request != null && contains(github.event.comment.body, 'VERDICT'))
|
|
1980
|
+
jobs:
|
|
1981
|
+
vinaya-review:
|
|
1982
|
+
name: vinaya review gate
|
|
1983
|
+
if: >
|
|
1984
|
+
github.event_name == 'pull_request' ||
|
|
1985
|
+
(github.event.issue.pull_request != null && contains(github.event.comment.body, 'VERDICT'))
|
|
1986
|
+
runs-on: ubuntu-latest
|
|
1987
|
+
permissions:
|
|
1988
|
+
contents: read
|
|
1989
|
+
pull-requests: read
|
|
1990
|
+
issues: read
|
|
1991
|
+
steps:
|
|
1992
|
+
# issue_comment payloads carry no PR head SHA/branch — resolve them
|
|
1993
|
+
# before checkout, and check out that exact commit (the event's default
|
|
1994
|
+
# ref is the repo's default branch, not the PR head).
|
|
1995
|
+
- name: Resolve PR head
|
|
1996
|
+
id: pr
|
|
1997
|
+
env:
|
|
1998
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
1999
|
+
run: |
|
|
2000
|
+
if [ "\${{ github.event_name }}" = "pull_request" ]; then
|
|
2001
|
+
NUMBER="\${{ github.event.pull_request.number }}"
|
|
2002
|
+
else
|
|
2003
|
+
NUMBER="\${{ github.event.issue.number }}"
|
|
2004
|
+
fi
|
|
2005
|
+
SHA=$(gh pr view "$NUMBER" --repo "\${{ github.repository }}" --json headRefOid -q .headRefOid)
|
|
2006
|
+
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
|
2007
|
+
- uses: actions/checkout@v4
|
|
2008
|
+
with:
|
|
2009
|
+
ref: \${{ steps.pr.outputs.sha }}
|
|
2010
|
+
fetch-depth: 0
|
|
2011
|
+
- uses: actions/setup-node@v4
|
|
2012
|
+
with:
|
|
2013
|
+
node-version: 20
|
|
2014
|
+
- name: Review gate
|
|
2015
|
+
env:
|
|
2016
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
2017
|
+
run: npx --yes @attalabs/vinaya check --all
|
|
2018
|
+
`;
|
|
2019
|
+
}
|
|
2020
|
+
function archivistWorkflow() {
|
|
2021
|
+
return `# ${MANAGED_NOTE}
|
|
2022
|
+
#
|
|
2023
|
+
# The ring-2 post-merge/scheduled mechanisms: per-task Archivist provenance
|
|
2024
|
+
# + close-out (post-merge), dead-branch-push drift (daily-drift, a
|
|
2025
|
+
# notification channel — never fails red), and direct-main-push detection
|
|
2026
|
+
# (direct-main-push-detection, a real pass/fail).
|
|
2027
|
+
name: Vinaya Archivist
|
|
2028
|
+
|
|
2029
|
+
on:
|
|
2030
|
+
push:
|
|
2031
|
+
branches: [main]
|
|
2032
|
+
schedule:
|
|
2033
|
+
- cron: "0 2 * * *" # daily at 02:00 UTC
|
|
2034
|
+
workflow_dispatch:
|
|
2035
|
+
|
|
2036
|
+
jobs:
|
|
2037
|
+
post-merge:
|
|
2038
|
+
name: Post-Merge Archivist
|
|
2039
|
+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
2040
|
+
runs-on: ubuntu-latest
|
|
2041
|
+
permissions:
|
|
2042
|
+
contents: read
|
|
2043
|
+
issues: write
|
|
2044
|
+
pull-requests: write
|
|
2045
|
+
steps:
|
|
2046
|
+
- uses: actions/checkout@v4
|
|
2047
|
+
with:
|
|
2048
|
+
fetch-depth: 0
|
|
2049
|
+
- uses: actions/setup-node@v4
|
|
2050
|
+
with:
|
|
2051
|
+
node-version: 20
|
|
2052
|
+
- name: Run vinaya archive
|
|
2053
|
+
env:
|
|
2054
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
2055
|
+
run: npx --yes @attalabs/vinaya archive --merge-sha=\${{ github.sha }}
|
|
2056
|
+
|
|
2057
|
+
daily-drift:
|
|
2058
|
+
name: Daily Drift Check (dead-branch pushes)
|
|
2059
|
+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
|
1067
2060
|
runs-on: ubuntu-latest
|
|
1068
2061
|
permissions:
|
|
1069
2062
|
contents: read
|
|
1070
|
-
|
|
1071
|
-
|
|
2063
|
+
issues: write
|
|
2064
|
+
pull-requests: write
|
|
1072
2065
|
steps:
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
-
|
|
1077
|
-
|
|
2066
|
+
- uses: actions/checkout@v4
|
|
2067
|
+
with:
|
|
2068
|
+
fetch-depth: 0
|
|
2069
|
+
- uses: actions/setup-node@v4
|
|
2070
|
+
with:
|
|
2071
|
+
node-version: 20
|
|
2072
|
+
- name: Run vinaya audit --only=dead-branches
|
|
2073
|
+
continue-on-error: true # never-red — this job is a notification channel, not a gate
|
|
1078
2074
|
env:
|
|
1079
2075
|
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
1080
|
-
run:
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
2076
|
+
run: npx --yes @attalabs/vinaya audit --only=dead-branches
|
|
2077
|
+
|
|
2078
|
+
direct-main-push-detection:
|
|
2079
|
+
name: Direct-Main-Push Detection
|
|
2080
|
+
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
|
|
2081
|
+
runs-on: ubuntu-latest
|
|
2082
|
+
permissions:
|
|
2083
|
+
contents: read
|
|
2084
|
+
issues: write
|
|
2085
|
+
pull-requests: read
|
|
2086
|
+
steps:
|
|
1088
2087
|
- uses: actions/checkout@v4
|
|
1089
2088
|
with:
|
|
1090
|
-
ref: \${{ steps.pr.outputs.sha }}
|
|
1091
2089
|
fetch-depth: 0
|
|
1092
2090
|
- uses: actions/setup-node@v4
|
|
1093
2091
|
with:
|
|
1094
2092
|
node-version: 20
|
|
1095
|
-
- name:
|
|
2093
|
+
- name: Run vinaya audit --only=direct-push
|
|
1096
2094
|
env:
|
|
1097
2095
|
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
1098
|
-
run: npx --yes vinaya
|
|
2096
|
+
run: npx --yes @attalabs/vinaya audit --only=direct-push --sha=\${{ github.sha }}
|
|
1099
2097
|
`;
|
|
1100
2098
|
}
|
|
1101
2099
|
var HOOK_PREAMBLE = `#!/usr/bin/env sh
|
|
@@ -1103,20 +2101,21 @@ var HOOK_PREAMBLE = `#!/usr/bin/env sh
|
|
|
1103
2101
|
function preCommitBody() {
|
|
1104
2102
|
return `# Vinaya commit-time gate. Runs the deterministic checks over your staged
|
|
1105
2103
|
# diff before the commit lands.
|
|
1106
|
-
npx --no-install vinaya check --all --diff-only || exit 1`;
|
|
2104
|
+
npx --no-install @attalabs/vinaya check --all --diff-only || exit 1`;
|
|
1107
2105
|
}
|
|
1108
2106
|
function prePushBody() {
|
|
1109
2107
|
return `# Vinaya pre-push gate. Runs branch/dispatch checks before the push leaves.
|
|
1110
|
-
npx --no-install vinaya check --all || exit 1`;
|
|
2108
|
+
npx --no-install @attalabs/vinaya check --all || exit 1`;
|
|
1111
2109
|
}
|
|
1112
2110
|
function doctrinePointer() {
|
|
2111
|
+
const doctrineRoot = join6(packageRoot(import.meta.url), "aeg-root");
|
|
1113
2112
|
return `<!-- ${MANAGED_NOTE} -->
|
|
1114
2113
|
# Vinaya doctrine — read this first
|
|
1115
2114
|
|
|
1116
2115
|
This repo is governed by Vinaya. The full, canonical doctrine (roles,
|
|
1117
2116
|
contracts, the state machine, the ring gates) ships inside the installed
|
|
1118
|
-
|
|
1119
|
-
\`vinaya upgrade\`
|
|
2117
|
+
\`@attalabs/vinaya\` npm package itself — no in-repo copy to drift, and
|
|
2118
|
+
\`vinaya upgrade\` regenerates this pointer if the install location changes.
|
|
1120
2119
|
|
|
1121
2120
|
An agent working in this repo follows the governed flow by reading two things:
|
|
1122
2121
|
|
|
@@ -1128,8 +2127,61 @@ An agent working in this repo follows the governed flow by reading two things:
|
|
|
1128
2127
|
Live task status is derived from the forge (Issues, labels, comments) via
|
|
1129
2128
|
\`vinaya check\` — it is never written into a file here.
|
|
1130
2129
|
|
|
1131
|
-
To
|
|
1132
|
-
|
|
2130
|
+
To read the doctrine text, it is bundled at this install's own resolved
|
|
2131
|
+
path:
|
|
2132
|
+
|
|
2133
|
+
${doctrineRoot}
|
|
2134
|
+
|
|
2135
|
+
\`vinaya doctor\` reports what is installed in this repo.
|
|
2136
|
+
`;
|
|
2137
|
+
}
|
|
2138
|
+
function starterDocOwners() {
|
|
2139
|
+
return `# ${MANAGED_NOTE}
|
|
2140
|
+
#
|
|
2141
|
+
# ${DOC_OWNERS_PATH} — code → doc bindings for the coherence seam.
|
|
2142
|
+
#
|
|
2143
|
+
# Format: one binding per line, CODEOWNERS-shaped.
|
|
2144
|
+
# <code-glob> <doc-pointer>
|
|
2145
|
+
# Whitespace-separated. Lines starting with \`#\` and blank lines are ignored.
|
|
2146
|
+
#
|
|
2147
|
+
# Glob syntax (kept deliberately simple — no character classes):
|
|
2148
|
+
# \`**\` matches any sequence (including \`/\`)
|
|
2149
|
+
# \`*\` matches any sequence not containing \`/\`
|
|
2150
|
+
# every other character is literal — so dynamic-route segments like
|
|
2151
|
+
# \`[username]\` match literally, no escaping required.
|
|
2152
|
+
#
|
|
2153
|
+
# Pointer forms:
|
|
2154
|
+
# in-repo path e.g. \`docs/my-feature.md\`
|
|
2155
|
+
# in-repo path with anchor e.g. \`docs/my-feature.md#section-2\`
|
|
2156
|
+
# URL e.g. \`https://example.com/docs/x\`
|
|
2157
|
+
#
|
|
2158
|
+
# Coverage rule (enforced by C5, the doc-owners check bundled into \`vinaya check\`):
|
|
2159
|
+
# When a code file in the PR matches a glob:
|
|
2160
|
+
# - in-repo pointer → that path must appear in the PR diff, else FAIL
|
|
2161
|
+
# - URL pointer → require a \`Doc-ack: <pointer> — <note>\` PR-body line
|
|
2162
|
+
# - dangling pointer → in-repo pointer that does not exist on disk: FAIL
|
|
2163
|
+
# Escape:
|
|
2164
|
+
# A PR-wide \`vinaya/waiver:docs\` label whose labeling timeline event's
|
|
2165
|
+
# actor is a configured principal suppresses every fired binding for that
|
|
2166
|
+
# PR — a forge-authenticated human act, never a parseable string. There is
|
|
2167
|
+
# no PR-body waiver field; label presence alone is never sufficient.
|
|
2168
|
+
#
|
|
2169
|
+
# Dormancy:
|
|
2170
|
+
# This file absent, OR no glob matches any changed code file → silent no-op.
|
|
2171
|
+
# The gate has no opinion until you teach it one.
|
|
2172
|
+
#
|
|
2173
|
+
# ── No-doc allow-list ───────────────────────────────────────────────────────
|
|
2174
|
+
# Surfaces that legitimately need no bound doc are listed below. Format:
|
|
2175
|
+
# # no-doc: <glob> — <reason>
|
|
2176
|
+
# These lines exempt the matching directory from the completeness scoreboard
|
|
2177
|
+
# (\`vinaya check\`'s advisory output). They do NOT affect C5 enforcement — if a
|
|
2178
|
+
# binding exists for a surface, C5 still fires on changed files regardless of
|
|
2179
|
+
# any no-doc line. Use no-doc for scaffold-only, config-only, or stub-only
|
|
2180
|
+
# surfaces where adding a doc-owners binding would be meaningless busywork.
|
|
2181
|
+
# To un-exempt a surface, remove its no-doc line and add a real binding.
|
|
2182
|
+
#
|
|
2183
|
+
# This starter ships empty — no bindings, no no-doc entries. Add your own
|
|
2184
|
+
# below this line as your repo grows real code → doc coverage needs.
|
|
1133
2185
|
`;
|
|
1134
2186
|
}
|
|
1135
2187
|
function labelOps() {
|
|
@@ -1162,6 +2214,7 @@ function buildInitOps(ctx) {
|
|
|
1162
2214
|
const hookMode = 493;
|
|
1163
2215
|
ops.push({ kind: "create-file", path: CHECKS_WORKFLOW_PATH, content: checksWorkflow(), group: "CI workflows" });
|
|
1164
2216
|
ops.push({ kind: "create-file", path: REVIEW_WORKFLOW_PATH, content: reviewWorkflow(), group: "CI workflows" });
|
|
2217
|
+
ops.push({ kind: "create-file", path: ARCHIVIST_WORKFLOW_PATH, content: archivistWorkflow(), group: "CI workflows" });
|
|
1165
2218
|
ops.push({
|
|
1166
2219
|
kind: "managed-block",
|
|
1167
2220
|
path: `${ctx.hookDir}/pre-commit`,
|
|
@@ -1195,6 +2248,12 @@ function buildInitOps(ctx) {
|
|
|
1195
2248
|
content: doctrinePointer(),
|
|
1196
2249
|
group: "Doctrine pointer"
|
|
1197
2250
|
});
|
|
2251
|
+
ops.push({
|
|
2252
|
+
kind: "create-file",
|
|
2253
|
+
path: DOC_OWNERS_PATH,
|
|
2254
|
+
content: starterDocOwners(),
|
|
2255
|
+
group: "Doc-ownership manifest"
|
|
2256
|
+
});
|
|
1198
2257
|
ops.push(...labelOps());
|
|
1199
2258
|
ops.push({ kind: "print", message: BRANCH_PROTECTION_NOTE, group: "Branch protection (printed, never applied)" });
|
|
1200
2259
|
return ops;
|
|
@@ -1212,107 +2271,9 @@ function buildInitProductOps(name) {
|
|
|
1212
2271
|
];
|
|
1213
2272
|
}
|
|
1214
2273
|
|
|
1215
|
-
// src/lib/detect.ts
|
|
1216
|
-
import { execFile as execFile4 } from "node:child_process";
|
|
1217
|
-
import { existsSync as existsSync2 } from "node:fs";
|
|
1218
|
-
import { join as join3 } from "node:path";
|
|
1219
|
-
import { promisify as promisify4 } from "node:util";
|
|
1220
|
-
var execFileAsync4 = promisify4(execFile4);
|
|
1221
|
-
async function detectGitRepo() {
|
|
1222
|
-
try {
|
|
1223
|
-
const { stdout: root } = await execFileAsync4("git", ["rev-parse", "--show-toplevel"]);
|
|
1224
|
-
const repoRoot = root.trim();
|
|
1225
|
-
let owner = "";
|
|
1226
|
-
let repo = "";
|
|
1227
|
-
try {
|
|
1228
|
-
const { stdout: url } = await execFileAsync4("git", ["remote", "get-url", "origin"]);
|
|
1229
|
-
const m = url.trim().match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
1230
|
-
if (m) {
|
|
1231
|
-
owner = m[1] ?? "";
|
|
1232
|
-
repo = m[2] ?? "";
|
|
1233
|
-
}
|
|
1234
|
-
} catch {}
|
|
1235
|
-
return { repoRoot, owner, repo };
|
|
1236
|
-
} catch {
|
|
1237
|
-
return null;
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
async function checkGhAuth() {
|
|
1241
|
-
try {
|
|
1242
|
-
await execFileAsync4("gh", ["auth", "status"]);
|
|
1243
|
-
return true;
|
|
1244
|
-
} catch {
|
|
1245
|
-
return false;
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
async function ghAuthStatus() {
|
|
1249
|
-
try {
|
|
1250
|
-
const { stderr, stdout } = await execFileAsync4("gh", ["auth", "status"]);
|
|
1251
|
-
return { authenticated: true, detail: (stderr || stdout).trim() };
|
|
1252
|
-
} catch (err) {
|
|
1253
|
-
const stderr = err.stderr;
|
|
1254
|
-
return { authenticated: false, detail: stderr?.trim() || "gh is not authenticated" };
|
|
1255
|
-
}
|
|
1256
|
-
}
|
|
1257
|
-
async function branchProtectionConfigured(owner, repo) {
|
|
1258
|
-
if (!owner || !repo)
|
|
1259
|
-
return null;
|
|
1260
|
-
try {
|
|
1261
|
-
await execFileAsync4("gh", ["api", `repos/${owner}/${repo}/branches/main/protection`]);
|
|
1262
|
-
return true;
|
|
1263
|
-
} catch (err) {
|
|
1264
|
-
const stderr = err.stderr ?? "";
|
|
1265
|
-
if (/\b404\b/.test(stderr))
|
|
1266
|
-
return false;
|
|
1267
|
-
return null;
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
function resolveHookDir(repoRoot) {
|
|
1271
|
-
return existsSync2(join3(repoRoot, ".husky")) ? ".husky" : ".git/hooks";
|
|
1272
|
-
}
|
|
1273
|
-
function hookDirFromManifest(manifest, fallback) {
|
|
1274
|
-
const block = manifest.blocks.find((b) => b.path.startsWith(".husky/") || b.path.startsWith(".git/hooks/"));
|
|
1275
|
-
if (block?.path.startsWith(".husky/"))
|
|
1276
|
-
return ".husky";
|
|
1277
|
-
if (block?.path.startsWith(".git/hooks/"))
|
|
1278
|
-
return ".git/hooks";
|
|
1279
|
-
return fallback;
|
|
1280
|
-
}
|
|
1281
|
-
async function customHooksPath(repoRoot) {
|
|
1282
|
-
try {
|
|
1283
|
-
const { stdout } = await execFileAsync4("git", ["-C", repoRoot, "config", "--get", "core.hooksPath"]);
|
|
1284
|
-
const v = stdout.trim();
|
|
1285
|
-
if (!v)
|
|
1286
|
-
return null;
|
|
1287
|
-
if (v === ".husky" || v === ".husky/_")
|
|
1288
|
-
return null;
|
|
1289
|
-
return v;
|
|
1290
|
-
} catch {
|
|
1291
|
-
return null;
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
function ghLabelGateway(repoRoot) {
|
|
1295
|
-
return {
|
|
1296
|
-
async exists(name) {
|
|
1297
|
-
try {
|
|
1298
|
-
const { stdout } = await execFileAsync4("gh", ["label", "list", "--json", "name"], { cwd: repoRoot });
|
|
1299
|
-
const names = JSON.parse(stdout);
|
|
1300
|
-
return names.some((l) => l.name === name);
|
|
1301
|
-
} catch {
|
|
1302
|
-
return false;
|
|
1303
|
-
}
|
|
1304
|
-
},
|
|
1305
|
-
async create(name, color, description) {
|
|
1306
|
-
await execFileAsync4("gh", ["label", "create", name, "--color", color, "--description", description], {
|
|
1307
|
-
cwd: repoRoot
|
|
1308
|
-
});
|
|
1309
|
-
}
|
|
1310
|
-
};
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
2274
|
// src/lib/ops.ts
|
|
1314
|
-
import { chmodSync, existsSync as
|
|
1315
|
-
import { dirname as dirname3, join as
|
|
2275
|
+
import { chmodSync, existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2276
|
+
import { dirname as dirname3, join as join7, resolve, sep } from "node:path";
|
|
1316
2277
|
var MARKER_NS = "vinaya:managed";
|
|
1317
2278
|
function markerLines(marker, comment) {
|
|
1318
2279
|
const [open, close] = comment === "hash" ? ["#", ""] : ["<!--", " -->"];
|
|
@@ -1328,7 +2289,7 @@ ${op.body}
|
|
|
1328
2289
|
${end}`;
|
|
1329
2290
|
}
|
|
1330
2291
|
function abs(repoRoot, relPath) {
|
|
1331
|
-
return
|
|
2292
|
+
return join7(repoRoot, relPath);
|
|
1332
2293
|
}
|
|
1333
2294
|
function containedAbs(repoRoot, relPath) {
|
|
1334
2295
|
const root = resolve(repoRoot);
|
|
@@ -1339,9 +2300,9 @@ function containedAbs(repoRoot, relPath) {
|
|
|
1339
2300
|
}
|
|
1340
2301
|
function fileContains(repoRoot, relPath, needle) {
|
|
1341
2302
|
const p = abs(repoRoot, relPath);
|
|
1342
|
-
if (!
|
|
2303
|
+
if (!existsSync6(p))
|
|
1343
2304
|
return false;
|
|
1344
|
-
return
|
|
2305
|
+
return readFileSync3(p, "utf-8").includes(needle);
|
|
1345
2306
|
}
|
|
1346
2307
|
function planInstall(ops, repoRoot, ownedFiles = new Set) {
|
|
1347
2308
|
const entries = [];
|
|
@@ -1349,7 +2310,7 @@ function planInstall(ops, repoRoot, ownedFiles = new Set) {
|
|
|
1349
2310
|
for (const op of ops) {
|
|
1350
2311
|
switch (op.kind) {
|
|
1351
2312
|
case "create-file": {
|
|
1352
|
-
const exists =
|
|
2313
|
+
const exists = existsSync6(abs(repoRoot, op.path));
|
|
1353
2314
|
let action;
|
|
1354
2315
|
if (!exists)
|
|
1355
2316
|
action = "create";
|
|
@@ -1367,7 +2328,7 @@ function planInstall(ops, repoRoot, ownedFiles = new Set) {
|
|
|
1367
2328
|
let action;
|
|
1368
2329
|
if (fileContains(repoRoot, op.path, begin))
|
|
1369
2330
|
action = "skip-present";
|
|
1370
|
-
else if (
|
|
2331
|
+
else if (existsSync6(abs(repoRoot, op.path)))
|
|
1371
2332
|
action = "append";
|
|
1372
2333
|
else
|
|
1373
2334
|
action = "create-host";
|
|
@@ -1445,19 +2406,19 @@ function renderInstallDiff(plan) {
|
|
|
1445
2406
|
}
|
|
1446
2407
|
function writeFileWithDirs(target, content, mode) {
|
|
1447
2408
|
mkdirSync2(dirname3(target), { recursive: true });
|
|
1448
|
-
|
|
2409
|
+
writeFileSync3(target, content, "utf-8");
|
|
1449
2410
|
if (mode !== undefined)
|
|
1450
2411
|
chmodSync(target, mode);
|
|
1451
2412
|
}
|
|
1452
2413
|
function appendBlock(repoRoot, op) {
|
|
1453
2414
|
const target = abs(repoRoot, op.path);
|
|
1454
|
-
const existing =
|
|
2415
|
+
const existing = readFileSync3(target, "utf-8");
|
|
1455
2416
|
const sep2 = existing.endsWith(`
|
|
1456
2417
|
`) ? `
|
|
1457
2418
|
` : `
|
|
1458
2419
|
|
|
1459
2420
|
`;
|
|
1460
|
-
|
|
2421
|
+
writeFileSync3(target, `${existing}${sep2}${renderBlock(op)}
|
|
1461
2422
|
`, "utf-8");
|
|
1462
2423
|
if (op.mode !== undefined)
|
|
1463
2424
|
chmodSync(target, op.mode);
|
|
@@ -1504,9 +2465,14 @@ async function applyInstall(plan, repoRoot, labels, onFilesRecorded) {
|
|
|
1504
2465
|
for (const e of plan.entries) {
|
|
1505
2466
|
if (e.kind !== "create-label")
|
|
1506
2467
|
continue;
|
|
1507
|
-
|
|
1508
|
-
await labels.
|
|
1509
|
-
|
|
2468
|
+
try {
|
|
2469
|
+
if (!await labels.exists(e.op.name)) {
|
|
2470
|
+
await labels.create(e.op.name, e.op.color, e.op.description);
|
|
2471
|
+
createdLabels.push(e.op.name);
|
|
2472
|
+
}
|
|
2473
|
+
} catch (err) {
|
|
2474
|
+
const reason = err.stderr?.trim() || err.message;
|
|
2475
|
+
console.warn(`Warning: could not create label '${e.op.name}': ${reason}`);
|
|
1510
2476
|
}
|
|
1511
2477
|
}
|
|
1512
2478
|
return { ...base, labels: dedupe(createdLabels) };
|
|
@@ -1557,7 +2523,7 @@ function planEject(manifest, repoRoot) {
|
|
|
1557
2523
|
escapes.push(b.path);
|
|
1558
2524
|
continue;
|
|
1559
2525
|
}
|
|
1560
|
-
if (!
|
|
2526
|
+
if (!existsSync6(p)) {
|
|
1561
2527
|
actions.push({
|
|
1562
2528
|
kind: "strip-block",
|
|
1563
2529
|
path: b.path,
|
|
@@ -1568,7 +2534,7 @@ function planEject(manifest, repoRoot) {
|
|
|
1568
2534
|
});
|
|
1569
2535
|
continue;
|
|
1570
2536
|
}
|
|
1571
|
-
const content =
|
|
2537
|
+
const content = readFileSync3(p, "utf-8");
|
|
1572
2538
|
const stripped = stripBlockFromContent(content, b.marker, b.comment);
|
|
1573
2539
|
const removesHost = stripped !== null && blockStripLeavesEmpty(stripped);
|
|
1574
2540
|
actions.push({
|
|
@@ -1586,7 +2552,7 @@ function planEject(manifest, repoRoot) {
|
|
|
1586
2552
|
escapes.push(f);
|
|
1587
2553
|
continue;
|
|
1588
2554
|
}
|
|
1589
|
-
actions.push({ kind: "delete-file", path: f, present:
|
|
2555
|
+
actions.push({ kind: "delete-file", path: f, present: existsSync6(p) });
|
|
1590
2556
|
}
|
|
1591
2557
|
for (const name of manifest.labels) {
|
|
1592
2558
|
actions.push({ kind: "report-label", name });
|
|
@@ -1620,20 +2586,20 @@ function applyEject(plan, repoRoot) {
|
|
|
1620
2586
|
const p = containedAbs(repoRoot, a.path);
|
|
1621
2587
|
if (p === null)
|
|
1622
2588
|
continue;
|
|
1623
|
-
const content =
|
|
2589
|
+
const content = readFileSync3(p, "utf-8");
|
|
1624
2590
|
const stripped = stripBlockFromContent(content, a.marker, a.comment);
|
|
1625
2591
|
if (stripped === null)
|
|
1626
2592
|
continue;
|
|
1627
2593
|
if (blockStripLeavesEmpty(stripped))
|
|
1628
|
-
|
|
2594
|
+
rmSync2(p, { force: true });
|
|
1629
2595
|
else
|
|
1630
|
-
|
|
2596
|
+
writeFileSync3(p, stripped.endsWith(`
|
|
1631
2597
|
`) ? stripped : `${stripped}
|
|
1632
2598
|
`, "utf-8");
|
|
1633
2599
|
} else if (a.kind === "delete-file") {
|
|
1634
2600
|
const p = containedAbs(repoRoot, a.path);
|
|
1635
2601
|
if (a.present && p !== null)
|
|
1636
|
-
|
|
2602
|
+
rmSync2(p, { force: true });
|
|
1637
2603
|
}
|
|
1638
2604
|
}
|
|
1639
2605
|
return {
|
|
@@ -1642,21 +2608,11 @@ function applyEject(plan, repoRoot) {
|
|
|
1642
2608
|
}
|
|
1643
2609
|
|
|
1644
2610
|
// src/commands/doctor.ts
|
|
1645
|
-
function packageRoot() {
|
|
1646
|
-
let dir = dirname4(fileURLToPath2(import.meta.url));
|
|
1647
|
-
while (!existsSync4(join5(dir, "package.json"))) {
|
|
1648
|
-
const parent = dirname4(dir);
|
|
1649
|
-
if (parent === dir)
|
|
1650
|
-
break;
|
|
1651
|
-
dir = parent;
|
|
1652
|
-
}
|
|
1653
|
-
return dir;
|
|
1654
|
-
}
|
|
1655
2611
|
function readVersion() {
|
|
1656
|
-
const pkg = JSON.parse(
|
|
2612
|
+
const pkg = JSON.parse(readFileSync4(join8(packageRoot(import.meta.url), "package.json"), "utf-8"));
|
|
1657
2613
|
return pkg.version;
|
|
1658
2614
|
}
|
|
1659
|
-
function
|
|
2615
|
+
function realDeps3() {
|
|
1660
2616
|
return {
|
|
1661
2617
|
detectRepo: detectGitRepo,
|
|
1662
2618
|
ghAuthStatus,
|
|
@@ -1672,12 +2628,12 @@ var info = (check, message) => ({ check, severity: "info", message });
|
|
|
1672
2628
|
var warn = (check, message) => ({ check, severity: "warn", message });
|
|
1673
2629
|
var error = (check, message) => ({ check, severity: "error", message });
|
|
1674
2630
|
function readConfig(repoRoot) {
|
|
1675
|
-
const p =
|
|
1676
|
-
if (!
|
|
2631
|
+
const p = join8(repoRoot, CONFIG_PATH);
|
|
2632
|
+
if (!existsSync7(p))
|
|
1677
2633
|
return { kind: "missing" };
|
|
1678
2634
|
let raw;
|
|
1679
2635
|
try {
|
|
1680
|
-
raw = JSON.parse(
|
|
2636
|
+
raw = JSON.parse(readFileSync4(p, "utf-8"));
|
|
1681
2637
|
} catch (err) {
|
|
1682
2638
|
return { kind: "invalid", error: `invalid JSON: ${err.message}` };
|
|
1683
2639
|
}
|
|
@@ -1706,14 +2662,14 @@ function diagnoseInstall(repoRoot, ctx, manifest) {
|
|
|
1706
2662
|
for (const op of buildInitOps(ctx)) {
|
|
1707
2663
|
if (op.kind === "create-file") {
|
|
1708
2664
|
const check = labelForPath(op.path);
|
|
1709
|
-
const abs2 =
|
|
1710
|
-
const exists =
|
|
2665
|
+
const abs2 = join8(repoRoot, op.path);
|
|
2666
|
+
const exists = existsSync7(abs2);
|
|
1711
2667
|
const owned = ownedFiles.has(op.path);
|
|
1712
2668
|
if (!exists) {
|
|
1713
2669
|
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\`.`));
|
|
1714
2670
|
continue;
|
|
1715
2671
|
}
|
|
1716
|
-
const content =
|
|
2672
|
+
const content = readFileSync4(abs2, "utf-8");
|
|
1717
2673
|
if (!owned) {
|
|
1718
2674
|
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.`));
|
|
1719
2675
|
continue;
|
|
@@ -1730,13 +2686,13 @@ function diagnoseInstall(repoRoot, ctx, manifest) {
|
|
|
1730
2686
|
}
|
|
1731
2687
|
} else if (op.kind === "managed-block") {
|
|
1732
2688
|
const check = "hooks";
|
|
1733
|
-
const abs2 =
|
|
2689
|
+
const abs2 = join8(repoRoot, op.path);
|
|
1734
2690
|
const owned = ownedBlocks.has(blockKey(op.path, op.marker));
|
|
1735
|
-
if (!
|
|
2691
|
+
if (!existsSync7(abs2)) {
|
|
1736
2692
|
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.`));
|
|
1737
2693
|
continue;
|
|
1738
2694
|
}
|
|
1739
|
-
const content =
|
|
2695
|
+
const content = readFileSync4(abs2, "utf-8");
|
|
1740
2696
|
const { begin, end } = markerLines(op.marker, op.comment);
|
|
1741
2697
|
const hasMarkers = content.includes(begin) && content.includes(end);
|
|
1742
2698
|
if (!hasMarkers) {
|
|
@@ -1761,8 +2717,8 @@ function diagnoseInstall(repoRoot, ctx, manifest) {
|
|
|
1761
2717
|
function diagnoseCustomChecks(repoRoot, config) {
|
|
1762
2718
|
const findings = [];
|
|
1763
2719
|
for (const [name, entry2] of Object.entries(config.checks ?? {})) {
|
|
1764
|
-
const scriptAbs =
|
|
1765
|
-
findings.push(
|
|
2720
|
+
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}`));
|
|
1766
2722
|
}
|
|
1767
2723
|
return findings;
|
|
1768
2724
|
}
|
|
@@ -1846,12 +2802,12 @@ async function runDoctor(args, deps) {
|
|
|
1846
2802
|
return healthy ? 0 : 1;
|
|
1847
2803
|
}
|
|
1848
2804
|
async function doctorCommand(args) {
|
|
1849
|
-
process.exit(await runDoctor(args,
|
|
2805
|
+
process.exit(await runDoctor(args, realDeps3()));
|
|
1850
2806
|
}
|
|
1851
2807
|
|
|
1852
2808
|
// src/commands/eject.ts
|
|
1853
|
-
import { existsSync as
|
|
1854
|
-
import { join as
|
|
2809
|
+
import { existsSync as existsSync8, readFileSync as readFileSync5 } from "node:fs";
|
|
2810
|
+
import { join as join9 } from "node:path";
|
|
1855
2811
|
|
|
1856
2812
|
// src/lib/prompt.ts
|
|
1857
2813
|
var stdinBuffer = "";
|
|
@@ -1918,7 +2874,7 @@ function closeStdin() {
|
|
|
1918
2874
|
}
|
|
1919
2875
|
|
|
1920
2876
|
// src/commands/eject.ts
|
|
1921
|
-
function
|
|
2877
|
+
function realDeps4() {
|
|
1922
2878
|
return {
|
|
1923
2879
|
detectRepo: detectGitRepo,
|
|
1924
2880
|
confirm: async (q) => {
|
|
@@ -1932,12 +2888,12 @@ function parse(args) {
|
|
|
1932
2888
|
return { dryRun: args.includes("--dry-run"), yes: args.includes("--yes") };
|
|
1933
2889
|
}
|
|
1934
2890
|
function readManifest(repoRoot) {
|
|
1935
|
-
const p =
|
|
1936
|
-
if (!
|
|
2891
|
+
const p = join9(repoRoot, CONFIG_PATH);
|
|
2892
|
+
if (!existsSync8(p))
|
|
1937
2893
|
return { kind: "none" };
|
|
1938
2894
|
let raw;
|
|
1939
2895
|
try {
|
|
1940
|
-
raw = JSON.parse(
|
|
2896
|
+
raw = JSON.parse(readFileSync5(p, "utf-8"));
|
|
1941
2897
|
} catch (err) {
|
|
1942
2898
|
return { kind: "orphan", reason: `vinaya.config.json is not valid JSON: ${err.message}` };
|
|
1943
2899
|
}
|
|
@@ -2015,14 +2971,14 @@ Vinaya ejected.
|
|
|
2015
2971
|
return 0;
|
|
2016
2972
|
}
|
|
2017
2973
|
async function ejectCommand(args) {
|
|
2018
|
-
const code = await runEject(args,
|
|
2974
|
+
const code = await runEject(args, realDeps4());
|
|
2019
2975
|
process.exit(code);
|
|
2020
2976
|
}
|
|
2021
2977
|
|
|
2022
2978
|
// src/commands/init.ts
|
|
2023
|
-
import { existsSync as
|
|
2024
|
-
import { join as
|
|
2025
|
-
function
|
|
2979
|
+
import { existsSync as existsSync9, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
|
|
2980
|
+
import { join as join10 } from "node:path";
|
|
2981
|
+
function realDeps5() {
|
|
2026
2982
|
return {
|
|
2027
2983
|
detectRepo: detectGitRepo,
|
|
2028
2984
|
checkGhAuth,
|
|
@@ -2041,19 +2997,19 @@ function flags(args) {
|
|
|
2041
2997
|
}
|
|
2042
2998
|
var PRODUCT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
2043
2999
|
function readManifest2(repoRoot) {
|
|
2044
|
-
const p =
|
|
2045
|
-
if (!
|
|
3000
|
+
const p = join10(repoRoot, CONFIG_PATH);
|
|
3001
|
+
if (!existsSync9(p))
|
|
2046
3002
|
return null;
|
|
2047
3003
|
try {
|
|
2048
|
-
return VinayaConfigSchema.parse(JSON.parse(
|
|
3004
|
+
return VinayaConfigSchema.parse(JSON.parse(readFileSync6(p, "utf-8"))).managed ?? null;
|
|
2049
3005
|
} catch {
|
|
2050
3006
|
return null;
|
|
2051
3007
|
}
|
|
2052
3008
|
}
|
|
2053
3009
|
function writeManifest(repoRoot, manifest) {
|
|
2054
|
-
const configAbs =
|
|
2055
|
-
const seed = JSON.parse(
|
|
2056
|
-
|
|
3010
|
+
const configAbs = join10(repoRoot, CONFIG_PATH);
|
|
3011
|
+
const seed = JSON.parse(readFileSync6(configAbs, "utf-8"));
|
|
3012
|
+
writeFileSync4(configAbs, `${JSON.stringify({ ...seed, managed: manifest }, null, 2)}
|
|
2057
3013
|
`, "utf-8");
|
|
2058
3014
|
}
|
|
2059
3015
|
async function runInit(args, deps) {
|
|
@@ -2181,20 +3137,20 @@ function dedupeBlocks2(bs) {
|
|
|
2181
3137
|
});
|
|
2182
3138
|
}
|
|
2183
3139
|
async function initCommand(args) {
|
|
2184
|
-
process.exit(await runInit(args,
|
|
3140
|
+
process.exit(await runInit(args, realDeps5()));
|
|
2185
3141
|
}
|
|
2186
3142
|
async function initProductCommand(args) {
|
|
2187
|
-
process.exit(await runInitProduct(args,
|
|
3143
|
+
process.exit(await runInitProduct(args, realDeps5()));
|
|
2188
3144
|
}
|
|
2189
3145
|
|
|
2190
3146
|
// src/commands/issue.ts
|
|
2191
|
-
import { execFileSync as
|
|
3147
|
+
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
2192
3148
|
|
|
2193
3149
|
// src/lib/forge-write.ts
|
|
2194
|
-
import { mkdtempSync, rmSync as
|
|
2195
|
-
import { readFileSync as
|
|
3150
|
+
import { mkdtempSync, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "node:fs";
|
|
3151
|
+
import { readFileSync as readFileSync7 } from "node:fs";
|
|
2196
3152
|
import { tmpdir } from "node:os";
|
|
2197
|
-
import { join as
|
|
3153
|
+
import { join as join11 } from "node:path";
|
|
2198
3154
|
class ForgeArgError extends Error {
|
|
2199
3155
|
}
|
|
2200
3156
|
function locateBody(args) {
|
|
@@ -2204,13 +3160,13 @@ function locateBody(args) {
|
|
|
2204
3160
|
const p = args[i + 1];
|
|
2205
3161
|
if (!p)
|
|
2206
3162
|
throw new ForgeArgError("`--body-file` was given with no path.");
|
|
2207
|
-
return { body:
|
|
3163
|
+
return { body: readFileSync7(p, "utf8"), source: { kind: "file", argIndex: i + 1, inlineForm: false } };
|
|
2208
3164
|
}
|
|
2209
3165
|
if (a.startsWith("--body-file=")) {
|
|
2210
3166
|
const p = a.slice("--body-file=".length);
|
|
2211
3167
|
if (!p)
|
|
2212
3168
|
throw new ForgeArgError("`--body-file=` was given with no path.");
|
|
2213
|
-
return { body:
|
|
3169
|
+
return { body: readFileSync7(p, "utf8"), source: { kind: "file", argIndex: i, inlineForm: true } };
|
|
2214
3170
|
}
|
|
2215
3171
|
if (a === "--body" || a === "-b") {
|
|
2216
3172
|
const v = args[i + 1];
|
|
@@ -2227,13 +3183,13 @@ function resolveShippableArgs(args, bodyResult) {
|
|
|
2227
3183
|
if (bodyResult?.source.kind !== "file") {
|
|
2228
3184
|
return { finalArgs: args, cleanup: () => {} };
|
|
2229
3185
|
}
|
|
2230
|
-
const dir = mkdtempSync(
|
|
2231
|
-
const tempPath =
|
|
2232
|
-
|
|
3186
|
+
const dir = mkdtempSync(join11(tmpdir(), "vinaya-forge-body-"));
|
|
3187
|
+
const tempPath = join11(dir, "body.md");
|
|
3188
|
+
writeFileSync5(tempPath, bodyResult.body, "utf8");
|
|
2233
3189
|
const finalArgs = [...args];
|
|
2234
3190
|
const { argIndex, inlineForm } = bodyResult.source;
|
|
2235
3191
|
finalArgs[argIndex] = inlineForm ? `--body-file=${tempPath}` : tempPath;
|
|
2236
|
-
return { finalArgs, cleanup: () =>
|
|
3192
|
+
return { finalArgs, cleanup: () => rmSync3(dir, { recursive: true, force: true }) };
|
|
2237
3193
|
}
|
|
2238
3194
|
function extractTitle(args) {
|
|
2239
3195
|
for (let i = 0;i < args.length; i++) {
|
|
@@ -2401,7 +3357,7 @@ function locateBodyOrRefuse(ghArgs, retryCommand) {
|
|
|
2401
3357
|
function fetchForgeLabels(issueRef) {
|
|
2402
3358
|
let out;
|
|
2403
3359
|
try {
|
|
2404
|
-
out =
|
|
3360
|
+
out = execFileSync6("gh", ["issue", "view", issueRef, "--json", "labels"], {
|
|
2405
3361
|
encoding: "utf8",
|
|
2406
3362
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2407
3363
|
});
|
|
@@ -2427,9 +3383,9 @@ function reportPass(json, command) {
|
|
|
2427
3383
|
}
|
|
2428
3384
|
}
|
|
2429
3385
|
function runGhWrite(ghCmd, ghArgs, bodyResult, json) {
|
|
2430
|
-
const { finalArgs, cleanup } = resolveShippableArgs(ghArgs, bodyResult);
|
|
3386
|
+
const { finalArgs, cleanup: cleanup2 } = resolveShippableArgs(ghArgs, bodyResult);
|
|
2431
3387
|
try {
|
|
2432
|
-
const out =
|
|
3388
|
+
const out = execFileSync6("gh", [...ghCmd, ...finalArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
|
|
2433
3389
|
const url = out.trim();
|
|
2434
3390
|
if (json)
|
|
2435
3391
|
printJson({ validated: true, written: true, url });
|
|
@@ -2437,7 +3393,7 @@ function runGhWrite(ghCmd, ghArgs, bodyResult, json) {
|
|
|
2437
3393
|
process.stdout.write(`${url}
|
|
2438
3394
|
`);
|
|
2439
3395
|
} finally {
|
|
2440
|
-
|
|
3396
|
+
cleanup2();
|
|
2441
3397
|
}
|
|
2442
3398
|
}
|
|
2443
3399
|
function validateTaskIssue(body, title, retryCommand) {
|
|
@@ -2500,21 +3456,10 @@ function issueEditCommand(args) {
|
|
|
2500
3456
|
}
|
|
2501
3457
|
|
|
2502
3458
|
// src/commands/new-check.ts
|
|
2503
|
-
import { chmodSync as chmodSync2, existsSync as
|
|
2504
|
-
import {
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
let dir = dirname5(fileURLToPath3(import.meta.url));
|
|
2508
|
-
while (!existsSync7(join9(dir, "package.json"))) {
|
|
2509
|
-
const parent = dirname5(dir);
|
|
2510
|
-
if (parent === dir)
|
|
2511
|
-
break;
|
|
2512
|
-
dir = parent;
|
|
2513
|
-
}
|
|
2514
|
-
return dir;
|
|
2515
|
-
}
|
|
2516
|
-
var TEMPLATE_PATH = join9(packageRoot2(), "templates", "custom-check.template.ts");
|
|
2517
|
-
var CHECKS_DIR = join9("scripts", "vinaya-checks");
|
|
3459
|
+
import { chmodSync as chmodSync2, existsSync as existsSync10, mkdirSync as mkdirSync3, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "node:fs";
|
|
3460
|
+
import { join as join12 } from "node:path";
|
|
3461
|
+
var TEMPLATE_PATH = join12(packageRoot(import.meta.url), "templates", "custom-check.template.ts");
|
|
3462
|
+
var CHECKS_DIR = join12("scripts", "vinaya-checks");
|
|
2518
3463
|
var VALID_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
2519
3464
|
function newCheckCommand(args) {
|
|
2520
3465
|
const name = args[0];
|
|
@@ -2522,19 +3467,19 @@ function newCheckCommand(args) {
|
|
|
2522
3467
|
console.error("Usage: vinaya new check <name> (name: lowercase letters, digits, hyphens)");
|
|
2523
3468
|
process.exit(2);
|
|
2524
3469
|
}
|
|
2525
|
-
const scriptsDir =
|
|
2526
|
-
if (!
|
|
3470
|
+
const scriptsDir = join12(process.cwd(), CHECKS_DIR);
|
|
3471
|
+
if (!existsSync10(scriptsDir))
|
|
2527
3472
|
mkdirSync3(scriptsDir, { recursive: true });
|
|
2528
|
-
const targetPath =
|
|
2529
|
-
if (
|
|
3473
|
+
const targetPath = join12(scriptsDir, `${name}.ts`);
|
|
3474
|
+
if (existsSync10(targetPath)) {
|
|
2530
3475
|
console.error(`Error: ${targetPath} already exists.`);
|
|
2531
3476
|
process.exit(1);
|
|
2532
3477
|
}
|
|
2533
|
-
const template =
|
|
3478
|
+
const template = readFileSync8(TEMPLATE_PATH, "utf-8");
|
|
2534
3479
|
const contents = template.split("{{CHECK_NAME}}").join(name);
|
|
2535
|
-
|
|
3480
|
+
writeFileSync6(targetPath, contents, "utf-8");
|
|
2536
3481
|
chmodSync2(targetPath, 493);
|
|
2537
|
-
const relPath =
|
|
3482
|
+
const relPath = join12(CHECKS_DIR, `${name}.ts`);
|
|
2538
3483
|
const registration = JSON.stringify({ checks: { [name]: { run: `./${relPath}`, scope: "diff" } } }, null, 2);
|
|
2539
3484
|
process.stdout.write(`Created ${relPath}
|
|
2540
3485
|
|
|
@@ -2544,12 +3489,12 @@ ${registration}
|
|
|
2544
3489
|
}
|
|
2545
3490
|
|
|
2546
3491
|
// src/commands/pr.ts
|
|
2547
|
-
import { execFileSync as
|
|
3492
|
+
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
2548
3493
|
var RETRY_CREATE2 = "vinaya pr create --validate-only …";
|
|
2549
3494
|
var RETRY_EDIT2 = "vinaya pr edit <n> --validate-only …";
|
|
2550
3495
|
function git2(args) {
|
|
2551
3496
|
try {
|
|
2552
|
-
return
|
|
3497
|
+
return execFileSync7("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
2553
3498
|
} catch {
|
|
2554
3499
|
return "";
|
|
2555
3500
|
}
|
|
@@ -2581,9 +3526,9 @@ function reportPass2(json, command) {
|
|
|
2581
3526
|
}
|
|
2582
3527
|
}
|
|
2583
3528
|
function runGhWrite2(ghCmd, ghArgs, bodyResult, json) {
|
|
2584
|
-
const { finalArgs, cleanup } = resolveShippableArgs(ghArgs, bodyResult);
|
|
3529
|
+
const { finalArgs, cleanup: cleanup2 } = resolveShippableArgs(ghArgs, bodyResult);
|
|
2585
3530
|
try {
|
|
2586
|
-
const out =
|
|
3531
|
+
const out = execFileSync7("gh", [...ghCmd, ...finalArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
|
|
2587
3532
|
const url = out.trim();
|
|
2588
3533
|
if (json)
|
|
2589
3534
|
printJson({ validated: true, written: true, url });
|
|
@@ -2591,13 +3536,13 @@ function runGhWrite2(ghCmd, ghArgs, bodyResult, json) {
|
|
|
2591
3536
|
process.stdout.write(`${url}
|
|
2592
3537
|
`);
|
|
2593
3538
|
} finally {
|
|
2594
|
-
|
|
3539
|
+
cleanup2();
|
|
2595
3540
|
}
|
|
2596
3541
|
}
|
|
2597
3542
|
function fetchPrForgeContext(prRef) {
|
|
2598
3543
|
let viewOut;
|
|
2599
3544
|
try {
|
|
2600
|
-
viewOut =
|
|
3545
|
+
viewOut = execFileSync7("gh", ["pr", "view", prRef, "--json", "headRefName,files"], {
|
|
2601
3546
|
encoding: "utf8",
|
|
2602
3547
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2603
3548
|
});
|
|
@@ -2694,22 +3639,22 @@ function prEditCommand(args) {
|
|
|
2694
3639
|
|
|
2695
3640
|
// src/commands/studio.ts
|
|
2696
3641
|
import { spawn as spawn2 } from "node:child_process";
|
|
2697
|
-
import { existsSync as
|
|
2698
|
-
import { dirname as
|
|
3642
|
+
import { existsSync as existsSync11, readFileSync as readFileSync9 } from "node:fs";
|
|
3643
|
+
import { dirname as dirname4, join as join13 } from "node:path";
|
|
2699
3644
|
function resolveStudioTarget(cwd) {
|
|
2700
3645
|
let dir = cwd;
|
|
2701
3646
|
for (;; ) {
|
|
2702
|
-
const webDir =
|
|
2703
|
-
const pkgPath =
|
|
2704
|
-
if (
|
|
3647
|
+
const webDir = join13(dir, "apps", "vinaya", "web");
|
|
3648
|
+
const pkgPath = join13(webDir, "package.json");
|
|
3649
|
+
if (existsSync11(pkgPath)) {
|
|
2705
3650
|
try {
|
|
2706
|
-
const pkg = JSON.parse(
|
|
3651
|
+
const pkg = JSON.parse(readFileSync9(pkgPath, "utf-8"));
|
|
2707
3652
|
if (pkg.name === "@atta/vinaya-web") {
|
|
2708
3653
|
return { kind: "workspace", webDir };
|
|
2709
3654
|
}
|
|
2710
3655
|
} catch {}
|
|
2711
3656
|
}
|
|
2712
|
-
const parent =
|
|
3657
|
+
const parent = dirname4(dir);
|
|
2713
3658
|
if (parent === dir)
|
|
2714
3659
|
break;
|
|
2715
3660
|
dir = parent;
|
|
@@ -2736,9 +3681,9 @@ async function runStudio(cwd, args) {
|
|
|
2736
3681
|
}
|
|
2737
3682
|
|
|
2738
3683
|
// src/commands/upgrade.ts
|
|
2739
|
-
import { existsSync as
|
|
2740
|
-
import { join as
|
|
2741
|
-
function
|
|
3684
|
+
import { existsSync as existsSync12, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "node:fs";
|
|
3685
|
+
import { join as join14 } from "node:path";
|
|
3686
|
+
function realDeps6() {
|
|
2742
3687
|
return {
|
|
2743
3688
|
detectRepo: detectGitRepo,
|
|
2744
3689
|
hookDirFor: resolveHookDir,
|
|
@@ -2753,12 +3698,12 @@ function flags2(args) {
|
|
|
2753
3698
|
return { dryRun: args.includes("--dry-run"), yes: args.includes("--yes") };
|
|
2754
3699
|
}
|
|
2755
3700
|
function readManifest3(repoRoot) {
|
|
2756
|
-
const p =
|
|
2757
|
-
if (!
|
|
3701
|
+
const p = join14(repoRoot, CONFIG_PATH);
|
|
3702
|
+
if (!existsSync12(p))
|
|
2758
3703
|
return { kind: "missing" };
|
|
2759
3704
|
let raw;
|
|
2760
3705
|
try {
|
|
2761
|
-
raw = JSON.parse(
|
|
3706
|
+
raw = JSON.parse(readFileSync10(p, "utf-8"));
|
|
2762
3707
|
} catch (err) {
|
|
2763
3708
|
return { kind: "invalid", error: `invalid JSON: ${err.message}` };
|
|
2764
3709
|
}
|
|
@@ -2774,10 +3719,10 @@ function readManifest3(repoRoot) {
|
|
|
2774
3719
|
return { kind: "ok", manifest: parsed.data.managed };
|
|
2775
3720
|
}
|
|
2776
3721
|
function writeManifestVersion(repoRoot, manifest) {
|
|
2777
|
-
const configAbs =
|
|
2778
|
-
const seed = JSON.parse(
|
|
3722
|
+
const configAbs = join14(repoRoot, CONFIG_PATH);
|
|
3723
|
+
const seed = JSON.parse(readFileSync10(configAbs, "utf-8"));
|
|
2779
3724
|
const updated = { ...manifest, version: MANAGED_MANIFEST_VERSION };
|
|
2780
|
-
|
|
3725
|
+
writeFileSync7(configAbs, `${JSON.stringify({ ...seed, managed: updated }, null, 2)}
|
|
2781
3726
|
`, "utf-8");
|
|
2782
3727
|
}
|
|
2783
3728
|
function planUpgrade(ops, repoRoot, manifest) {
|
|
@@ -2788,8 +3733,8 @@ function planUpgrade(ops, repoRoot, manifest) {
|
|
|
2788
3733
|
const ownedBlocks = new Set(manifest.blocks.map((b) => blockKey(b.path, b.marker)));
|
|
2789
3734
|
for (const op of ops) {
|
|
2790
3735
|
if (op.kind === "create-file") {
|
|
2791
|
-
const abs2 =
|
|
2792
|
-
const exists =
|
|
3736
|
+
const abs2 = join14(repoRoot, op.path);
|
|
3737
|
+
const exists = existsSync12(abs2);
|
|
2793
3738
|
const owned = ownedFiles.has(op.path);
|
|
2794
3739
|
let action;
|
|
2795
3740
|
if (op.path === CONFIG_PATH) {
|
|
@@ -2799,7 +3744,7 @@ function planUpgrade(ops, repoRoot, manifest) {
|
|
|
2799
3744
|
} else if (!exists) {
|
|
2800
3745
|
action = "recreate";
|
|
2801
3746
|
hasChanges = true;
|
|
2802
|
-
} else if (
|
|
3747
|
+
} else if (readFileSync10(abs2, "utf-8") !== op.content) {
|
|
2803
3748
|
action = "regenerate";
|
|
2804
3749
|
hasChanges = true;
|
|
2805
3750
|
} else {
|
|
@@ -2807,16 +3752,16 @@ function planUpgrade(ops, repoRoot, manifest) {
|
|
|
2807
3752
|
}
|
|
2808
3753
|
entries.push({ kind: "create-file", op, action });
|
|
2809
3754
|
} else if (op.kind === "managed-block") {
|
|
2810
|
-
const abs2 =
|
|
3755
|
+
const abs2 = join14(repoRoot, op.path);
|
|
2811
3756
|
const owned = ownedBlocks.has(blockKey(op.path, op.marker));
|
|
2812
3757
|
let action;
|
|
2813
3758
|
if (!owned) {
|
|
2814
3759
|
action = "not-installed";
|
|
2815
|
-
} else if (!
|
|
3760
|
+
} else if (!existsSync12(abs2)) {
|
|
2816
3761
|
action = "recreate-host";
|
|
2817
3762
|
hasChanges = true;
|
|
2818
3763
|
} else {
|
|
2819
|
-
const content =
|
|
3764
|
+
const content = readFileSync10(abs2, "utf-8");
|
|
2820
3765
|
const { begin, end } = markerLines(op.marker, op.comment);
|
|
2821
3766
|
if (!(content.includes(begin) && content.includes(end))) {
|
|
2822
3767
|
action = "recreate-append";
|
|
@@ -2892,11 +3837,11 @@ function renderUpgradeDiff(plan) {
|
|
|
2892
3837
|
`);
|
|
2893
3838
|
}
|
|
2894
3839
|
function regenerateBlock(repoRoot, op) {
|
|
2895
|
-
const abs2 =
|
|
2896
|
-
const content =
|
|
3840
|
+
const abs2 = join14(repoRoot, op.path);
|
|
3841
|
+
const content = readFileSync10(abs2, "utf-8");
|
|
2897
3842
|
const stripped = stripBlockFromContent(content, op.marker, op.comment);
|
|
2898
3843
|
if (stripped !== null) {
|
|
2899
|
-
|
|
3844
|
+
writeFileSync7(abs2, stripped.endsWith(`
|
|
2900
3845
|
`) ? stripped : `${stripped}
|
|
2901
3846
|
`, "utf-8");
|
|
2902
3847
|
}
|
|
@@ -2906,7 +3851,7 @@ function applyUpgrade(plan, repoRoot) {
|
|
|
2906
3851
|
for (const e of plan.entries) {
|
|
2907
3852
|
if (e.kind === "create-file") {
|
|
2908
3853
|
if (e.action === "regenerate" || e.action === "recreate") {
|
|
2909
|
-
writeFileWithDirs(
|
|
3854
|
+
writeFileWithDirs(join14(repoRoot, e.op.path), e.op.content, e.op.mode);
|
|
2910
3855
|
}
|
|
2911
3856
|
} else {
|
|
2912
3857
|
if (e.action === "recreate-host")
|
|
@@ -2976,7 +3921,120 @@ Vinaya upgraded.
|
|
|
2976
3921
|
return 0;
|
|
2977
3922
|
}
|
|
2978
3923
|
async function upgradeCommand(args) {
|
|
2979
|
-
process.exit(await runUpgrade(args,
|
|
3924
|
+
process.exit(await runUpgrade(args, realDeps6()));
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
// src/commands/waiver.ts
|
|
3928
|
+
import { execFileSync as execFileSync8 } from "node:child_process";
|
|
3929
|
+
function labelFor(kind) {
|
|
3930
|
+
return kind === "docs" ? WAIVER_LABEL : WAIVER_LABEL_REVIEW;
|
|
3931
|
+
}
|
|
3932
|
+
function gh(args) {
|
|
3933
|
+
return execFileSync8("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: process.env }).trim();
|
|
3934
|
+
}
|
|
3935
|
+
function detectPrNumber() {
|
|
3936
|
+
try {
|
|
3937
|
+
const out = gh(["pr", "view", "--json", "number", "-q", ".number"]);
|
|
3938
|
+
return /^\d+$/.test(out) ? out : null;
|
|
3939
|
+
} catch {
|
|
3940
|
+
return null;
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
var VALUE_FLAGS = ["--reason"];
|
|
3944
|
+
var BOOLEAN_FLAGS = ["--print-only"];
|
|
3945
|
+
function positionalArgs(args) {
|
|
3946
|
+
const result = [];
|
|
3947
|
+
for (let i = 0;i < args.length; i++) {
|
|
3948
|
+
const a = args[i];
|
|
3949
|
+
if (VALUE_FLAGS.some((f) => a.startsWith(`${f}=`)))
|
|
3950
|
+
continue;
|
|
3951
|
+
if (VALUE_FLAGS.includes(a)) {
|
|
3952
|
+
i += 1;
|
|
3953
|
+
continue;
|
|
3954
|
+
}
|
|
3955
|
+
if (BOOLEAN_FLAGS.includes(a))
|
|
3956
|
+
continue;
|
|
3957
|
+
result.push(a);
|
|
3958
|
+
}
|
|
3959
|
+
return result;
|
|
3960
|
+
}
|
|
3961
|
+
async function resolveKind(args) {
|
|
3962
|
+
const positional = positionalArgs(args).find((a) => a === "docs" || a === "review");
|
|
3963
|
+
if (positional === "docs" || positional === "review")
|
|
3964
|
+
return positional;
|
|
3965
|
+
const answer = (await prompt("Which waiver — `docs` or `review`? ")).trim().toLowerCase();
|
|
3966
|
+
return answer === "docs" || answer === "review" ? answer : null;
|
|
3967
|
+
}
|
|
3968
|
+
async function resolvePrNumber(args) {
|
|
3969
|
+
const positional = positionalArgs(args).find((a) => /^\d+$/.test(a));
|
|
3970
|
+
if (positional)
|
|
3971
|
+
return positional;
|
|
3972
|
+
const detected = detectPrNumber();
|
|
3973
|
+
if (detected)
|
|
3974
|
+
return detected;
|
|
3975
|
+
const answer = (await prompt("PR number (could not auto-detect one for the current branch): ")).trim();
|
|
3976
|
+
return /^\d+$/.test(answer) ? answer : null;
|
|
3977
|
+
}
|
|
3978
|
+
function flagValue(args, flag) {
|
|
3979
|
+
const eq = args.find((a) => a.startsWith(`${flag}=`));
|
|
3980
|
+
if (eq)
|
|
3981
|
+
return eq.slice(flag.length + 1);
|
|
3982
|
+
const idx = args.indexOf(flag);
|
|
3983
|
+
if (idx !== -1 && args[idx + 1] !== undefined)
|
|
3984
|
+
return args[idx + 1];
|
|
3985
|
+
return null;
|
|
3986
|
+
}
|
|
3987
|
+
async function resolveReason(args) {
|
|
3988
|
+
const flagged = flagValue(args, "--reason");
|
|
3989
|
+
if (flagged && flagged.trim().length > 0)
|
|
3990
|
+
return flagged.trim();
|
|
3991
|
+
const answer = await prompt("Reason for this waiver (one line — posted as a PR comment): ");
|
|
3992
|
+
return answer.trim();
|
|
3993
|
+
}
|
|
3994
|
+
async function waiverCommand(args) {
|
|
3995
|
+
const printOnly = args.includes("--print-only");
|
|
3996
|
+
const kind = await resolveKind(args);
|
|
3997
|
+
if (!kind) {
|
|
3998
|
+
console.error("Usage: vinaya waiver <docs|review> [<pr-number>] [--reason <text>] [--print-only]");
|
|
3999
|
+
closeStdin();
|
|
4000
|
+
process.exit(2);
|
|
4001
|
+
return;
|
|
4002
|
+
}
|
|
4003
|
+
const prNumber = await resolvePrNumber(args);
|
|
4004
|
+
if (!prNumber) {
|
|
4005
|
+
console.error("A PR number is required — none was given and none could be auto-detected.");
|
|
4006
|
+
closeStdin();
|
|
4007
|
+
process.exit(2);
|
|
4008
|
+
return;
|
|
4009
|
+
}
|
|
4010
|
+
const reason = await resolveReason(args);
|
|
4011
|
+
closeStdin();
|
|
4012
|
+
if (!reason) {
|
|
4013
|
+
console.error("A reason is required — the waiver label alone carries no rationale for the reviewer.");
|
|
4014
|
+
process.exit(2);
|
|
4015
|
+
return;
|
|
4016
|
+
}
|
|
4017
|
+
const label2 = labelFor(kind);
|
|
4018
|
+
const editArgs = ["pr", "edit", prNumber, "--add-label", label2];
|
|
4019
|
+
const commentArgs = ["pr", "comment", prNumber, "--body", `Waiver applied: \`${label2}\`
|
|
4020
|
+
Reason: ${reason}`];
|
|
4021
|
+
if (printOnly) {
|
|
4022
|
+
process.stdout.write(`--print-only: nothing below was executed. Run these yourself, as a human, to apply the waiver:
|
|
4023
|
+
|
|
4024
|
+
`);
|
|
4025
|
+
process.stdout.write(` gh ${editArgs.join(" ")}
|
|
4026
|
+
`);
|
|
4027
|
+
process.stdout.write(` gh ${commentArgs.join(" ")}
|
|
4028
|
+
`);
|
|
4029
|
+
return;
|
|
4030
|
+
}
|
|
4031
|
+
process.stdout.write(`Applying \`${label2}\` to PR #${prNumber} under your authenticated \`gh\` identity — vinaya never fabricates an actor.
|
|
4032
|
+
`);
|
|
4033
|
+
execFileSync8("gh", editArgs, { stdio: "inherit", env: process.env });
|
|
4034
|
+
execFileSync8("gh", commentArgs, { stdio: "inherit", env: process.env });
|
|
4035
|
+
process.stdout.write(`
|
|
4036
|
+
✓ Label applied and reason posted as a PR comment.
|
|
4037
|
+
`);
|
|
2980
4038
|
}
|
|
2981
4039
|
|
|
2982
4040
|
// ../sources/src/commands.ts
|
|
@@ -3101,6 +4159,30 @@ var COMMANDS = [
|
|
|
3101
4159
|
],
|
|
3102
4160
|
status: "shipped"
|
|
3103
4161
|
},
|
|
4162
|
+
{
|
|
4163
|
+
name: "archive",
|
|
4164
|
+
description: "Run the post-merge Archivist directly: provenance + Issue close-out for a merged task PR",
|
|
4165
|
+
flags: [{ flag: "--merge-sha", description: "The merge commit to resolve (defaults to the current HEAD)" }],
|
|
4166
|
+
details: [
|
|
4167
|
+
"Resolves the merge commit's associated PR via `gh`, and — if it's a task PR without a provenance comment yet — posts the provenance block and closes the linked Issue. Idempotent: re-running against an already-archived PR is a no-op.",
|
|
4168
|
+
"The same logic the generated `vinaya-archivist.yml` workflow's `post-merge` job runs on every push to `main` — callable directly for a one-off run or local verification."
|
|
4169
|
+
],
|
|
4170
|
+
status: "shipped"
|
|
4171
|
+
},
|
|
4172
|
+
{
|
|
4173
|
+
name: "audit",
|
|
4174
|
+
description: "Run the ring-2 dead-branch-push and direct-main-push detection checks directly",
|
|
4175
|
+
flags: [
|
|
4176
|
+
{ flag: "--only", description: "Scope to one check: 'dead-branches' or 'direct-push'" },
|
|
4177
|
+
{ flag: "--sha", description: "The commit to check for direct-main-push (defaults to the current HEAD)" },
|
|
4178
|
+
{ flag: "--json", description: "Enveloped JSON output" }
|
|
4179
|
+
],
|
|
4180
|
+
details: [
|
|
4181
|
+
"Dead-branch-push is never-red — a notification channel that flags (label + PR comment) any `task/*` branch whose tip commit lands after its own PR already resolved. Direct-main-push is a real pass/fail — it opens an incident Issue and exits 1 if a commit on `main` has no associated merged PR.",
|
|
4182
|
+
"The same logic the generated `vinaya-archivist.yml` workflow's `daily-drift` and `direct-main-push-detection` jobs run on schedule / on every push to `main` — callable directly for a one-off run or local verification."
|
|
4183
|
+
],
|
|
4184
|
+
status: "shipped"
|
|
4185
|
+
},
|
|
3104
4186
|
{
|
|
3105
4187
|
name: "eject",
|
|
3106
4188
|
description: "Remove every Vinaya-installed artifact, restoring the repo to stock",
|
|
@@ -3113,12 +4195,25 @@ var COMMANDS = [
|
|
|
3113
4195
|
{
|
|
3114
4196
|
name: "demo break",
|
|
3115
4197
|
description: "Run a guided refusal-then-fix demo on an isolated, discardable branch",
|
|
3116
|
-
|
|
4198
|
+
flags: [{ flag: "--keep", description: "Skip cleanup and leave the demo branch checked out to inspect" }],
|
|
4199
|
+
details: [
|
|
4200
|
+
"Creates a collision-safe `vinaya/demo-break-<id>` branch off the current one, stages a deliberately incomplete draft brief, and attempts a real `git commit` — the repo's actually-installed pre-commit hook refuses it with its real output, not a scripted string. Applies the minimal fix, commits again, then switches back and deletes the demo branch.",
|
|
4201
|
+
"Safe to run twice: refuses on a dirty working tree, refuses from a detached HEAD, and recovers automatically from a prior crashed run before starting a fresh one — never leaves the original branch touched or a stray demo branch behind."
|
|
4202
|
+
],
|
|
4203
|
+
status: "shipped"
|
|
3117
4204
|
},
|
|
3118
4205
|
{
|
|
3119
4206
|
name: "waiver",
|
|
3120
|
-
description: "Apply the
|
|
3121
|
-
|
|
4207
|
+
description: "Apply the actor-verified 'vinaya/waiver:docs' or 'vinaya/waiver:review' label after prompting for a reason",
|
|
4208
|
+
flags: [
|
|
4209
|
+
{ flag: "--reason", description: "The waiver rationale, posted as a PR comment (prompted for if omitted)" },
|
|
4210
|
+
{ flag: "--print-only", description: "Print the exact `gh` commands instead of running them" }
|
|
4211
|
+
],
|
|
4212
|
+
details: [
|
|
4213
|
+
"Applies the label via `gh pr edit --add-label`, under the invoking human's own authenticated `gh` identity — this command never fabricates an actor. A waiver is never an agent-emittable string: not a PR body field, not a commit trailer, not a comment — the label plus its own labeling-timeline actor is the only mechanism ring 1 honors.",
|
|
4214
|
+
"`--print-only` prints the exact `gh pr edit`/`gh pr comment` commands and runs nothing — for a human who wants to run them itself, or a CI/non-interactive context where an agent session should never be the one applying its own waiver."
|
|
4215
|
+
],
|
|
4216
|
+
status: "shipped"
|
|
3122
4217
|
},
|
|
3123
4218
|
{
|
|
3124
4219
|
name: "studio",
|
|
@@ -3155,9 +4250,9 @@ function printHelp() {
|
|
|
3155
4250
|
}
|
|
3156
4251
|
|
|
3157
4252
|
// src/index.ts
|
|
3158
|
-
var PACKAGE_ROOT =
|
|
4253
|
+
var PACKAGE_ROOT = join15(dirname5(fileURLToPath2(import.meta.url)), "..");
|
|
3159
4254
|
function readVersion2() {
|
|
3160
|
-
const pkg = JSON.parse(
|
|
4255
|
+
const pkg = JSON.parse(readFileSync11(join15(PACKAGE_ROOT, "package.json"), "utf-8"));
|
|
3161
4256
|
return pkg.version;
|
|
3162
4257
|
}
|
|
3163
4258
|
var [, , command, ...args] = process.argv;
|
|
@@ -3207,6 +4302,14 @@ try {
|
|
|
3207
4302
|
await checkCommand(args);
|
|
3208
4303
|
break;
|
|
3209
4304
|
}
|
|
4305
|
+
case "archive": {
|
|
4306
|
+
await archiveCommand(args);
|
|
4307
|
+
break;
|
|
4308
|
+
}
|
|
4309
|
+
case "audit": {
|
|
4310
|
+
await auditCommand(args);
|
|
4311
|
+
break;
|
|
4312
|
+
}
|
|
3210
4313
|
case "new": {
|
|
3211
4314
|
const [subcommand, ...rest] = args;
|
|
3212
4315
|
if (subcommand === "check") {
|
|
@@ -3241,6 +4344,20 @@ try {
|
|
|
3241
4344
|
}
|
|
3242
4345
|
break;
|
|
3243
4346
|
}
|
|
4347
|
+
case "demo": {
|
|
4348
|
+
const [subcommand, ...rest] = args;
|
|
4349
|
+
if (subcommand === "break") {
|
|
4350
|
+
await demoBreakCommand(rest);
|
|
4351
|
+
} else {
|
|
4352
|
+
console.error(`Unknown 'demo' subcommand: ${subcommand ?? "(none)"} (expected 'break')`);
|
|
4353
|
+
process.exit(2);
|
|
4354
|
+
}
|
|
4355
|
+
break;
|
|
4356
|
+
}
|
|
4357
|
+
case "waiver": {
|
|
4358
|
+
await waiverCommand(args);
|
|
4359
|
+
break;
|
|
4360
|
+
}
|
|
3244
4361
|
default:
|
|
3245
4362
|
console.error(`Unknown command: ${command}`);
|
|
3246
4363
|
printHelp();
|