@attalabs/vinaya 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -661
- package/README.md +1 -1
- package/dist/index.js +1548 -447
- package/package.json +4 -3
- 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 join14 } 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,873 @@ 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 { join as join3 } from "node:path";
|
|
1248
|
+
|
|
1249
|
+
// src/lib/package-root.ts
|
|
1250
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
1251
|
+
import { dirname, join as join2 } from "node:path";
|
|
1252
|
+
import { fileURLToPath } from "node:url";
|
|
1253
|
+
function packageRoot(moduleUrl) {
|
|
1254
|
+
let dir = dirname(fileURLToPath(moduleUrl));
|
|
1255
|
+
while (!existsSync2(join2(dir, "package.json"))) {
|
|
1256
|
+
const parent = dirname(dir);
|
|
1257
|
+
if (parent === dir)
|
|
1258
|
+
break;
|
|
1259
|
+
dir = parent;
|
|
1260
|
+
}
|
|
1261
|
+
return dir;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// src/checks/registry.ts
|
|
1265
|
+
var BIN_DIR = join3(packageRoot(import.meta.url), "src", "checks", "bin");
|
|
1266
|
+
function coreCheckRegistry() {
|
|
1267
|
+
return [
|
|
1268
|
+
{
|
|
1269
|
+
name: "brief-shape",
|
|
1270
|
+
run: join3(BIN_DIR, "check-brief-shape.ts"),
|
|
1271
|
+
scope: "diff",
|
|
1272
|
+
timeoutMs: 15000
|
|
1273
|
+
},
|
|
1274
|
+
{
|
|
1275
|
+
name: "doc-coverage",
|
|
1276
|
+
run: join3(BIN_DIR, "check-doc-coverage.ts"),
|
|
1277
|
+
scope: "diff",
|
|
1278
|
+
timeoutMs: 15000
|
|
1279
|
+
},
|
|
1280
|
+
{
|
|
1281
|
+
name: "coherence",
|
|
1282
|
+
run: join3(BIN_DIR, "check-coherence.ts"),
|
|
1283
|
+
scope: "full",
|
|
1284
|
+
timeoutMs: 30000
|
|
1285
|
+
},
|
|
1286
|
+
{
|
|
1287
|
+
name: "dispatch-readiness",
|
|
1288
|
+
run: join3(BIN_DIR, "check-dispatch-readiness.ts"),
|
|
1289
|
+
scope: "full",
|
|
1290
|
+
timeoutMs: 30000
|
|
1291
|
+
},
|
|
1292
|
+
{
|
|
1293
|
+
name: "closes-n",
|
|
1294
|
+
run: join3(BIN_DIR, "check-closes-n.ts"),
|
|
1295
|
+
scope: "diff",
|
|
1296
|
+
timeoutMs: 15000
|
|
1297
|
+
},
|
|
1298
|
+
{
|
|
1299
|
+
name: "single-plan-pr",
|
|
1300
|
+
run: join3(BIN_DIR, "check-single-plan-pr.ts"),
|
|
1301
|
+
scope: "diff",
|
|
1302
|
+
timeoutMs: 15000
|
|
1303
|
+
},
|
|
1304
|
+
{
|
|
1305
|
+
name: "test-plan",
|
|
1306
|
+
run: join3(BIN_DIR, "check-test-plan.ts"),
|
|
1307
|
+
scope: "diff",
|
|
1308
|
+
timeoutMs: 15000
|
|
1309
|
+
},
|
|
1310
|
+
{
|
|
1311
|
+
name: "no-disk-state",
|
|
1312
|
+
run: join3(BIN_DIR, "check-no-disk-state.ts"),
|
|
1313
|
+
scope: "diff",
|
|
1314
|
+
timeoutMs: 15000
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
name: "registry-gates",
|
|
1318
|
+
run: join3(BIN_DIR, "check-registry-gates.ts"),
|
|
1319
|
+
scope: "full",
|
|
1320
|
+
timeoutMs: 30000
|
|
1321
|
+
},
|
|
1322
|
+
{
|
|
1323
|
+
name: "review-gate",
|
|
1324
|
+
run: join3(BIN_DIR, "check-review-gate.ts"),
|
|
1325
|
+
scope: "full",
|
|
1326
|
+
timeoutMs: 30000
|
|
1327
|
+
},
|
|
1328
|
+
{
|
|
1329
|
+
name: "branch-topology",
|
|
1330
|
+
run: join3(BIN_DIR, "check-branch-topology.ts"),
|
|
1331
|
+
scope: "full",
|
|
1332
|
+
timeoutMs: 30000
|
|
1333
|
+
},
|
|
1334
|
+
{
|
|
1335
|
+
name: "dead-branch-push",
|
|
1336
|
+
run: join3(BIN_DIR, "check-dead-branch-push.ts"),
|
|
1337
|
+
scope: "full",
|
|
1338
|
+
timeoutMs: 30000
|
|
1339
|
+
},
|
|
1340
|
+
{
|
|
1341
|
+
name: "first-push-dispatch",
|
|
1342
|
+
run: join3(BIN_DIR, "check-first-push-dispatch.ts"),
|
|
1343
|
+
scope: "full",
|
|
1344
|
+
timeoutMs: 30000
|
|
1345
|
+
},
|
|
1346
|
+
{
|
|
1347
|
+
name: "doc-coverage-push",
|
|
1348
|
+
run: join3(BIN_DIR, "check-doc-coverage-push.ts"),
|
|
1349
|
+
scope: "diff",
|
|
1350
|
+
timeoutMs: 15000
|
|
1351
|
+
},
|
|
1352
|
+
{
|
|
1353
|
+
name: "issue-assignment",
|
|
1354
|
+
run: join3(BIN_DIR, "check-issue-assignment.ts"),
|
|
1355
|
+
scope: "full",
|
|
1356
|
+
timeoutMs: 30000
|
|
1357
|
+
}
|
|
1358
|
+
];
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// src/checks/runner.ts
|
|
1362
|
+
import { spawn } from "node:child_process";
|
|
1363
|
+
import { cpus } from "node:os";
|
|
1364
|
+
function defaultParallelism() {
|
|
1365
|
+
return Math.max(1, cpus().length);
|
|
1366
|
+
}
|
|
1367
|
+
function isCheckError(value) {
|
|
1368
|
+
if (typeof value !== "object" || value === null)
|
|
1369
|
+
return false;
|
|
1370
|
+
const v = value;
|
|
1371
|
+
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";
|
|
1372
|
+
}
|
|
1373
|
+
function shouldSkip(spec, opts) {
|
|
1374
|
+
if (spec.scope !== "diff")
|
|
1375
|
+
return false;
|
|
1376
|
+
if (!opts.diffOnly)
|
|
1377
|
+
return false;
|
|
1378
|
+
if (opts.changedFiles === null)
|
|
1379
|
+
return false;
|
|
1380
|
+
if (!spec.include || spec.include.length === 0)
|
|
1381
|
+
return false;
|
|
1382
|
+
const regexes = spec.include.map(globToRegex);
|
|
1383
|
+
return !opts.changedFiles.some((f) => regexes.some((re) => re.test(f)));
|
|
1384
|
+
}
|
|
1385
|
+
var KILL_GRACE_MS = 2000;
|
|
1386
|
+
async function runOne(spec, timeoutMs) {
|
|
1387
|
+
const start = performance.now();
|
|
1388
|
+
const proc = spawn(spec.run, spec.args ?? [], {
|
|
1389
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1390
|
+
});
|
|
1391
|
+
let timedOut = false;
|
|
1392
|
+
let killTimer;
|
|
1393
|
+
const timer = setTimeout(() => {
|
|
1394
|
+
timedOut = true;
|
|
1395
|
+
proc.kill("SIGTERM");
|
|
1396
|
+
killTimer = setTimeout(() => proc.kill("SIGKILL"), KILL_GRACE_MS);
|
|
1397
|
+
}, timeoutMs);
|
|
1398
|
+
let stderrText = "";
|
|
1399
|
+
proc.stderr.setEncoding("utf-8");
|
|
1400
|
+
proc.stderr.on("data", (chunk) => {
|
|
1401
|
+
stderrText += chunk;
|
|
1402
|
+
});
|
|
1403
|
+
proc.stdout.resume();
|
|
1404
|
+
let spawnError;
|
|
1405
|
+
const exitCode = await new Promise((resolve) => {
|
|
1406
|
+
proc.on("close", (code) => resolve(code));
|
|
1407
|
+
proc.on("error", (err) => {
|
|
1408
|
+
spawnError = err;
|
|
1409
|
+
resolve(null);
|
|
1410
|
+
});
|
|
1411
|
+
});
|
|
1412
|
+
clearTimeout(timer);
|
|
1413
|
+
clearTimeout(killTimer);
|
|
1414
|
+
const durationMs = performance.now() - start;
|
|
1415
|
+
if (timedOut) {
|
|
1416
|
+
return { name: spec.name, status: "timeout", exitCode: null, errors: [], durationMs };
|
|
1417
|
+
}
|
|
1418
|
+
if (spawnError) {
|
|
1419
|
+
const code = spawnError.code;
|
|
1420
|
+
return {
|
|
1421
|
+
name: spec.name,
|
|
1422
|
+
status: "error",
|
|
1423
|
+
exitCode: null,
|
|
1424
|
+
errors: [
|
|
1425
|
+
{
|
|
1426
|
+
schema: 1,
|
|
1427
|
+
check: spec.name,
|
|
1428
|
+
severity: "error",
|
|
1429
|
+
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}`,
|
|
1430
|
+
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.`
|
|
1431
|
+
}
|
|
1432
|
+
],
|
|
1433
|
+
durationMs
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
const lines = stderrText.split(`
|
|
1437
|
+
`).map((l) => l.trim()).filter(Boolean);
|
|
1438
|
+
const errors = [];
|
|
1439
|
+
let malformed = false;
|
|
1440
|
+
for (const line of lines) {
|
|
1441
|
+
try {
|
|
1442
|
+
const parsed = JSON.parse(line);
|
|
1443
|
+
if (isCheckError(parsed)) {
|
|
1444
|
+
errors.push(parsed);
|
|
1445
|
+
} else {
|
|
1446
|
+
malformed = true;
|
|
1447
|
+
}
|
|
1448
|
+
} catch {
|
|
1449
|
+
malformed = true;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
let status;
|
|
1453
|
+
if (malformed) {
|
|
1454
|
+
status = "error";
|
|
1455
|
+
} else if (exitCode === 0) {
|
|
1456
|
+
status = "pass";
|
|
1457
|
+
} else if (exitCode === 1) {
|
|
1458
|
+
status = "fail";
|
|
1459
|
+
} else {
|
|
1460
|
+
status = "error";
|
|
1461
|
+
}
|
|
1462
|
+
return { name: spec.name, status, exitCode, errors, durationMs };
|
|
1463
|
+
}
|
|
1464
|
+
async function runChecks(specs, opts) {
|
|
1465
|
+
const results = new Array(specs.length);
|
|
1466
|
+
const toRun = [];
|
|
1467
|
+
for (let i = 0;i < specs.length; i++) {
|
|
1468
|
+
const spec = specs[i];
|
|
756
1469
|
if (shouldSkip(spec, opts)) {
|
|
757
1470
|
results[i] = { name: spec.name, status: "skipped", exitCode: null, errors: [], durationMs: 0 };
|
|
758
1471
|
} else {
|
|
@@ -774,9 +1487,9 @@ async function runChecks(specs, opts) {
|
|
|
774
1487
|
}
|
|
775
1488
|
|
|
776
1489
|
// src/lib/config.ts
|
|
777
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1490
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
778
1491
|
import { homedir } from "node:os";
|
|
779
|
-
import { dirname as dirname2, join as
|
|
1492
|
+
import { dirname as dirname2, join as join4 } from "node:path";
|
|
780
1493
|
import { z } from "zod";
|
|
781
1494
|
var CheckEntrySchema = z.object({
|
|
782
1495
|
run: z.string(),
|
|
@@ -842,14 +1555,14 @@ var VinayaConfigSchema = z.object({
|
|
|
842
1555
|
briefSchema: BriefSchemaSchema.optional(),
|
|
843
1556
|
managed: ManagedManifestSchema.optional()
|
|
844
1557
|
});
|
|
845
|
-
var GLOBAL_VINAYA_HOME =
|
|
846
|
-
var GLOBAL_CONFIG_PATH =
|
|
1558
|
+
var GLOBAL_VINAYA_HOME = join4(homedir(), ".vinaya");
|
|
1559
|
+
var GLOBAL_CONFIG_PATH = join4(GLOBAL_VINAYA_HOME, "config.json");
|
|
847
1560
|
var LOCAL_CONFIG_FILENAME = "vinaya.config.json";
|
|
848
1561
|
function findLocalConfig() {
|
|
849
1562
|
let dir = process.cwd();
|
|
850
1563
|
while (true) {
|
|
851
|
-
const candidate =
|
|
852
|
-
if (
|
|
1564
|
+
const candidate = join4(dir, LOCAL_CONFIG_FILENAME);
|
|
1565
|
+
if (existsSync3(candidate))
|
|
853
1566
|
return candidate;
|
|
854
1567
|
const parent = dirname2(dir);
|
|
855
1568
|
if (parent === dir)
|
|
@@ -861,7 +1574,7 @@ function configPath() {
|
|
|
861
1574
|
const local = findLocalConfig();
|
|
862
1575
|
if (local)
|
|
863
1576
|
return local;
|
|
864
|
-
if (
|
|
1577
|
+
if (existsSync3(GLOBAL_CONFIG_PATH))
|
|
865
1578
|
return GLOBAL_CONFIG_PATH;
|
|
866
1579
|
return null;
|
|
867
1580
|
}
|
|
@@ -883,20 +1596,10 @@ function loadConfigChecked() {
|
|
|
883
1596
|
return { ok: true, config: parsed.data };
|
|
884
1597
|
}
|
|
885
1598
|
|
|
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
1599
|
// src/commands/check.ts
|
|
897
1600
|
function git(args) {
|
|
898
1601
|
try {
|
|
899
|
-
return
|
|
1602
|
+
return execFileSync4("git", args, { encoding: "utf8" }).trim();
|
|
900
1603
|
} catch {
|
|
901
1604
|
return "";
|
|
902
1605
|
}
|
|
@@ -983,16 +1686,217 @@ async function checkCommand(args) {
|
|
|
983
1686
|
process.exit(failed ? 1 : 0);
|
|
984
1687
|
}
|
|
985
1688
|
|
|
1689
|
+
// src/commands/demo.ts
|
|
1690
|
+
import { execFileSync as execFileSync5, spawnSync } from "node:child_process";
|
|
1691
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2, rmSync, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1692
|
+
import { isAbsolute, join as join5 } from "node:path";
|
|
1693
|
+
var DEMO_BRANCH_PREFIX = "vinaya/demo-break-";
|
|
1694
|
+
var FIXTURE_PATH = ".vinaya-demo-brief.md";
|
|
1695
|
+
var STATE_FILENAME = "vinaya-demo-state.json";
|
|
1696
|
+
function gitCapture(repoRoot, args) {
|
|
1697
|
+
return execFileSync5("git", args, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
1698
|
+
}
|
|
1699
|
+
function branchExists(repoRoot, name) {
|
|
1700
|
+
try {
|
|
1701
|
+
execFileSync5("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`], {
|
|
1702
|
+
cwd: repoRoot,
|
|
1703
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1704
|
+
});
|
|
1705
|
+
return true;
|
|
1706
|
+
} catch {
|
|
1707
|
+
return false;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
function createDemoBranch(repoRoot) {
|
|
1711
|
+
for (let attempt = 0;attempt < 5; attempt++) {
|
|
1712
|
+
const candidate = `${DEMO_BRANCH_PREFIX}${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1713
|
+
if (!branchExists(repoRoot, candidate)) {
|
|
1714
|
+
gitCapture(repoRoot, ["checkout", "-b", candidate]);
|
|
1715
|
+
return candidate;
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
throw new Error("Could not generate a collision-free demo branch name after 5 attempts.");
|
|
1719
|
+
}
|
|
1720
|
+
function gitDirAbs(repoRoot) {
|
|
1721
|
+
const raw = gitCapture(repoRoot, ["rev-parse", "--git-dir"]);
|
|
1722
|
+
return isAbsolute(raw) ? raw : join5(repoRoot, raw);
|
|
1723
|
+
}
|
|
1724
|
+
function recoverFromCrash(repoRoot, statePath) {
|
|
1725
|
+
const currentBranch = gitCapture(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
1726
|
+
let state = null;
|
|
1727
|
+
if (existsSync4(statePath)) {
|
|
1728
|
+
try {
|
|
1729
|
+
state = JSON.parse(readFileSync2(statePath, "utf-8"));
|
|
1730
|
+
} catch {
|
|
1731
|
+
state = null;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
const onStrayBranch = currentBranch.startsWith(DEMO_BRANCH_PREFIX);
|
|
1735
|
+
if (!state) {
|
|
1736
|
+
if (onStrayBranch) {
|
|
1737
|
+
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`.");
|
|
1738
|
+
}
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
process.stdout.write(`→ Found a leftover demo branch from a previous run — cleaning it up first.
|
|
1742
|
+
`);
|
|
1743
|
+
if (onStrayBranch) {
|
|
1744
|
+
gitCapture(repoRoot, ["reset", "--hard"]);
|
|
1745
|
+
gitCapture(repoRoot, ["clean", "-fd"]);
|
|
1746
|
+
if (!branchExists(repoRoot, state.originalBranch)) {
|
|
1747
|
+
throw new Error(`Recorded original branch \`${state.originalBranch}\` no longer exists. ` + "Checkout the branch you want manually, then re-run `vinaya demo break`.");
|
|
1748
|
+
}
|
|
1749
|
+
gitCapture(repoRoot, ["checkout", state.originalBranch]);
|
|
1750
|
+
}
|
|
1751
|
+
if (branchExists(repoRoot, state.demoBranch)) {
|
|
1752
|
+
gitCapture(repoRoot, ["branch", "-D", state.demoBranch]);
|
|
1753
|
+
}
|
|
1754
|
+
rmSync(statePath, { force: true });
|
|
1755
|
+
}
|
|
1756
|
+
function attemptCommit(repoRoot, demoBranch, prBody, message) {
|
|
1757
|
+
const result = spawnSync("git", ["commit", "-m", message], {
|
|
1758
|
+
cwd: repoRoot,
|
|
1759
|
+
encoding: "utf8",
|
|
1760
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1761
|
+
env: { ...process.env, PR_BODY: prBody, BRANCH: demoBranch }
|
|
1762
|
+
});
|
|
1763
|
+
return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
1764
|
+
}
|
|
1765
|
+
function briefFixture(opts) {
|
|
1766
|
+
const tierLine = opts.includeTier ? `Tier: 1
|
|
1767
|
+
` : "";
|
|
1768
|
+
return `For: vinaya demo
|
|
1769
|
+
Project: vinaya-demo-fixture
|
|
1770
|
+
${tierLine}
|
|
1771
|
+
## Summary
|
|
1772
|
+
Fixture brief staged only by \`vinaya demo break\` — discarded with the demo branch.
|
|
1773
|
+
|
|
1774
|
+
## Technical surface map
|
|
1775
|
+
None — fixture content only; no real files change.
|
|
1776
|
+
|
|
1777
|
+
## Documentation-update list
|
|
1778
|
+
None — fixture content only.
|
|
1779
|
+
|
|
1780
|
+
## Test Plan
|
|
1781
|
+
Test Plan: unit-tests-only
|
|
1782
|
+
|
|
1783
|
+
## Stop Conditions
|
|
1784
|
+
Autonomy: Do not stop to ask clarifying questions — this is a scripted demo; proceed and clean up automatically.
|
|
1785
|
+
|
|
1786
|
+
Illustrative Step 0 (never actually run by this demo):
|
|
1787
|
+
\`\`\`
|
|
1788
|
+
git worktree add .worktrees/demo-fixture -b demo/fixture origin/main
|
|
1789
|
+
\`\`\`
|
|
1790
|
+
|
|
1791
|
+
Closes #0 (fixture value — no real Issue; discarded with the demo branch)
|
|
1792
|
+
`;
|
|
1793
|
+
}
|
|
1794
|
+
function cleanup(repoRoot, originalBranch, demoBranch, statePath) {
|
|
1795
|
+
gitCapture(repoRoot, ["reset", "--hard"]);
|
|
1796
|
+
gitCapture(repoRoot, ["clean", "-fd"]);
|
|
1797
|
+
gitCapture(repoRoot, ["checkout", originalBranch]);
|
|
1798
|
+
gitCapture(repoRoot, ["branch", "-D", demoBranch]);
|
|
1799
|
+
if (existsSync4(statePath))
|
|
1800
|
+
rmSync(statePath, { force: true });
|
|
1801
|
+
}
|
|
1802
|
+
async function runDemoBreak(repoRoot, args) {
|
|
1803
|
+
const keep = args.includes("--keep");
|
|
1804
|
+
const hookDir = resolveHookDir(repoRoot);
|
|
1805
|
+
const hookPath = join5(repoRoot, hookDir, "pre-commit");
|
|
1806
|
+
if (!existsSync4(hookPath)) {
|
|
1807
|
+
console.error("Vinaya hooks are not installed in this repo. Run `vinaya init` first.");
|
|
1808
|
+
return 1;
|
|
1809
|
+
}
|
|
1810
|
+
const statePath = join5(gitDirAbs(repoRoot), STATE_FILENAME);
|
|
1811
|
+
recoverFromCrash(repoRoot, statePath);
|
|
1812
|
+
const dirty = gitCapture(repoRoot, ["status", "--porcelain"]);
|
|
1813
|
+
if (dirty.length > 0) {
|
|
1814
|
+
console.error("Working tree has uncommitted changes. Commit or stash them before running `vinaya demo break` — " + "the demo never touches a dirty tree.");
|
|
1815
|
+
return 1;
|
|
1816
|
+
}
|
|
1817
|
+
const originalBranch = gitCapture(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
1818
|
+
if (originalBranch === "HEAD") {
|
|
1819
|
+
console.error("Cannot run `vinaya demo break` from a detached HEAD — checkout a branch first.");
|
|
1820
|
+
return 1;
|
|
1821
|
+
}
|
|
1822
|
+
const demoBranch = createDemoBranch(repoRoot);
|
|
1823
|
+
writeFileSync2(statePath, JSON.stringify({ originalBranch, demoBranch }), "utf-8");
|
|
1824
|
+
process.stdout.write(`
|
|
1825
|
+
→ Created isolated branch \`${demoBranch}\` off \`${originalBranch}\`.
|
|
1826
|
+
|
|
1827
|
+
`);
|
|
1828
|
+
const fixturePath = join5(repoRoot, FIXTURE_PATH);
|
|
1829
|
+
writeFileSync2(fixturePath, briefFixture({ includeTier: false }), "utf-8");
|
|
1830
|
+
gitCapture(repoRoot, ["add", FIXTURE_PATH]);
|
|
1831
|
+
process.stdout.write(`Staged \`${FIXTURE_PATH}\` — a brief missing its required \`Tier:\` field.
|
|
1832
|
+
Attempting to commit…
|
|
1833
|
+
|
|
1834
|
+
`);
|
|
1835
|
+
const badResult = attemptCommit(repoRoot, demoBranch, briefFixture({ includeTier: false }), "Chore(demo): stage a malformed brief");
|
|
1836
|
+
if (badResult.status === 0) {
|
|
1837
|
+
console.error("Expected the installed hook to refuse this commit, but it passed — the demo cannot show a real refusal here.");
|
|
1838
|
+
cleanup(repoRoot, originalBranch, demoBranch, statePath);
|
|
1839
|
+
return 1;
|
|
1840
|
+
}
|
|
1841
|
+
process.stdout.write(`✗ Commit refused — the real installed hook output:
|
|
1842
|
+
|
|
1843
|
+
`);
|
|
1844
|
+
process.stdout.write(badResult.stdout);
|
|
1845
|
+
process.stdout.write(badResult.stderr);
|
|
1846
|
+
process.stdout.write(`
|
|
1847
|
+
→ Fixing the brief: adding the missing \`Tier:\` field…
|
|
1848
|
+
|
|
1849
|
+
`);
|
|
1850
|
+
writeFileSync2(fixturePath, briefFixture({ includeTier: true }), "utf-8");
|
|
1851
|
+
gitCapture(repoRoot, ["add", FIXTURE_PATH]);
|
|
1852
|
+
const goodResult = attemptCommit(repoRoot, demoBranch, briefFixture({ includeTier: true }), "Chore(demo): fix the brief and commit");
|
|
1853
|
+
if (goodResult.status !== 0) {
|
|
1854
|
+
console.error(`The fixed brief still failed the hook — this demo cannot proceed:
|
|
1855
|
+
`);
|
|
1856
|
+
console.error(goodResult.stdout);
|
|
1857
|
+
console.error(goodResult.stderr);
|
|
1858
|
+
cleanup(repoRoot, originalBranch, demoBranch, statePath);
|
|
1859
|
+
return 1;
|
|
1860
|
+
}
|
|
1861
|
+
process.stdout.write(`✓ Commit passed — the fix worked.
|
|
1862
|
+
`);
|
|
1863
|
+
if (keep) {
|
|
1864
|
+
if (existsSync4(statePath))
|
|
1865
|
+
unlinkSync(statePath);
|
|
1866
|
+
process.stdout.write(`
|
|
1867
|
+
--keep: leaving \`${demoBranch}\` checked out for you to inspect. Clean up with:
|
|
1868
|
+
|
|
1869
|
+
`);
|
|
1870
|
+
process.stdout.write(` git checkout ${originalBranch} && git branch -D ${demoBranch}
|
|
1871
|
+
`);
|
|
1872
|
+
return 0;
|
|
1873
|
+
}
|
|
1874
|
+
cleanup(repoRoot, originalBranch, demoBranch, statePath);
|
|
1875
|
+
process.stdout.write(`
|
|
1876
|
+
✓ Cleaned up — back on \`${originalBranch}\`, \`${demoBranch}\` deleted.
|
|
1877
|
+
`);
|
|
1878
|
+
return 0;
|
|
1879
|
+
}
|
|
1880
|
+
async function demoBreakCommand(args) {
|
|
1881
|
+
const repo = await detectGitRepo();
|
|
1882
|
+
if (!repo) {
|
|
1883
|
+
console.error("Error: not a git repository. Run `vinaya demo break` from inside your repo.");
|
|
1884
|
+
process.exit(1);
|
|
1885
|
+
}
|
|
1886
|
+
const code = await runDemoBreak(repo.repoRoot, args);
|
|
1887
|
+
process.exit(code);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
986
1890
|
// src/commands/doctor.ts
|
|
987
|
-
import { existsSync as
|
|
988
|
-
import {
|
|
989
|
-
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1891
|
+
import { existsSync as existsSync6, readFileSync as readFileSync4, statSync } from "node:fs";
|
|
1892
|
+
import { join as join7 } from "node:path";
|
|
990
1893
|
|
|
991
1894
|
// src/lib/artifacts.ts
|
|
992
1895
|
var CONFIG_PATH = "vinaya.config.json";
|
|
993
1896
|
var DOCTRINE_POINTER_PATH = "VINAYA.md";
|
|
994
1897
|
var CHECKS_WORKFLOW_PATH = ".github/workflows/vinaya-checks.yml";
|
|
995
1898
|
var REVIEW_WORKFLOW_PATH = ".github/workflows/vinaya-review.yml";
|
|
1899
|
+
var ARCHIVIST_WORKFLOW_PATH = ".github/workflows/vinaya-archivist.yml";
|
|
996
1900
|
var MANAGED_NOTE = "Managed by Vinaya — created by `vinaya init`. `vinaya upgrade` regenerates it; `vinaya eject` removes it.";
|
|
997
1901
|
function starterConfig() {
|
|
998
1902
|
return {
|
|
@@ -1039,7 +1943,7 @@ jobs:
|
|
|
1039
1943
|
with:
|
|
1040
1944
|
node-version: 20
|
|
1041
1945
|
- name: Run checks
|
|
1042
|
-
run: npx --yes vinaya check --all --diff-only
|
|
1946
|
+
run: npx --yes @attalabs/vinaya check --all --diff-only
|
|
1043
1947
|
`;
|
|
1044
1948
|
}
|
|
1045
1949
|
function reviewWorkflow() {
|
|
@@ -1058,44 +1962,123 @@ on:
|
|
|
1058
1962
|
issue_comment:
|
|
1059
1963
|
types: [created]
|
|
1060
1964
|
|
|
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'))
|
|
1965
|
+
jobs:
|
|
1966
|
+
vinaya-review:
|
|
1967
|
+
name: vinaya review gate
|
|
1968
|
+
if: >
|
|
1969
|
+
github.event_name == 'pull_request' ||
|
|
1970
|
+
(github.event.issue.pull_request != null && contains(github.event.comment.body, 'VERDICT'))
|
|
1971
|
+
runs-on: ubuntu-latest
|
|
1972
|
+
permissions:
|
|
1973
|
+
contents: read
|
|
1974
|
+
pull-requests: read
|
|
1975
|
+
issues: read
|
|
1976
|
+
steps:
|
|
1977
|
+
# issue_comment payloads carry no PR head SHA/branch — resolve them
|
|
1978
|
+
# before checkout, and check out that exact commit (the event's default
|
|
1979
|
+
# ref is the repo's default branch, not the PR head).
|
|
1980
|
+
- name: Resolve PR head
|
|
1981
|
+
id: pr
|
|
1982
|
+
env:
|
|
1983
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
1984
|
+
run: |
|
|
1985
|
+
if [ "\${{ github.event_name }}" = "pull_request" ]; then
|
|
1986
|
+
NUMBER="\${{ github.event.pull_request.number }}"
|
|
1987
|
+
else
|
|
1988
|
+
NUMBER="\${{ github.event.issue.number }}"
|
|
1989
|
+
fi
|
|
1990
|
+
SHA=$(gh pr view "$NUMBER" --repo "\${{ github.repository }}" --json headRefOid -q .headRefOid)
|
|
1991
|
+
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
|
1992
|
+
- uses: actions/checkout@v4
|
|
1993
|
+
with:
|
|
1994
|
+
ref: \${{ steps.pr.outputs.sha }}
|
|
1995
|
+
fetch-depth: 0
|
|
1996
|
+
- uses: actions/setup-node@v4
|
|
1997
|
+
with:
|
|
1998
|
+
node-version: 20
|
|
1999
|
+
- name: Review gate
|
|
2000
|
+
env:
|
|
2001
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
2002
|
+
run: npx --yes @attalabs/vinaya check --all
|
|
2003
|
+
`;
|
|
2004
|
+
}
|
|
2005
|
+
function archivistWorkflow() {
|
|
2006
|
+
return `# ${MANAGED_NOTE}
|
|
2007
|
+
#
|
|
2008
|
+
# The ring-2 post-merge/scheduled mechanisms: per-task Archivist provenance
|
|
2009
|
+
# + close-out (post-merge), dead-branch-push drift (daily-drift, a
|
|
2010
|
+
# notification channel — never fails red), and direct-main-push detection
|
|
2011
|
+
# (direct-main-push-detection, a real pass/fail).
|
|
2012
|
+
name: Vinaya Archivist
|
|
2013
|
+
|
|
2014
|
+
on:
|
|
2015
|
+
push:
|
|
2016
|
+
branches: [main]
|
|
2017
|
+
schedule:
|
|
2018
|
+
- cron: "0 2 * * *" # daily at 02:00 UTC
|
|
2019
|
+
workflow_dispatch:
|
|
2020
|
+
|
|
2021
|
+
jobs:
|
|
2022
|
+
post-merge:
|
|
2023
|
+
name: Post-Merge Archivist
|
|
2024
|
+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
2025
|
+
runs-on: ubuntu-latest
|
|
2026
|
+
permissions:
|
|
2027
|
+
contents: read
|
|
2028
|
+
issues: write
|
|
2029
|
+
pull-requests: write
|
|
2030
|
+
steps:
|
|
2031
|
+
- uses: actions/checkout@v4
|
|
2032
|
+
with:
|
|
2033
|
+
fetch-depth: 0
|
|
2034
|
+
- uses: actions/setup-node@v4
|
|
2035
|
+
with:
|
|
2036
|
+
node-version: 20
|
|
2037
|
+
- name: Run vinaya archive
|
|
2038
|
+
env:
|
|
2039
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
2040
|
+
run: npx --yes @attalabs/vinaya archive --merge-sha=\${{ github.sha }}
|
|
2041
|
+
|
|
2042
|
+
daily-drift:
|
|
2043
|
+
name: Daily Drift Check (dead-branch pushes)
|
|
2044
|
+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
|
1067
2045
|
runs-on: ubuntu-latest
|
|
1068
2046
|
permissions:
|
|
1069
2047
|
contents: read
|
|
1070
|
-
|
|
1071
|
-
|
|
2048
|
+
issues: write
|
|
2049
|
+
pull-requests: write
|
|
1072
2050
|
steps:
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
-
|
|
1077
|
-
|
|
2051
|
+
- uses: actions/checkout@v4
|
|
2052
|
+
with:
|
|
2053
|
+
fetch-depth: 0
|
|
2054
|
+
- uses: actions/setup-node@v4
|
|
2055
|
+
with:
|
|
2056
|
+
node-version: 20
|
|
2057
|
+
- name: Run vinaya audit --only=dead-branches
|
|
2058
|
+
continue-on-error: true # never-red — this job is a notification channel, not a gate
|
|
1078
2059
|
env:
|
|
1079
2060
|
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
1080
|
-
run:
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
2061
|
+
run: npx --yes @attalabs/vinaya audit --only=dead-branches
|
|
2062
|
+
|
|
2063
|
+
direct-main-push-detection:
|
|
2064
|
+
name: Direct-Main-Push Detection
|
|
2065
|
+
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
|
|
2066
|
+
runs-on: ubuntu-latest
|
|
2067
|
+
permissions:
|
|
2068
|
+
contents: read
|
|
2069
|
+
issues: write
|
|
2070
|
+
pull-requests: read
|
|
2071
|
+
steps:
|
|
1088
2072
|
- uses: actions/checkout@v4
|
|
1089
2073
|
with:
|
|
1090
|
-
ref: \${{ steps.pr.outputs.sha }}
|
|
1091
2074
|
fetch-depth: 0
|
|
1092
2075
|
- uses: actions/setup-node@v4
|
|
1093
2076
|
with:
|
|
1094
2077
|
node-version: 20
|
|
1095
|
-
- name:
|
|
2078
|
+
- name: Run vinaya audit --only=direct-push
|
|
1096
2079
|
env:
|
|
1097
2080
|
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
1098
|
-
run: npx --yes vinaya
|
|
2081
|
+
run: npx --yes @attalabs/vinaya audit --only=direct-push --sha=\${{ github.sha }}
|
|
1099
2082
|
`;
|
|
1100
2083
|
}
|
|
1101
2084
|
var HOOK_PREAMBLE = `#!/usr/bin/env sh
|
|
@@ -1103,20 +2086,22 @@ var HOOK_PREAMBLE = `#!/usr/bin/env sh
|
|
|
1103
2086
|
function preCommitBody() {
|
|
1104
2087
|
return `# Vinaya commit-time gate. Runs the deterministic checks over your staged
|
|
1105
2088
|
# diff before the commit lands.
|
|
1106
|
-
npx --no-install vinaya check --all --diff-only || exit 1`;
|
|
2089
|
+
npx --no-install @attalabs/vinaya check --all --diff-only || exit 1`;
|
|
1107
2090
|
}
|
|
1108
2091
|
function prePushBody() {
|
|
1109
2092
|
return `# Vinaya pre-push gate. Runs branch/dispatch checks before the push leaves.
|
|
1110
|
-
npx --no-install vinaya check --all || exit 1`;
|
|
2093
|
+
npx --no-install @attalabs/vinaya check --all || exit 1`;
|
|
1111
2094
|
}
|
|
1112
2095
|
function doctrinePointer() {
|
|
1113
2096
|
return `<!-- ${MANAGED_NOTE} -->
|
|
1114
2097
|
# Vinaya doctrine — read this first
|
|
1115
2098
|
|
|
1116
2099
|
This repo is governed by Vinaya. The full, canonical doctrine (roles,
|
|
1117
|
-
contracts, the state machine, the ring gates)
|
|
1118
|
-
\`
|
|
1119
|
-
\`vinaya
|
|
2100
|
+
contracts, the state machine, the ring gates) currently lives in the
|
|
2101
|
+
\`attalabs\` monorepo's \`aeg-root/\` (public GitHub source) — bundling it into
|
|
2102
|
+
the installed \`vinaya\` npm package, so it ships and updates cleanly via
|
|
2103
|
+
\`vinaya upgrade\` with no in-repo copy to drift, is planned but not yet
|
|
2104
|
+
shipped.
|
|
1120
2105
|
|
|
1121
2106
|
An agent working in this repo follows the governed flow by reading two things:
|
|
1122
2107
|
|
|
@@ -1128,8 +2113,59 @@ An agent working in this repo follows the governed flow by reading two things:
|
|
|
1128
2113
|
Live task status is derived from the forge (Issues, labels, comments) via
|
|
1129
2114
|
\`vinaya check\` — it is never written into a file here.
|
|
1130
2115
|
|
|
1131
|
-
To view the doctrine text:
|
|
1132
|
-
|
|
2116
|
+
To view the doctrine text today: read \`aeg-root/\` in the \`attalabs\`
|
|
2117
|
+
monorepo; \`vinaya doctor\` reports what is installed in this repo. Once
|
|
2118
|
+
in-package bundling ships, the package's own reference content becomes the
|
|
2119
|
+
source of truth.
|
|
2120
|
+
`;
|
|
2121
|
+
}
|
|
2122
|
+
function starterDocOwners() {
|
|
2123
|
+
return `# ${MANAGED_NOTE}
|
|
2124
|
+
#
|
|
2125
|
+
# ${DOC_OWNERS_PATH} — code → doc bindings for the coherence seam.
|
|
2126
|
+
#
|
|
2127
|
+
# Format: one binding per line, CODEOWNERS-shaped.
|
|
2128
|
+
# <code-glob> <doc-pointer>
|
|
2129
|
+
# Whitespace-separated. Lines starting with \`#\` and blank lines are ignored.
|
|
2130
|
+
#
|
|
2131
|
+
# Glob syntax (kept deliberately simple — no character classes):
|
|
2132
|
+
# \`**\` matches any sequence (including \`/\`)
|
|
2133
|
+
# \`*\` matches any sequence not containing \`/\`
|
|
2134
|
+
# every other character is literal — so dynamic-route segments like
|
|
2135
|
+
# \`[username]\` match literally, no escaping required.
|
|
2136
|
+
#
|
|
2137
|
+
# Pointer forms:
|
|
2138
|
+
# in-repo path e.g. \`docs/my-feature.md\`
|
|
2139
|
+
# in-repo path with anchor e.g. \`docs/my-feature.md#section-2\`
|
|
2140
|
+
# URL e.g. \`https://example.com/docs/x\`
|
|
2141
|
+
#
|
|
2142
|
+
# Coverage rule (enforced by C5, the doc-owners check bundled into \`vinaya check\`):
|
|
2143
|
+
# When a code file in the PR matches a glob:
|
|
2144
|
+
# - in-repo pointer → that path must appear in the PR diff, else FAIL
|
|
2145
|
+
# - URL pointer → require a \`Doc-ack: <pointer> — <note>\` PR-body line
|
|
2146
|
+
# - dangling pointer → in-repo pointer that does not exist on disk: FAIL
|
|
2147
|
+
# Escape:
|
|
2148
|
+
# A PR-wide \`vinaya/waiver:docs\` label whose labeling timeline event's
|
|
2149
|
+
# actor is a configured principal suppresses every fired binding for that
|
|
2150
|
+
# PR — a forge-authenticated human act, never a parseable string. There is
|
|
2151
|
+
# no PR-body waiver field; label presence alone is never sufficient.
|
|
2152
|
+
#
|
|
2153
|
+
# Dormancy:
|
|
2154
|
+
# This file absent, OR no glob matches any changed code file → silent no-op.
|
|
2155
|
+
# The gate has no opinion until you teach it one.
|
|
2156
|
+
#
|
|
2157
|
+
# ── No-doc allow-list ───────────────────────────────────────────────────────
|
|
2158
|
+
# Surfaces that legitimately need no bound doc are listed below. Format:
|
|
2159
|
+
# # no-doc: <glob> — <reason>
|
|
2160
|
+
# These lines exempt the matching directory from the completeness scoreboard
|
|
2161
|
+
# (\`vinaya check\`'s advisory output). They do NOT affect C5 enforcement — if a
|
|
2162
|
+
# binding exists for a surface, C5 still fires on changed files regardless of
|
|
2163
|
+
# any no-doc line. Use no-doc for scaffold-only, config-only, or stub-only
|
|
2164
|
+
# surfaces where adding a doc-owners binding would be meaningless busywork.
|
|
2165
|
+
# To un-exempt a surface, remove its no-doc line and add a real binding.
|
|
2166
|
+
#
|
|
2167
|
+
# This starter ships empty — no bindings, no no-doc entries. Add your own
|
|
2168
|
+
# below this line as your repo grows real code → doc coverage needs.
|
|
1133
2169
|
`;
|
|
1134
2170
|
}
|
|
1135
2171
|
function labelOps() {
|
|
@@ -1162,6 +2198,7 @@ function buildInitOps(ctx) {
|
|
|
1162
2198
|
const hookMode = 493;
|
|
1163
2199
|
ops.push({ kind: "create-file", path: CHECKS_WORKFLOW_PATH, content: checksWorkflow(), group: "CI workflows" });
|
|
1164
2200
|
ops.push({ kind: "create-file", path: REVIEW_WORKFLOW_PATH, content: reviewWorkflow(), group: "CI workflows" });
|
|
2201
|
+
ops.push({ kind: "create-file", path: ARCHIVIST_WORKFLOW_PATH, content: archivistWorkflow(), group: "CI workflows" });
|
|
1165
2202
|
ops.push({
|
|
1166
2203
|
kind: "managed-block",
|
|
1167
2204
|
path: `${ctx.hookDir}/pre-commit`,
|
|
@@ -1195,6 +2232,12 @@ function buildInitOps(ctx) {
|
|
|
1195
2232
|
content: doctrinePointer(),
|
|
1196
2233
|
group: "Doctrine pointer"
|
|
1197
2234
|
});
|
|
2235
|
+
ops.push({
|
|
2236
|
+
kind: "create-file",
|
|
2237
|
+
path: DOC_OWNERS_PATH,
|
|
2238
|
+
content: starterDocOwners(),
|
|
2239
|
+
group: "Doc-ownership manifest"
|
|
2240
|
+
});
|
|
1198
2241
|
ops.push(...labelOps());
|
|
1199
2242
|
ops.push({ kind: "print", message: BRANCH_PROTECTION_NOTE, group: "Branch protection (printed, never applied)" });
|
|
1200
2243
|
return ops;
|
|
@@ -1212,107 +2255,9 @@ function buildInitProductOps(name) {
|
|
|
1212
2255
|
];
|
|
1213
2256
|
}
|
|
1214
2257
|
|
|
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
2258
|
// src/lib/ops.ts
|
|
1314
|
-
import { chmodSync, existsSync as
|
|
1315
|
-
import { dirname as dirname3, join as
|
|
2259
|
+
import { chmodSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
2260
|
+
import { dirname as dirname3, join as join6, resolve, sep } from "node:path";
|
|
1316
2261
|
var MARKER_NS = "vinaya:managed";
|
|
1317
2262
|
function markerLines(marker, comment) {
|
|
1318
2263
|
const [open, close] = comment === "hash" ? ["#", ""] : ["<!--", " -->"];
|
|
@@ -1328,7 +2273,7 @@ ${op.body}
|
|
|
1328
2273
|
${end}`;
|
|
1329
2274
|
}
|
|
1330
2275
|
function abs(repoRoot, relPath) {
|
|
1331
|
-
return
|
|
2276
|
+
return join6(repoRoot, relPath);
|
|
1332
2277
|
}
|
|
1333
2278
|
function containedAbs(repoRoot, relPath) {
|
|
1334
2279
|
const root = resolve(repoRoot);
|
|
@@ -1339,9 +2284,9 @@ function containedAbs(repoRoot, relPath) {
|
|
|
1339
2284
|
}
|
|
1340
2285
|
function fileContains(repoRoot, relPath, needle) {
|
|
1341
2286
|
const p = abs(repoRoot, relPath);
|
|
1342
|
-
if (!
|
|
2287
|
+
if (!existsSync5(p))
|
|
1343
2288
|
return false;
|
|
1344
|
-
return
|
|
2289
|
+
return readFileSync3(p, "utf-8").includes(needle);
|
|
1345
2290
|
}
|
|
1346
2291
|
function planInstall(ops, repoRoot, ownedFiles = new Set) {
|
|
1347
2292
|
const entries = [];
|
|
@@ -1349,7 +2294,7 @@ function planInstall(ops, repoRoot, ownedFiles = new Set) {
|
|
|
1349
2294
|
for (const op of ops) {
|
|
1350
2295
|
switch (op.kind) {
|
|
1351
2296
|
case "create-file": {
|
|
1352
|
-
const exists =
|
|
2297
|
+
const exists = existsSync5(abs(repoRoot, op.path));
|
|
1353
2298
|
let action;
|
|
1354
2299
|
if (!exists)
|
|
1355
2300
|
action = "create";
|
|
@@ -1367,7 +2312,7 @@ function planInstall(ops, repoRoot, ownedFiles = new Set) {
|
|
|
1367
2312
|
let action;
|
|
1368
2313
|
if (fileContains(repoRoot, op.path, begin))
|
|
1369
2314
|
action = "skip-present";
|
|
1370
|
-
else if (
|
|
2315
|
+
else if (existsSync5(abs(repoRoot, op.path)))
|
|
1371
2316
|
action = "append";
|
|
1372
2317
|
else
|
|
1373
2318
|
action = "create-host";
|
|
@@ -1445,19 +2390,19 @@ function renderInstallDiff(plan) {
|
|
|
1445
2390
|
}
|
|
1446
2391
|
function writeFileWithDirs(target, content, mode) {
|
|
1447
2392
|
mkdirSync2(dirname3(target), { recursive: true });
|
|
1448
|
-
|
|
2393
|
+
writeFileSync3(target, content, "utf-8");
|
|
1449
2394
|
if (mode !== undefined)
|
|
1450
2395
|
chmodSync(target, mode);
|
|
1451
2396
|
}
|
|
1452
2397
|
function appendBlock(repoRoot, op) {
|
|
1453
2398
|
const target = abs(repoRoot, op.path);
|
|
1454
|
-
const existing =
|
|
2399
|
+
const existing = readFileSync3(target, "utf-8");
|
|
1455
2400
|
const sep2 = existing.endsWith(`
|
|
1456
2401
|
`) ? `
|
|
1457
2402
|
` : `
|
|
1458
2403
|
|
|
1459
2404
|
`;
|
|
1460
|
-
|
|
2405
|
+
writeFileSync3(target, `${existing}${sep2}${renderBlock(op)}
|
|
1461
2406
|
`, "utf-8");
|
|
1462
2407
|
if (op.mode !== undefined)
|
|
1463
2408
|
chmodSync(target, op.mode);
|
|
@@ -1504,9 +2449,14 @@ async function applyInstall(plan, repoRoot, labels, onFilesRecorded) {
|
|
|
1504
2449
|
for (const e of plan.entries) {
|
|
1505
2450
|
if (e.kind !== "create-label")
|
|
1506
2451
|
continue;
|
|
1507
|
-
|
|
1508
|
-
await labels.
|
|
1509
|
-
|
|
2452
|
+
try {
|
|
2453
|
+
if (!await labels.exists(e.op.name)) {
|
|
2454
|
+
await labels.create(e.op.name, e.op.color, e.op.description);
|
|
2455
|
+
createdLabels.push(e.op.name);
|
|
2456
|
+
}
|
|
2457
|
+
} catch (err) {
|
|
2458
|
+
const reason = err.stderr?.trim() || err.message;
|
|
2459
|
+
console.warn(`Warning: could not create label '${e.op.name}': ${reason}`);
|
|
1510
2460
|
}
|
|
1511
2461
|
}
|
|
1512
2462
|
return { ...base, labels: dedupe(createdLabels) };
|
|
@@ -1557,7 +2507,7 @@ function planEject(manifest, repoRoot) {
|
|
|
1557
2507
|
escapes.push(b.path);
|
|
1558
2508
|
continue;
|
|
1559
2509
|
}
|
|
1560
|
-
if (!
|
|
2510
|
+
if (!existsSync5(p)) {
|
|
1561
2511
|
actions.push({
|
|
1562
2512
|
kind: "strip-block",
|
|
1563
2513
|
path: b.path,
|
|
@@ -1568,7 +2518,7 @@ function planEject(manifest, repoRoot) {
|
|
|
1568
2518
|
});
|
|
1569
2519
|
continue;
|
|
1570
2520
|
}
|
|
1571
|
-
const content =
|
|
2521
|
+
const content = readFileSync3(p, "utf-8");
|
|
1572
2522
|
const stripped = stripBlockFromContent(content, b.marker, b.comment);
|
|
1573
2523
|
const removesHost = stripped !== null && blockStripLeavesEmpty(stripped);
|
|
1574
2524
|
actions.push({
|
|
@@ -1586,7 +2536,7 @@ function planEject(manifest, repoRoot) {
|
|
|
1586
2536
|
escapes.push(f);
|
|
1587
2537
|
continue;
|
|
1588
2538
|
}
|
|
1589
|
-
actions.push({ kind: "delete-file", path: f, present:
|
|
2539
|
+
actions.push({ kind: "delete-file", path: f, present: existsSync5(p) });
|
|
1590
2540
|
}
|
|
1591
2541
|
for (const name of manifest.labels) {
|
|
1592
2542
|
actions.push({ kind: "report-label", name });
|
|
@@ -1620,20 +2570,20 @@ function applyEject(plan, repoRoot) {
|
|
|
1620
2570
|
const p = containedAbs(repoRoot, a.path);
|
|
1621
2571
|
if (p === null)
|
|
1622
2572
|
continue;
|
|
1623
|
-
const content =
|
|
2573
|
+
const content = readFileSync3(p, "utf-8");
|
|
1624
2574
|
const stripped = stripBlockFromContent(content, a.marker, a.comment);
|
|
1625
2575
|
if (stripped === null)
|
|
1626
2576
|
continue;
|
|
1627
2577
|
if (blockStripLeavesEmpty(stripped))
|
|
1628
|
-
|
|
2578
|
+
rmSync2(p, { force: true });
|
|
1629
2579
|
else
|
|
1630
|
-
|
|
2580
|
+
writeFileSync3(p, stripped.endsWith(`
|
|
1631
2581
|
`) ? stripped : `${stripped}
|
|
1632
2582
|
`, "utf-8");
|
|
1633
2583
|
} else if (a.kind === "delete-file") {
|
|
1634
2584
|
const p = containedAbs(repoRoot, a.path);
|
|
1635
2585
|
if (a.present && p !== null)
|
|
1636
|
-
|
|
2586
|
+
rmSync2(p, { force: true });
|
|
1637
2587
|
}
|
|
1638
2588
|
}
|
|
1639
2589
|
return {
|
|
@@ -1642,21 +2592,11 @@ function applyEject(plan, repoRoot) {
|
|
|
1642
2592
|
}
|
|
1643
2593
|
|
|
1644
2594
|
// 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
2595
|
function readVersion() {
|
|
1656
|
-
const pkg = JSON.parse(
|
|
2596
|
+
const pkg = JSON.parse(readFileSync4(join7(packageRoot(import.meta.url), "package.json"), "utf-8"));
|
|
1657
2597
|
return pkg.version;
|
|
1658
2598
|
}
|
|
1659
|
-
function
|
|
2599
|
+
function realDeps3() {
|
|
1660
2600
|
return {
|
|
1661
2601
|
detectRepo: detectGitRepo,
|
|
1662
2602
|
ghAuthStatus,
|
|
@@ -1672,12 +2612,12 @@ var info = (check, message) => ({ check, severity: "info", message });
|
|
|
1672
2612
|
var warn = (check, message) => ({ check, severity: "warn", message });
|
|
1673
2613
|
var error = (check, message) => ({ check, severity: "error", message });
|
|
1674
2614
|
function readConfig(repoRoot) {
|
|
1675
|
-
const p =
|
|
1676
|
-
if (!
|
|
2615
|
+
const p = join7(repoRoot, CONFIG_PATH);
|
|
2616
|
+
if (!existsSync6(p))
|
|
1677
2617
|
return { kind: "missing" };
|
|
1678
2618
|
let raw;
|
|
1679
2619
|
try {
|
|
1680
|
-
raw = JSON.parse(
|
|
2620
|
+
raw = JSON.parse(readFileSync4(p, "utf-8"));
|
|
1681
2621
|
} catch (err) {
|
|
1682
2622
|
return { kind: "invalid", error: `invalid JSON: ${err.message}` };
|
|
1683
2623
|
}
|
|
@@ -1706,14 +2646,14 @@ function diagnoseInstall(repoRoot, ctx, manifest) {
|
|
|
1706
2646
|
for (const op of buildInitOps(ctx)) {
|
|
1707
2647
|
if (op.kind === "create-file") {
|
|
1708
2648
|
const check = labelForPath(op.path);
|
|
1709
|
-
const abs2 =
|
|
1710
|
-
const exists =
|
|
2649
|
+
const abs2 = join7(repoRoot, op.path);
|
|
2650
|
+
const exists = existsSync6(abs2);
|
|
1711
2651
|
const owned = ownedFiles.has(op.path);
|
|
1712
2652
|
if (!exists) {
|
|
1713
2653
|
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
2654
|
continue;
|
|
1715
2655
|
}
|
|
1716
|
-
const content =
|
|
2656
|
+
const content = readFileSync4(abs2, "utf-8");
|
|
1717
2657
|
if (!owned) {
|
|
1718
2658
|
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
2659
|
continue;
|
|
@@ -1730,13 +2670,13 @@ function diagnoseInstall(repoRoot, ctx, manifest) {
|
|
|
1730
2670
|
}
|
|
1731
2671
|
} else if (op.kind === "managed-block") {
|
|
1732
2672
|
const check = "hooks";
|
|
1733
|
-
const abs2 =
|
|
2673
|
+
const abs2 = join7(repoRoot, op.path);
|
|
1734
2674
|
const owned = ownedBlocks.has(blockKey(op.path, op.marker));
|
|
1735
|
-
if (!
|
|
2675
|
+
if (!existsSync6(abs2)) {
|
|
1736
2676
|
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
2677
|
continue;
|
|
1738
2678
|
}
|
|
1739
|
-
const content =
|
|
2679
|
+
const content = readFileSync4(abs2, "utf-8");
|
|
1740
2680
|
const { begin, end } = markerLines(op.marker, op.comment);
|
|
1741
2681
|
const hasMarkers = content.includes(begin) && content.includes(end);
|
|
1742
2682
|
if (!hasMarkers) {
|
|
@@ -1761,8 +2701,8 @@ function diagnoseInstall(repoRoot, ctx, manifest) {
|
|
|
1761
2701
|
function diagnoseCustomChecks(repoRoot, config) {
|
|
1762
2702
|
const findings = [];
|
|
1763
2703
|
for (const [name, entry2] of Object.entries(config.checks ?? {})) {
|
|
1764
|
-
const scriptAbs =
|
|
1765
|
-
findings.push(
|
|
2704
|
+
const scriptAbs = join7(repoRoot, entry2.run);
|
|
2705
|
+
findings.push(existsSync6(scriptAbs) ? ok("checks", `custom check '${name}' → ${entry2.run}`) : error("checks", `custom check '${name}' points at a missing script: ${entry2.run}`));
|
|
1766
2706
|
}
|
|
1767
2707
|
return findings;
|
|
1768
2708
|
}
|
|
@@ -1846,12 +2786,12 @@ async function runDoctor(args, deps) {
|
|
|
1846
2786
|
return healthy ? 0 : 1;
|
|
1847
2787
|
}
|
|
1848
2788
|
async function doctorCommand(args) {
|
|
1849
|
-
process.exit(await runDoctor(args,
|
|
2789
|
+
process.exit(await runDoctor(args, realDeps3()));
|
|
1850
2790
|
}
|
|
1851
2791
|
|
|
1852
2792
|
// src/commands/eject.ts
|
|
1853
|
-
import { existsSync as
|
|
1854
|
-
import { join as
|
|
2793
|
+
import { existsSync as existsSync7, readFileSync as readFileSync5 } from "node:fs";
|
|
2794
|
+
import { join as join8 } from "node:path";
|
|
1855
2795
|
|
|
1856
2796
|
// src/lib/prompt.ts
|
|
1857
2797
|
var stdinBuffer = "";
|
|
@@ -1918,7 +2858,7 @@ function closeStdin() {
|
|
|
1918
2858
|
}
|
|
1919
2859
|
|
|
1920
2860
|
// src/commands/eject.ts
|
|
1921
|
-
function
|
|
2861
|
+
function realDeps4() {
|
|
1922
2862
|
return {
|
|
1923
2863
|
detectRepo: detectGitRepo,
|
|
1924
2864
|
confirm: async (q) => {
|
|
@@ -1932,12 +2872,12 @@ function parse(args) {
|
|
|
1932
2872
|
return { dryRun: args.includes("--dry-run"), yes: args.includes("--yes") };
|
|
1933
2873
|
}
|
|
1934
2874
|
function readManifest(repoRoot) {
|
|
1935
|
-
const p =
|
|
1936
|
-
if (!
|
|
2875
|
+
const p = join8(repoRoot, CONFIG_PATH);
|
|
2876
|
+
if (!existsSync7(p))
|
|
1937
2877
|
return { kind: "none" };
|
|
1938
2878
|
let raw;
|
|
1939
2879
|
try {
|
|
1940
|
-
raw = JSON.parse(
|
|
2880
|
+
raw = JSON.parse(readFileSync5(p, "utf-8"));
|
|
1941
2881
|
} catch (err) {
|
|
1942
2882
|
return { kind: "orphan", reason: `vinaya.config.json is not valid JSON: ${err.message}` };
|
|
1943
2883
|
}
|
|
@@ -2015,14 +2955,14 @@ Vinaya ejected.
|
|
|
2015
2955
|
return 0;
|
|
2016
2956
|
}
|
|
2017
2957
|
async function ejectCommand(args) {
|
|
2018
|
-
const code = await runEject(args,
|
|
2958
|
+
const code = await runEject(args, realDeps4());
|
|
2019
2959
|
process.exit(code);
|
|
2020
2960
|
}
|
|
2021
2961
|
|
|
2022
2962
|
// src/commands/init.ts
|
|
2023
|
-
import { existsSync as
|
|
2024
|
-
import { join as
|
|
2025
|
-
function
|
|
2963
|
+
import { existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
|
|
2964
|
+
import { join as join9 } from "node:path";
|
|
2965
|
+
function realDeps5() {
|
|
2026
2966
|
return {
|
|
2027
2967
|
detectRepo: detectGitRepo,
|
|
2028
2968
|
checkGhAuth,
|
|
@@ -2041,19 +2981,19 @@ function flags(args) {
|
|
|
2041
2981
|
}
|
|
2042
2982
|
var PRODUCT_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
2043
2983
|
function readManifest2(repoRoot) {
|
|
2044
|
-
const p =
|
|
2045
|
-
if (!
|
|
2984
|
+
const p = join9(repoRoot, CONFIG_PATH);
|
|
2985
|
+
if (!existsSync8(p))
|
|
2046
2986
|
return null;
|
|
2047
2987
|
try {
|
|
2048
|
-
return VinayaConfigSchema.parse(JSON.parse(
|
|
2988
|
+
return VinayaConfigSchema.parse(JSON.parse(readFileSync6(p, "utf-8"))).managed ?? null;
|
|
2049
2989
|
} catch {
|
|
2050
2990
|
return null;
|
|
2051
2991
|
}
|
|
2052
2992
|
}
|
|
2053
2993
|
function writeManifest(repoRoot, manifest) {
|
|
2054
|
-
const configAbs =
|
|
2055
|
-
const seed = JSON.parse(
|
|
2056
|
-
|
|
2994
|
+
const configAbs = join9(repoRoot, CONFIG_PATH);
|
|
2995
|
+
const seed = JSON.parse(readFileSync6(configAbs, "utf-8"));
|
|
2996
|
+
writeFileSync4(configAbs, `${JSON.stringify({ ...seed, managed: manifest }, null, 2)}
|
|
2057
2997
|
`, "utf-8");
|
|
2058
2998
|
}
|
|
2059
2999
|
async function runInit(args, deps) {
|
|
@@ -2181,20 +3121,20 @@ function dedupeBlocks2(bs) {
|
|
|
2181
3121
|
});
|
|
2182
3122
|
}
|
|
2183
3123
|
async function initCommand(args) {
|
|
2184
|
-
process.exit(await runInit(args,
|
|
3124
|
+
process.exit(await runInit(args, realDeps5()));
|
|
2185
3125
|
}
|
|
2186
3126
|
async function initProductCommand(args) {
|
|
2187
|
-
process.exit(await runInitProduct(args,
|
|
3127
|
+
process.exit(await runInitProduct(args, realDeps5()));
|
|
2188
3128
|
}
|
|
2189
3129
|
|
|
2190
3130
|
// src/commands/issue.ts
|
|
2191
|
-
import { execFileSync as
|
|
3131
|
+
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
2192
3132
|
|
|
2193
3133
|
// src/lib/forge-write.ts
|
|
2194
|
-
import { mkdtempSync, rmSync as
|
|
2195
|
-
import { readFileSync as
|
|
3134
|
+
import { mkdtempSync, rmSync as rmSync3, writeFileSync as writeFileSync5 } from "node:fs";
|
|
3135
|
+
import { readFileSync as readFileSync7 } from "node:fs";
|
|
2196
3136
|
import { tmpdir } from "node:os";
|
|
2197
|
-
import { join as
|
|
3137
|
+
import { join as join10 } from "node:path";
|
|
2198
3138
|
class ForgeArgError extends Error {
|
|
2199
3139
|
}
|
|
2200
3140
|
function locateBody(args) {
|
|
@@ -2204,13 +3144,13 @@ function locateBody(args) {
|
|
|
2204
3144
|
const p = args[i + 1];
|
|
2205
3145
|
if (!p)
|
|
2206
3146
|
throw new ForgeArgError("`--body-file` was given with no path.");
|
|
2207
|
-
return { body:
|
|
3147
|
+
return { body: readFileSync7(p, "utf8"), source: { kind: "file", argIndex: i + 1, inlineForm: false } };
|
|
2208
3148
|
}
|
|
2209
3149
|
if (a.startsWith("--body-file=")) {
|
|
2210
3150
|
const p = a.slice("--body-file=".length);
|
|
2211
3151
|
if (!p)
|
|
2212
3152
|
throw new ForgeArgError("`--body-file=` was given with no path.");
|
|
2213
|
-
return { body:
|
|
3153
|
+
return { body: readFileSync7(p, "utf8"), source: { kind: "file", argIndex: i, inlineForm: true } };
|
|
2214
3154
|
}
|
|
2215
3155
|
if (a === "--body" || a === "-b") {
|
|
2216
3156
|
const v = args[i + 1];
|
|
@@ -2227,13 +3167,13 @@ function resolveShippableArgs(args, bodyResult) {
|
|
|
2227
3167
|
if (bodyResult?.source.kind !== "file") {
|
|
2228
3168
|
return { finalArgs: args, cleanup: () => {} };
|
|
2229
3169
|
}
|
|
2230
|
-
const dir = mkdtempSync(
|
|
2231
|
-
const tempPath =
|
|
2232
|
-
|
|
3170
|
+
const dir = mkdtempSync(join10(tmpdir(), "vinaya-forge-body-"));
|
|
3171
|
+
const tempPath = join10(dir, "body.md");
|
|
3172
|
+
writeFileSync5(tempPath, bodyResult.body, "utf8");
|
|
2233
3173
|
const finalArgs = [...args];
|
|
2234
3174
|
const { argIndex, inlineForm } = bodyResult.source;
|
|
2235
3175
|
finalArgs[argIndex] = inlineForm ? `--body-file=${tempPath}` : tempPath;
|
|
2236
|
-
return { finalArgs, cleanup: () =>
|
|
3176
|
+
return { finalArgs, cleanup: () => rmSync3(dir, { recursive: true, force: true }) };
|
|
2237
3177
|
}
|
|
2238
3178
|
function extractTitle(args) {
|
|
2239
3179
|
for (let i = 0;i < args.length; i++) {
|
|
@@ -2401,7 +3341,7 @@ function locateBodyOrRefuse(ghArgs, retryCommand) {
|
|
|
2401
3341
|
function fetchForgeLabels(issueRef) {
|
|
2402
3342
|
let out;
|
|
2403
3343
|
try {
|
|
2404
|
-
out =
|
|
3344
|
+
out = execFileSync6("gh", ["issue", "view", issueRef, "--json", "labels"], {
|
|
2405
3345
|
encoding: "utf8",
|
|
2406
3346
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2407
3347
|
});
|
|
@@ -2427,9 +3367,9 @@ function reportPass(json, command) {
|
|
|
2427
3367
|
}
|
|
2428
3368
|
}
|
|
2429
3369
|
function runGhWrite(ghCmd, ghArgs, bodyResult, json) {
|
|
2430
|
-
const { finalArgs, cleanup } = resolveShippableArgs(ghArgs, bodyResult);
|
|
3370
|
+
const { finalArgs, cleanup: cleanup2 } = resolveShippableArgs(ghArgs, bodyResult);
|
|
2431
3371
|
try {
|
|
2432
|
-
const out =
|
|
3372
|
+
const out = execFileSync6("gh", [...ghCmd, ...finalArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
|
|
2433
3373
|
const url = out.trim();
|
|
2434
3374
|
if (json)
|
|
2435
3375
|
printJson({ validated: true, written: true, url });
|
|
@@ -2437,7 +3377,7 @@ function runGhWrite(ghCmd, ghArgs, bodyResult, json) {
|
|
|
2437
3377
|
process.stdout.write(`${url}
|
|
2438
3378
|
`);
|
|
2439
3379
|
} finally {
|
|
2440
|
-
|
|
3380
|
+
cleanup2();
|
|
2441
3381
|
}
|
|
2442
3382
|
}
|
|
2443
3383
|
function validateTaskIssue(body, title, retryCommand) {
|
|
@@ -2500,21 +3440,10 @@ function issueEditCommand(args) {
|
|
|
2500
3440
|
}
|
|
2501
3441
|
|
|
2502
3442
|
// 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");
|
|
3443
|
+
import { chmodSync as chmodSync2, existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "node:fs";
|
|
3444
|
+
import { join as join11 } from "node:path";
|
|
3445
|
+
var TEMPLATE_PATH = join11(packageRoot(import.meta.url), "templates", "custom-check.template.ts");
|
|
3446
|
+
var CHECKS_DIR = join11("scripts", "vinaya-checks");
|
|
2518
3447
|
var VALID_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
2519
3448
|
function newCheckCommand(args) {
|
|
2520
3449
|
const name = args[0];
|
|
@@ -2522,19 +3451,19 @@ function newCheckCommand(args) {
|
|
|
2522
3451
|
console.error("Usage: vinaya new check <name> (name: lowercase letters, digits, hyphens)");
|
|
2523
3452
|
process.exit(2);
|
|
2524
3453
|
}
|
|
2525
|
-
const scriptsDir =
|
|
2526
|
-
if (!
|
|
3454
|
+
const scriptsDir = join11(process.cwd(), CHECKS_DIR);
|
|
3455
|
+
if (!existsSync9(scriptsDir))
|
|
2527
3456
|
mkdirSync3(scriptsDir, { recursive: true });
|
|
2528
|
-
const targetPath =
|
|
2529
|
-
if (
|
|
3457
|
+
const targetPath = join11(scriptsDir, `${name}.ts`);
|
|
3458
|
+
if (existsSync9(targetPath)) {
|
|
2530
3459
|
console.error(`Error: ${targetPath} already exists.`);
|
|
2531
3460
|
process.exit(1);
|
|
2532
3461
|
}
|
|
2533
|
-
const template =
|
|
3462
|
+
const template = readFileSync8(TEMPLATE_PATH, "utf-8");
|
|
2534
3463
|
const contents = template.split("{{CHECK_NAME}}").join(name);
|
|
2535
|
-
|
|
3464
|
+
writeFileSync6(targetPath, contents, "utf-8");
|
|
2536
3465
|
chmodSync2(targetPath, 493);
|
|
2537
|
-
const relPath =
|
|
3466
|
+
const relPath = join11(CHECKS_DIR, `${name}.ts`);
|
|
2538
3467
|
const registration = JSON.stringify({ checks: { [name]: { run: `./${relPath}`, scope: "diff" } } }, null, 2);
|
|
2539
3468
|
process.stdout.write(`Created ${relPath}
|
|
2540
3469
|
|
|
@@ -2544,12 +3473,12 @@ ${registration}
|
|
|
2544
3473
|
}
|
|
2545
3474
|
|
|
2546
3475
|
// src/commands/pr.ts
|
|
2547
|
-
import { execFileSync as
|
|
3476
|
+
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
2548
3477
|
var RETRY_CREATE2 = "vinaya pr create --validate-only …";
|
|
2549
3478
|
var RETRY_EDIT2 = "vinaya pr edit <n> --validate-only …";
|
|
2550
3479
|
function git2(args) {
|
|
2551
3480
|
try {
|
|
2552
|
-
return
|
|
3481
|
+
return execFileSync7("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
2553
3482
|
} catch {
|
|
2554
3483
|
return "";
|
|
2555
3484
|
}
|
|
@@ -2581,9 +3510,9 @@ function reportPass2(json, command) {
|
|
|
2581
3510
|
}
|
|
2582
3511
|
}
|
|
2583
3512
|
function runGhWrite2(ghCmd, ghArgs, bodyResult, json) {
|
|
2584
|
-
const { finalArgs, cleanup } = resolveShippableArgs(ghArgs, bodyResult);
|
|
3513
|
+
const { finalArgs, cleanup: cleanup2 } = resolveShippableArgs(ghArgs, bodyResult);
|
|
2585
3514
|
try {
|
|
2586
|
-
const out =
|
|
3515
|
+
const out = execFileSync7("gh", [...ghCmd, ...finalArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
|
|
2587
3516
|
const url = out.trim();
|
|
2588
3517
|
if (json)
|
|
2589
3518
|
printJson({ validated: true, written: true, url });
|
|
@@ -2591,13 +3520,13 @@ function runGhWrite2(ghCmd, ghArgs, bodyResult, json) {
|
|
|
2591
3520
|
process.stdout.write(`${url}
|
|
2592
3521
|
`);
|
|
2593
3522
|
} finally {
|
|
2594
|
-
|
|
3523
|
+
cleanup2();
|
|
2595
3524
|
}
|
|
2596
3525
|
}
|
|
2597
3526
|
function fetchPrForgeContext(prRef) {
|
|
2598
3527
|
let viewOut;
|
|
2599
3528
|
try {
|
|
2600
|
-
viewOut =
|
|
3529
|
+
viewOut = execFileSync7("gh", ["pr", "view", prRef, "--json", "headRefName,files"], {
|
|
2601
3530
|
encoding: "utf8",
|
|
2602
3531
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2603
3532
|
});
|
|
@@ -2694,22 +3623,22 @@ function prEditCommand(args) {
|
|
|
2694
3623
|
|
|
2695
3624
|
// src/commands/studio.ts
|
|
2696
3625
|
import { spawn as spawn2 } from "node:child_process";
|
|
2697
|
-
import { existsSync as
|
|
2698
|
-
import { dirname as
|
|
3626
|
+
import { existsSync as existsSync10, readFileSync as readFileSync9 } from "node:fs";
|
|
3627
|
+
import { dirname as dirname4, join as join12 } from "node:path";
|
|
2699
3628
|
function resolveStudioTarget(cwd) {
|
|
2700
3629
|
let dir = cwd;
|
|
2701
3630
|
for (;; ) {
|
|
2702
|
-
const webDir =
|
|
2703
|
-
const pkgPath =
|
|
2704
|
-
if (
|
|
3631
|
+
const webDir = join12(dir, "apps", "vinaya", "web");
|
|
3632
|
+
const pkgPath = join12(webDir, "package.json");
|
|
3633
|
+
if (existsSync10(pkgPath)) {
|
|
2705
3634
|
try {
|
|
2706
|
-
const pkg = JSON.parse(
|
|
3635
|
+
const pkg = JSON.parse(readFileSync9(pkgPath, "utf-8"));
|
|
2707
3636
|
if (pkg.name === "@atta/vinaya-web") {
|
|
2708
3637
|
return { kind: "workspace", webDir };
|
|
2709
3638
|
}
|
|
2710
3639
|
} catch {}
|
|
2711
3640
|
}
|
|
2712
|
-
const parent =
|
|
3641
|
+
const parent = dirname4(dir);
|
|
2713
3642
|
if (parent === dir)
|
|
2714
3643
|
break;
|
|
2715
3644
|
dir = parent;
|
|
@@ -2736,9 +3665,9 @@ async function runStudio(cwd, args) {
|
|
|
2736
3665
|
}
|
|
2737
3666
|
|
|
2738
3667
|
// src/commands/upgrade.ts
|
|
2739
|
-
import { existsSync as
|
|
2740
|
-
import { join as
|
|
2741
|
-
function
|
|
3668
|
+
import { existsSync as existsSync11, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "node:fs";
|
|
3669
|
+
import { join as join13 } from "node:path";
|
|
3670
|
+
function realDeps6() {
|
|
2742
3671
|
return {
|
|
2743
3672
|
detectRepo: detectGitRepo,
|
|
2744
3673
|
hookDirFor: resolveHookDir,
|
|
@@ -2753,12 +3682,12 @@ function flags2(args) {
|
|
|
2753
3682
|
return { dryRun: args.includes("--dry-run"), yes: args.includes("--yes") };
|
|
2754
3683
|
}
|
|
2755
3684
|
function readManifest3(repoRoot) {
|
|
2756
|
-
const p =
|
|
2757
|
-
if (!
|
|
3685
|
+
const p = join13(repoRoot, CONFIG_PATH);
|
|
3686
|
+
if (!existsSync11(p))
|
|
2758
3687
|
return { kind: "missing" };
|
|
2759
3688
|
let raw;
|
|
2760
3689
|
try {
|
|
2761
|
-
raw = JSON.parse(
|
|
3690
|
+
raw = JSON.parse(readFileSync10(p, "utf-8"));
|
|
2762
3691
|
} catch (err) {
|
|
2763
3692
|
return { kind: "invalid", error: `invalid JSON: ${err.message}` };
|
|
2764
3693
|
}
|
|
@@ -2774,10 +3703,10 @@ function readManifest3(repoRoot) {
|
|
|
2774
3703
|
return { kind: "ok", manifest: parsed.data.managed };
|
|
2775
3704
|
}
|
|
2776
3705
|
function writeManifestVersion(repoRoot, manifest) {
|
|
2777
|
-
const configAbs =
|
|
2778
|
-
const seed = JSON.parse(
|
|
3706
|
+
const configAbs = join13(repoRoot, CONFIG_PATH);
|
|
3707
|
+
const seed = JSON.parse(readFileSync10(configAbs, "utf-8"));
|
|
2779
3708
|
const updated = { ...manifest, version: MANAGED_MANIFEST_VERSION };
|
|
2780
|
-
|
|
3709
|
+
writeFileSync7(configAbs, `${JSON.stringify({ ...seed, managed: updated }, null, 2)}
|
|
2781
3710
|
`, "utf-8");
|
|
2782
3711
|
}
|
|
2783
3712
|
function planUpgrade(ops, repoRoot, manifest) {
|
|
@@ -2788,8 +3717,8 @@ function planUpgrade(ops, repoRoot, manifest) {
|
|
|
2788
3717
|
const ownedBlocks = new Set(manifest.blocks.map((b) => blockKey(b.path, b.marker)));
|
|
2789
3718
|
for (const op of ops) {
|
|
2790
3719
|
if (op.kind === "create-file") {
|
|
2791
|
-
const abs2 =
|
|
2792
|
-
const exists =
|
|
3720
|
+
const abs2 = join13(repoRoot, op.path);
|
|
3721
|
+
const exists = existsSync11(abs2);
|
|
2793
3722
|
const owned = ownedFiles.has(op.path);
|
|
2794
3723
|
let action;
|
|
2795
3724
|
if (op.path === CONFIG_PATH) {
|
|
@@ -2799,7 +3728,7 @@ function planUpgrade(ops, repoRoot, manifest) {
|
|
|
2799
3728
|
} else if (!exists) {
|
|
2800
3729
|
action = "recreate";
|
|
2801
3730
|
hasChanges = true;
|
|
2802
|
-
} else if (
|
|
3731
|
+
} else if (readFileSync10(abs2, "utf-8") !== op.content) {
|
|
2803
3732
|
action = "regenerate";
|
|
2804
3733
|
hasChanges = true;
|
|
2805
3734
|
} else {
|
|
@@ -2807,16 +3736,16 @@ function planUpgrade(ops, repoRoot, manifest) {
|
|
|
2807
3736
|
}
|
|
2808
3737
|
entries.push({ kind: "create-file", op, action });
|
|
2809
3738
|
} else if (op.kind === "managed-block") {
|
|
2810
|
-
const abs2 =
|
|
3739
|
+
const abs2 = join13(repoRoot, op.path);
|
|
2811
3740
|
const owned = ownedBlocks.has(blockKey(op.path, op.marker));
|
|
2812
3741
|
let action;
|
|
2813
3742
|
if (!owned) {
|
|
2814
3743
|
action = "not-installed";
|
|
2815
|
-
} else if (!
|
|
3744
|
+
} else if (!existsSync11(abs2)) {
|
|
2816
3745
|
action = "recreate-host";
|
|
2817
3746
|
hasChanges = true;
|
|
2818
3747
|
} else {
|
|
2819
|
-
const content =
|
|
3748
|
+
const content = readFileSync10(abs2, "utf-8");
|
|
2820
3749
|
const { begin, end } = markerLines(op.marker, op.comment);
|
|
2821
3750
|
if (!(content.includes(begin) && content.includes(end))) {
|
|
2822
3751
|
action = "recreate-append";
|
|
@@ -2892,11 +3821,11 @@ function renderUpgradeDiff(plan) {
|
|
|
2892
3821
|
`);
|
|
2893
3822
|
}
|
|
2894
3823
|
function regenerateBlock(repoRoot, op) {
|
|
2895
|
-
const abs2 =
|
|
2896
|
-
const content =
|
|
3824
|
+
const abs2 = join13(repoRoot, op.path);
|
|
3825
|
+
const content = readFileSync10(abs2, "utf-8");
|
|
2897
3826
|
const stripped = stripBlockFromContent(content, op.marker, op.comment);
|
|
2898
3827
|
if (stripped !== null) {
|
|
2899
|
-
|
|
3828
|
+
writeFileSync7(abs2, stripped.endsWith(`
|
|
2900
3829
|
`) ? stripped : `${stripped}
|
|
2901
3830
|
`, "utf-8");
|
|
2902
3831
|
}
|
|
@@ -2906,7 +3835,7 @@ function applyUpgrade(plan, repoRoot) {
|
|
|
2906
3835
|
for (const e of plan.entries) {
|
|
2907
3836
|
if (e.kind === "create-file") {
|
|
2908
3837
|
if (e.action === "regenerate" || e.action === "recreate") {
|
|
2909
|
-
writeFileWithDirs(
|
|
3838
|
+
writeFileWithDirs(join13(repoRoot, e.op.path), e.op.content, e.op.mode);
|
|
2910
3839
|
}
|
|
2911
3840
|
} else {
|
|
2912
3841
|
if (e.action === "recreate-host")
|
|
@@ -2976,7 +3905,120 @@ Vinaya upgraded.
|
|
|
2976
3905
|
return 0;
|
|
2977
3906
|
}
|
|
2978
3907
|
async function upgradeCommand(args) {
|
|
2979
|
-
process.exit(await runUpgrade(args,
|
|
3908
|
+
process.exit(await runUpgrade(args, realDeps6()));
|
|
3909
|
+
}
|
|
3910
|
+
|
|
3911
|
+
// src/commands/waiver.ts
|
|
3912
|
+
import { execFileSync as execFileSync8 } from "node:child_process";
|
|
3913
|
+
function labelFor(kind) {
|
|
3914
|
+
return kind === "docs" ? WAIVER_LABEL : WAIVER_LABEL_REVIEW;
|
|
3915
|
+
}
|
|
3916
|
+
function gh(args) {
|
|
3917
|
+
return execFileSync8("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: process.env }).trim();
|
|
3918
|
+
}
|
|
3919
|
+
function detectPrNumber() {
|
|
3920
|
+
try {
|
|
3921
|
+
const out = gh(["pr", "view", "--json", "number", "-q", ".number"]);
|
|
3922
|
+
return /^\d+$/.test(out) ? out : null;
|
|
3923
|
+
} catch {
|
|
3924
|
+
return null;
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3927
|
+
var VALUE_FLAGS = ["--reason"];
|
|
3928
|
+
var BOOLEAN_FLAGS = ["--print-only"];
|
|
3929
|
+
function positionalArgs(args) {
|
|
3930
|
+
const result = [];
|
|
3931
|
+
for (let i = 0;i < args.length; i++) {
|
|
3932
|
+
const a = args[i];
|
|
3933
|
+
if (VALUE_FLAGS.some((f) => a.startsWith(`${f}=`)))
|
|
3934
|
+
continue;
|
|
3935
|
+
if (VALUE_FLAGS.includes(a)) {
|
|
3936
|
+
i += 1;
|
|
3937
|
+
continue;
|
|
3938
|
+
}
|
|
3939
|
+
if (BOOLEAN_FLAGS.includes(a))
|
|
3940
|
+
continue;
|
|
3941
|
+
result.push(a);
|
|
3942
|
+
}
|
|
3943
|
+
return result;
|
|
3944
|
+
}
|
|
3945
|
+
async function resolveKind(args) {
|
|
3946
|
+
const positional = positionalArgs(args).find((a) => a === "docs" || a === "review");
|
|
3947
|
+
if (positional === "docs" || positional === "review")
|
|
3948
|
+
return positional;
|
|
3949
|
+
const answer = (await prompt("Which waiver — `docs` or `review`? ")).trim().toLowerCase();
|
|
3950
|
+
return answer === "docs" || answer === "review" ? answer : null;
|
|
3951
|
+
}
|
|
3952
|
+
async function resolvePrNumber(args) {
|
|
3953
|
+
const positional = positionalArgs(args).find((a) => /^\d+$/.test(a));
|
|
3954
|
+
if (positional)
|
|
3955
|
+
return positional;
|
|
3956
|
+
const detected = detectPrNumber();
|
|
3957
|
+
if (detected)
|
|
3958
|
+
return detected;
|
|
3959
|
+
const answer = (await prompt("PR number (could not auto-detect one for the current branch): ")).trim();
|
|
3960
|
+
return /^\d+$/.test(answer) ? answer : null;
|
|
3961
|
+
}
|
|
3962
|
+
function flagValue(args, flag) {
|
|
3963
|
+
const eq = args.find((a) => a.startsWith(`${flag}=`));
|
|
3964
|
+
if (eq)
|
|
3965
|
+
return eq.slice(flag.length + 1);
|
|
3966
|
+
const idx = args.indexOf(flag);
|
|
3967
|
+
if (idx !== -1 && args[idx + 1] !== undefined)
|
|
3968
|
+
return args[idx + 1];
|
|
3969
|
+
return null;
|
|
3970
|
+
}
|
|
3971
|
+
async function resolveReason(args) {
|
|
3972
|
+
const flagged = flagValue(args, "--reason");
|
|
3973
|
+
if (flagged && flagged.trim().length > 0)
|
|
3974
|
+
return flagged.trim();
|
|
3975
|
+
const answer = await prompt("Reason for this waiver (one line — posted as a PR comment): ");
|
|
3976
|
+
return answer.trim();
|
|
3977
|
+
}
|
|
3978
|
+
async function waiverCommand(args) {
|
|
3979
|
+
const printOnly = args.includes("--print-only");
|
|
3980
|
+
const kind = await resolveKind(args);
|
|
3981
|
+
if (!kind) {
|
|
3982
|
+
console.error("Usage: vinaya waiver <docs|review> [<pr-number>] [--reason <text>] [--print-only]");
|
|
3983
|
+
closeStdin();
|
|
3984
|
+
process.exit(2);
|
|
3985
|
+
return;
|
|
3986
|
+
}
|
|
3987
|
+
const prNumber = await resolvePrNumber(args);
|
|
3988
|
+
if (!prNumber) {
|
|
3989
|
+
console.error("A PR number is required — none was given and none could be auto-detected.");
|
|
3990
|
+
closeStdin();
|
|
3991
|
+
process.exit(2);
|
|
3992
|
+
return;
|
|
3993
|
+
}
|
|
3994
|
+
const reason = await resolveReason(args);
|
|
3995
|
+
closeStdin();
|
|
3996
|
+
if (!reason) {
|
|
3997
|
+
console.error("A reason is required — the waiver label alone carries no rationale for the reviewer.");
|
|
3998
|
+
process.exit(2);
|
|
3999
|
+
return;
|
|
4000
|
+
}
|
|
4001
|
+
const label2 = labelFor(kind);
|
|
4002
|
+
const editArgs = ["pr", "edit", prNumber, "--add-label", label2];
|
|
4003
|
+
const commentArgs = ["pr", "comment", prNumber, "--body", `Waiver applied: \`${label2}\`
|
|
4004
|
+
Reason: ${reason}`];
|
|
4005
|
+
if (printOnly) {
|
|
4006
|
+
process.stdout.write(`--print-only: nothing below was executed. Run these yourself, as a human, to apply the waiver:
|
|
4007
|
+
|
|
4008
|
+
`);
|
|
4009
|
+
process.stdout.write(` gh ${editArgs.join(" ")}
|
|
4010
|
+
`);
|
|
4011
|
+
process.stdout.write(` gh ${commentArgs.join(" ")}
|
|
4012
|
+
`);
|
|
4013
|
+
return;
|
|
4014
|
+
}
|
|
4015
|
+
process.stdout.write(`Applying \`${label2}\` to PR #${prNumber} under your authenticated \`gh\` identity — vinaya never fabricates an actor.
|
|
4016
|
+
`);
|
|
4017
|
+
execFileSync8("gh", editArgs, { stdio: "inherit", env: process.env });
|
|
4018
|
+
execFileSync8("gh", commentArgs, { stdio: "inherit", env: process.env });
|
|
4019
|
+
process.stdout.write(`
|
|
4020
|
+
✓ Label applied and reason posted as a PR comment.
|
|
4021
|
+
`);
|
|
2980
4022
|
}
|
|
2981
4023
|
|
|
2982
4024
|
// ../sources/src/commands.ts
|
|
@@ -3101,6 +4143,30 @@ var COMMANDS = [
|
|
|
3101
4143
|
],
|
|
3102
4144
|
status: "shipped"
|
|
3103
4145
|
},
|
|
4146
|
+
{
|
|
4147
|
+
name: "archive",
|
|
4148
|
+
description: "Run the post-merge Archivist directly: provenance + Issue close-out for a merged task PR",
|
|
4149
|
+
flags: [{ flag: "--merge-sha", description: "The merge commit to resolve (defaults to the current HEAD)" }],
|
|
4150
|
+
details: [
|
|
4151
|
+
"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.",
|
|
4152
|
+
"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."
|
|
4153
|
+
],
|
|
4154
|
+
status: "shipped"
|
|
4155
|
+
},
|
|
4156
|
+
{
|
|
4157
|
+
name: "audit",
|
|
4158
|
+
description: "Run the ring-2 dead-branch-push and direct-main-push detection checks directly",
|
|
4159
|
+
flags: [
|
|
4160
|
+
{ flag: "--only", description: "Scope to one check: 'dead-branches' or 'direct-push'" },
|
|
4161
|
+
{ flag: "--sha", description: "The commit to check for direct-main-push (defaults to the current HEAD)" },
|
|
4162
|
+
{ flag: "--json", description: "Enveloped JSON output" }
|
|
4163
|
+
],
|
|
4164
|
+
details: [
|
|
4165
|
+
"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.",
|
|
4166
|
+
"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."
|
|
4167
|
+
],
|
|
4168
|
+
status: "shipped"
|
|
4169
|
+
},
|
|
3104
4170
|
{
|
|
3105
4171
|
name: "eject",
|
|
3106
4172
|
description: "Remove every Vinaya-installed artifact, restoring the repo to stock",
|
|
@@ -3113,12 +4179,25 @@ var COMMANDS = [
|
|
|
3113
4179
|
{
|
|
3114
4180
|
name: "demo break",
|
|
3115
4181
|
description: "Run a guided refusal-then-fix demo on an isolated, discardable branch",
|
|
3116
|
-
|
|
4182
|
+
flags: [{ flag: "--keep", description: "Skip cleanup and leave the demo branch checked out to inspect" }],
|
|
4183
|
+
details: [
|
|
4184
|
+
"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.",
|
|
4185
|
+
"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."
|
|
4186
|
+
],
|
|
4187
|
+
status: "shipped"
|
|
3117
4188
|
},
|
|
3118
4189
|
{
|
|
3119
4190
|
name: "waiver",
|
|
3120
|
-
description: "Apply the
|
|
3121
|
-
|
|
4191
|
+
description: "Apply the actor-verified 'vinaya/waiver:docs' or 'vinaya/waiver:review' label after prompting for a reason",
|
|
4192
|
+
flags: [
|
|
4193
|
+
{ flag: "--reason", description: "The waiver rationale, posted as a PR comment (prompted for if omitted)" },
|
|
4194
|
+
{ flag: "--print-only", description: "Print the exact `gh` commands instead of running them" }
|
|
4195
|
+
],
|
|
4196
|
+
details: [
|
|
4197
|
+
"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.",
|
|
4198
|
+
"`--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."
|
|
4199
|
+
],
|
|
4200
|
+
status: "shipped"
|
|
3122
4201
|
},
|
|
3123
4202
|
{
|
|
3124
4203
|
name: "studio",
|
|
@@ -3155,9 +4234,9 @@ function printHelp() {
|
|
|
3155
4234
|
}
|
|
3156
4235
|
|
|
3157
4236
|
// src/index.ts
|
|
3158
|
-
var PACKAGE_ROOT =
|
|
4237
|
+
var PACKAGE_ROOT = join14(dirname5(fileURLToPath2(import.meta.url)), "..");
|
|
3159
4238
|
function readVersion2() {
|
|
3160
|
-
const pkg = JSON.parse(
|
|
4239
|
+
const pkg = JSON.parse(readFileSync11(join14(PACKAGE_ROOT, "package.json"), "utf-8"));
|
|
3161
4240
|
return pkg.version;
|
|
3162
4241
|
}
|
|
3163
4242
|
var [, , command, ...args] = process.argv;
|
|
@@ -3207,6 +4286,14 @@ try {
|
|
|
3207
4286
|
await checkCommand(args);
|
|
3208
4287
|
break;
|
|
3209
4288
|
}
|
|
4289
|
+
case "archive": {
|
|
4290
|
+
await archiveCommand(args);
|
|
4291
|
+
break;
|
|
4292
|
+
}
|
|
4293
|
+
case "audit": {
|
|
4294
|
+
await auditCommand(args);
|
|
4295
|
+
break;
|
|
4296
|
+
}
|
|
3210
4297
|
case "new": {
|
|
3211
4298
|
const [subcommand, ...rest] = args;
|
|
3212
4299
|
if (subcommand === "check") {
|
|
@@ -3241,6 +4328,20 @@ try {
|
|
|
3241
4328
|
}
|
|
3242
4329
|
break;
|
|
3243
4330
|
}
|
|
4331
|
+
case "demo": {
|
|
4332
|
+
const [subcommand, ...rest] = args;
|
|
4333
|
+
if (subcommand === "break") {
|
|
4334
|
+
await demoBreakCommand(rest);
|
|
4335
|
+
} else {
|
|
4336
|
+
console.error(`Unknown 'demo' subcommand: ${subcommand ?? "(none)"} (expected 'break')`);
|
|
4337
|
+
process.exit(2);
|
|
4338
|
+
}
|
|
4339
|
+
break;
|
|
4340
|
+
}
|
|
4341
|
+
case "waiver": {
|
|
4342
|
+
await waiverCommand(args);
|
|
4343
|
+
break;
|
|
4344
|
+
}
|
|
3244
4345
|
default:
|
|
3245
4346
|
console.error(`Unknown command: ${command}`);
|
|
3246
4347
|
printHelp();
|