@attalabs/vinaya 0.22.0 → 0.23.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 +13 -3
- package/aeg-root/contracts/archivist-tranche-archivist.md +2 -2
- package/aeg-root/contracts/brief-developer.md +1 -0
- package/aeg-root/contracts/developer-reviewer.md +3 -3
- package/aeg-root/contracts/reviewer-archivist.md +3 -3
- package/aeg-root/enforcement.md +26 -20
- package/aeg-root/process.md +8 -4
- package/aeg-root/roles/archivist.md +2 -2
- package/aeg-root/roles/brief-author.md +9 -3
- package/aeg-root/roles/developer.md +63 -31
- package/aeg-root/roles/planner.md +1 -1
- package/aeg-root/roles/principal.md +14 -0
- package/aeg-root/roles/reviewer.md +32 -15
- package/aeg-root/roles/security.md +15 -5
- package/aeg-root/skills/aeg/SKILL.md +9 -5
- package/aeg-root/skills/aeg-roles/SKILL.md +2 -2
- package/aeg-root/skills/brief-authoring/SKILL.md +39 -27
- package/aeg-root/state-machine.md +9 -11
- package/aeg-root/templates/brief-template.md +6 -6
- package/aeg-root/templates/pr-report-template.md +10 -7
- package/aeg-root/tranche-model.md +6 -2
- package/dist/checks/bin/check-body-bare-digits.js +552 -139
- package/dist/checks/bin/check-branch-topology.js +603 -144
- package/dist/checks/bin/check-brief-shape.js +593 -142
- package/dist/checks/bin/check-changeset-coverage.js +935 -161
- package/dist/checks/bin/check-closes-n.js +603 -144
- package/dist/checks/bin/check-coherence.js +614 -147
- package/dist/checks/bin/check-dead-branch-push.js +552 -139
- package/dist/checks/bin/check-dispatch-readiness.js +612 -145
- package/dist/checks/bin/check-doc-coverage-push.js +689 -218
- package/dist/checks/bin/check-doc-coverage.js +689 -218
- package/dist/checks/bin/check-doctrine-no-procedures.js +4794 -0
- package/dist/checks/bin/check-doctrine-portability.js +684 -146
- package/dist/checks/bin/check-evidence-fresh.js +994 -174
- package/dist/checks/bin/check-exec-bits.js +4931 -0
- package/dist/checks/bin/check-first-push-dispatch.js +603 -144
- package/dist/checks/bin/check-issue-assignment.js +603 -144
- package/dist/checks/bin/check-main-branch-refusal.js +552 -139
- package/dist/checks/bin/check-no-disk-state.js +552 -139
- package/dist/checks/bin/check-pr-report-density.js +4461 -0
- package/dist/checks/bin/check-quoted-command.js +621 -155
- package/dist/checks/bin/check-reader-resolvable-prose.js +617 -154
- package/dist/checks/bin/check-registry-gates.js +595 -144
- package/dist/checks/bin/check-retired-vocabulary.js +617 -154
- package/dist/checks/bin/check-review-gate.js +670 -141
- package/dist/checks/bin/check-single-plan-pr.js +552 -139
- package/dist/checks/bin/check-test-plan.js +553 -140
- package/dist/checks/bin/check-token-collection-wired.js +552 -139
- package/dist/checks/bin/check-token-report.js +552 -139
- package/dist/checks/bin/check-workspace-escape.js +985 -145
- package/dist/index.js +8500 -5518
- package/package.json +1 -1
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/actions.ts +173 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/anchored-region.ts +113 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/archive-task.ts +206 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/baseline-capture.ts +65 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/blast-radius-domains.ts +192 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/branch-topology-gate.ts +85 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-validation.ts +511 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/claude-code-transcript.ts +460 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +787 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-audit.ts +55 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-guard.ts +77 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-section7.ts +66 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-tranche.ts +94 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/diagram-model.ts +372 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/direct-main-push.ts +39 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +224 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doc-owners.ts +314 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/build-doc-nav.ts +38 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/docs-coherence.ts +136 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/index.ts +29 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/legacy-anchors.ts +48 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/nav-helpers.ts +17 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/node-route.ts +134 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/parse-doc.ts +43 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/published-prose.ts +306 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/surfaced-manifest.ts +89 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/types.ts +33 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +170 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-source.ts +23 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/ensure-label.ts +28 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/file-classify.ts +51 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/first-push-dispatch-gate.ts +80 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +168 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +301 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-assignment.ts +102 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-validation.ts +746 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/leftover-detection.ts +68 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/local-anchor-coverage.ts +245 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/main-branch-refusal.ts +64 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/manifest-validity.ts +81 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/markdown-table.ts +76 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/metering-io-guard.ts +119 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/milestone-validation.ts +240 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/no-disk-state.ts +50 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-ledger.ts +77 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-registry.ts +105 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +165 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-tranche.ts +183 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-tier.ts +81 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +125 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +275 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/reader-resolvable-prose.ts +320 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-checks.ts +255 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-parse.ts +164 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-scaffold.ts +216 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/report-tokens.ts +261 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/retired-vocabulary.ts +163 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +269 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/single-plan-pr.ts +73 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-machine-model.ts +269 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-source.ts +14 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/status-block.ts +8 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/sum-ledger.ts +26 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/symbol-collisions.ts +78 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +104 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-section.ts +65 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/types.ts +174 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +140 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/vocabulary-citation.ts +114 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/waiver-label.ts +52 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/workspace-escape.ts +172 -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/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.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.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.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.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.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.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.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.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.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.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/[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]__0o771t1._.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]__1hs0dcu._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__069-_41._.js → [root-of-the-server]__1mcitst._.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/_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/_1fqw88f._.js +1 -1
- 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/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/{37hnluhzj9bei.js → 0jer_drkl1e4r.js} +4 -4
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 20x-t0a0v129z.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.js → 2ou_81ahqw31e.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
- /package/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/{aeg-core → vinaya/studio-standalone/_node_modules/@attalabs/aeg-core}/bin/verify-coherence.ts +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_buildManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_clientMiddlewareManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct-main-push detection (aeg-governance-hardening task 24, #364,
|
|
3
|
+
* Part 3 — item 1). Pure — no `fs`, no `gh`/`git` shell-outs. The CLI shim
|
|
4
|
+
* (`bin/check-direct-main-push.ts`) makes the one forge call (the
|
|
5
|
+
* commits→pulls association API: `GET /repos/{owner}/{repo}/commits/{sha}/pulls`,
|
|
6
|
+
* which GitHub documents as returning "the merged Pull Request that
|
|
7
|
+
* introduced the commit to the repository" for a commit on the default
|
|
8
|
+
* branch) and passes the result in here.
|
|
9
|
+
*
|
|
10
|
+
* Detection only, never prevention here: prevention lives at
|
|
11
|
+
* `.husky/pre-push` (ring 0) and the repository ruleset (ring 1). This
|
|
12
|
+
* predicate only decides whether the pushed commit is
|
|
13
|
+
* legitimate (associated with a merged PR) — the CLI shim opens an
|
|
14
|
+
* incident Issue and fails the run loudly on a violation; it never reverts
|
|
15
|
+
* or mutates anything (Principal decision, brief §11).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type DirectMainPushFact = {
|
|
19
|
+
sha: string
|
|
20
|
+
/** PR numbers the commits→pulls association API reports as MERGED for this commit. Empty when the commit has no associated merged PR — i.e. it was pushed directly. */
|
|
21
|
+
associatedMergedPrNumbers: number[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type DirectMainPushResult =
|
|
25
|
+
| { verdict: 'legitimate'; mergedPrNumber: number }
|
|
26
|
+
| { verdict: 'direct-push'; sha: string }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `legitimate` — at least one merged PR is associated with this commit (the
|
|
30
|
+
* normal case: a squash-merge or merge commit that landed via a PR).
|
|
31
|
+
* `direct-push` — no associated merged PR at all. This is the violation:
|
|
32
|
+
* either a genuine direct push, or (equivalently suspicious) a commit the
|
|
33
|
+
* association API cannot explain.
|
|
34
|
+
*/
|
|
35
|
+
export function checkDirectMainPush(fact: DirectMainPushFact): DirectMainPushResult {
|
|
36
|
+
const [first] = fact.associatedMergedPrNumbers
|
|
37
|
+
if (first !== undefined) return { verdict: 'legitimate', mergedPrNumber: first }
|
|
38
|
+
return { verdict: 'direct-push', sha: fact.sha }
|
|
39
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch-readiness composition (aeg-governance-hardening task 11, #324).
|
|
3
|
+
* Pure — no `fs`, no `fetch`. Composes the forge/rationale/provenance/
|
|
4
|
+
* archival facts the CLI shim (`bin/verify-dispatch.ts`) gathers (reusing
|
|
5
|
+
* `parseTranche`, `hasProvenance`, `taskRefFromBranch`,
|
|
6
|
+
* `checkIssueRationale`, `fetchProvenance` — never re-implementing any of
|
|
7
|
+
* them) into one `{ ready, blockers }` verdict, one blocker string per
|
|
8
|
+
* failing predicate.
|
|
9
|
+
*
|
|
10
|
+
* Every predicate here mechanizes a prose precondition that `roles/
|
|
11
|
+
* developer.md`'s entry gate and `contracts/brief-developer.md` currently
|
|
12
|
+
* state as something the Developer must re-derive by hand — the exact gap
|
|
13
|
+
* this task exists to close: four Developer agents independently re-derived
|
|
14
|
+
* (and stopped on) the same archival fact from scratch, at real token cost,
|
|
15
|
+
* hours after it first became true (aeg-governance-hardening's 2026-07-02/03
|
|
16
|
+
* live-fire wave).
|
|
17
|
+
*
|
|
18
|
+
* Message style mirrors `coherence-checks.ts`'s A1/A2/T2/etc. family: one
|
|
19
|
+
* line per failure, naming the exact task/Issue/PR involved.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Task } from './types'
|
|
23
|
+
import { isPrincipal, PRINCIPAL_ALLOWLIST } from './waiver-label'
|
|
24
|
+
|
|
25
|
+
export type DispatchIssueFact = { number: number; state: 'open' | 'closed' } | null
|
|
26
|
+
|
|
27
|
+
export type DispatchEdgeFact = {
|
|
28
|
+
/** The edge's target task id, as written in the topology's depends-on/conflicts-with cell. */
|
|
29
|
+
id: string
|
|
30
|
+
/** The target task's Issue number, if known — for message readability only. */
|
|
31
|
+
issue: number | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type DispatchDependsOnFact = DispatchEdgeFact & {
|
|
35
|
+
merged: boolean
|
|
36
|
+
/**
|
|
37
|
+
* `false` when the resolver could not find any tranche/task/Issue matching
|
|
38
|
+
* this edge at all — a fact distinct from `merged: false`, which means the
|
|
39
|
+
* edge resolved to a real target that just hasn't merged yet. Conflating
|
|
40
|
+
* the two produced a false "not merged yet" claim for #193's
|
|
41
|
+
* `vinaya-milestone-model-v1 2` edge, which had genuinely already merged
|
|
42
|
+
* (#196) — the message this distinction exists to correct. Absent or
|
|
43
|
+
* `true` for every edge the resolver actually matched to a target,
|
|
44
|
+
* including one whose target lookup itself then failed (an outage), which
|
|
45
|
+
* stays under the existing conservative `merged: false` default rather
|
|
46
|
+
* than this one.
|
|
47
|
+
*/
|
|
48
|
+
resolved?: boolean
|
|
49
|
+
/**
|
|
50
|
+
* Hand-close recognition facts (task `vinaya-engine-v1` 21, #99) — a
|
|
51
|
+
* second, narrower path alongside `merged` for a dependency Issue closed
|
|
52
|
+
* directly by a recognized Principal, with a stated `COMPLETED` reason,
|
|
53
|
+
* rather than via a merged PR. All three are `null`/absent when the edge
|
|
54
|
+
* is unresolvable or the target issue is open/has no close event — in
|
|
55
|
+
* which case this path never fires and only `merged` matters, same as
|
|
56
|
+
* before this task.
|
|
57
|
+
*/
|
|
58
|
+
issueState?: 'open' | 'closed' | null
|
|
59
|
+
stateReason?: 'completed' | 'not_planned' | null
|
|
60
|
+
closedByActor?: string | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* True when a dependency Issue was closed directly by a recognized Principal
|
|
65
|
+
* with a stated `COMPLETED` reason — the second, narrower "done" path this
|
|
66
|
+
* task adds alongside `merged`. Deliberately conjunctive and centralized
|
|
67
|
+
* here (not duplicated per caller): the trap this task exists to avoid is
|
|
68
|
+
* "any closed Issue with a comment counts," so every condition below is a
|
|
69
|
+
* real forge fact, not a prose claim —
|
|
70
|
+
* - `issueState === 'closed'`: the Issue is actually closed.
|
|
71
|
+
* - `stateReason === 'completed'`: GitHub's own close reason says resolved,
|
|
72
|
+
* not `not_planned` (abandoned — the opposite of resolved).
|
|
73
|
+
* - `closedByActor` is the GitHub login that performed the CLOSED_EVENT
|
|
74
|
+
* (not a claim in a comment body) and is a member of the recognized
|
|
75
|
+
* Principal allowlist.
|
|
76
|
+
*/
|
|
77
|
+
function isHandClosedByRecognizedPrincipal(
|
|
78
|
+
dep: Pick<DispatchDependsOnFact, 'issueState' | 'stateReason' | 'closedByActor'>,
|
|
79
|
+
principalAllowlist: string[]
|
|
80
|
+
): boolean {
|
|
81
|
+
return (
|
|
82
|
+
dep.issueState === 'closed' &&
|
|
83
|
+
dep.stateReason === 'completed' &&
|
|
84
|
+
isPrincipal(dep.closedByActor ?? null, principalAllowlist)
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type DispatchConflictsWithFact = DispatchEdgeFact & { openOrInFlight: boolean }
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Removed the predicate that consumed this fact
|
|
92
|
+
* (`checkDispatchReadiness` no longer blocks on it) — automated the
|
|
93
|
+
* signal (per-task provenance posting) this predicate existed to protect,
|
|
94
|
+
* making the row-adjacency gate itself the stale part, not the automation.
|
|
95
|
+
* Kept, dormant, rather than deleted: several callers (`bin/verify-dispatch.ts`,
|
|
96
|
+
* both Studio/Vinaya `map-dispatch-input.ts` mappers) still assemble this
|
|
97
|
+
* fact as harmless dead plumbing, and `DispatchGateInput.priorTask` below
|
|
98
|
+
* remains the exact shape needed to test the predicate's absence.
|
|
99
|
+
*/
|
|
100
|
+
export type DispatchPriorTaskFact = {
|
|
101
|
+
id: string
|
|
102
|
+
issue: number | null
|
|
103
|
+
issueClosed: boolean
|
|
104
|
+
prMerged: boolean
|
|
105
|
+
hasProvenance: boolean
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type DispatchPriorTrancheFact = {
|
|
109
|
+
project: string
|
|
110
|
+
/** null when this project has no prior tranche at all — the gate passes trivially for it. */
|
|
111
|
+
priorTrancheSlug: string | null
|
|
112
|
+
archived: boolean
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type DispatchGateInput = {
|
|
116
|
+
trancheSlug: string
|
|
117
|
+
/** The topology row for this task, as parsed by `parseTranche`. */
|
|
118
|
+
task: Task
|
|
119
|
+
/** null when the task has no Issue (#TBD/blank) OR the Issue number doesn't resolve on the forge (phantom ref). */
|
|
120
|
+
issue: DispatchIssueFact
|
|
121
|
+
/** `checkIssueRationale` result for this task's Issue — irrelevant (treated as passing) when `issue` is null. */
|
|
122
|
+
issueRationalePass: boolean
|
|
123
|
+
dependsOn: DispatchDependsOnFact[]
|
|
124
|
+
conflictsWith: DispatchConflictsWithFact[]
|
|
125
|
+
/**
|
|
126
|
+
* The immediately-prior task in this same tranche's topology, or null
|
|
127
|
+
* when this is the first task. no longer read by
|
|
128
|
+
* `checkDispatchReadiness` — dormant field, kept for caller compatibility.
|
|
129
|
+
*/
|
|
130
|
+
priorTask: DispatchPriorTaskFact | null
|
|
131
|
+
/** One entry per project named in `task.projects`. */
|
|
132
|
+
priorTrancheArchival: DispatchPriorTrancheFact[]
|
|
133
|
+
/**
|
|
134
|
+
* Overrides `PRINCIPAL_ALLOWLIST` for hand-close recognition when
|
|
135
|
+
* provided — an adopter repo's own `vinaya.config.json` `principals`
|
|
136
|
+
* field, resolved by the CLI bin before calling in (never read from here;
|
|
137
|
+
* this stays pure). Defaults to `PRINCIPAL_ALLOWLIST` when omitted, same
|
|
138
|
+
* pattern as `checkReviewGate`'s `principalAllowlist` (`review-gate.ts`) —
|
|
139
|
+
* hardcoding this repo's own principal made that gate unpassable on any
|
|
140
|
+
* adopter repo; the same mistake here would silently do the same to the
|
|
141
|
+
* hand-close path.
|
|
142
|
+
*/
|
|
143
|
+
principalAllowlist?: string[]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type DispatchResult = { ready: boolean; blockers: string[] }
|
|
147
|
+
|
|
148
|
+
export function checkDispatchReadiness(input: DispatchGateInput): DispatchResult {
|
|
149
|
+
const { trancheSlug, task } = input
|
|
150
|
+
const taskLabel = `task ${task.id} (tranche ${trancheSlug})`
|
|
151
|
+
const principalAllowlist = input.principalAllowlist ?? PRINCIPAL_ALLOWLIST
|
|
152
|
+
const blockers: string[] = []
|
|
153
|
+
|
|
154
|
+
// Issue-existence — the topology row itself has no Issue number.
|
|
155
|
+
if (task.issue === null) {
|
|
156
|
+
blockers.push(
|
|
157
|
+
`dispatch-gate issue-existence: ${taskLabel} has no Issue (#TBD or blank) in the topology — not dispatchable until the Planner cuts the Issue.`
|
|
158
|
+
)
|
|
159
|
+
} else if (input.issue === null) {
|
|
160
|
+
// Row names an Issue number, but it doesn't resolve on the forge — phantom ref (T1's fail class).
|
|
161
|
+
blockers.push(
|
|
162
|
+
`dispatch-gate issue-existence: ${taskLabel} names Issue #${task.issue}, but it does not resolve to a real GitHub Issue (phantom reference).`
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Planner-rationale completeness — only evaluable when the Issue itself resolved.
|
|
167
|
+
if (input.issue !== null && !input.issueRationalePass) {
|
|
168
|
+
blockers.push(
|
|
169
|
+
`dispatch-gate rationale: Issue #${input.issue.number} for ${taskLabel} fails the rationale gate (checkIssueRationale) — the Planner must complete the eight-field rationale before this task is dispatchable.`
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Depends-on merged — OR hand-closed by a recognized Principal (task
|
|
174
|
+
// `vinaya-engine-v1` 21, #99): a second, narrower path for a dependency
|
|
175
|
+
// Issue closed directly rather than via a merged PR.
|
|
176
|
+
for (const dep of input.dependsOn) {
|
|
177
|
+
if (dep.resolved === false) {
|
|
178
|
+
// Distinct from the "not merged yet" branch below (#196): this edge
|
|
179
|
+
// never resolved to any tranche/task/Issue at all, so a "not merged"
|
|
180
|
+
// claim would misattribute the failure to the forge rather than to
|
|
181
|
+
// the edge text. Still blocks — the conservative default is correct
|
|
182
|
+
// for a genuinely unresolvable edge — but says so honestly.
|
|
183
|
+
blockers.push(
|
|
184
|
+
`dispatch-gate depends-on: ${taskLabel} depends on "${dep.id}", which is UNRESOLVABLE — the resolver could not find a matching tranche/task/Issue for this edge (not a claim about merge status). Not dispatchable until the edge is corrected.`
|
|
185
|
+
)
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
if (!dep.merged && !isHandClosedByRecognizedPrincipal(dep, principalAllowlist)) {
|
|
189
|
+
const issueStr = dep.issue !== null ? ` (#${dep.issue})` : ''
|
|
190
|
+
blockers.push(
|
|
191
|
+
`dispatch-gate depends-on: ${taskLabel} depends on ${dep.id}${issueStr}, whose PR is not merged yet — not dispatchable, it serializes behind it.`
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Conflicts-with not open/in-flight.
|
|
197
|
+
for (const c of input.conflictsWith) {
|
|
198
|
+
if (c.openOrInFlight) {
|
|
199
|
+
const issueStr = c.issue !== null ? ` (#${c.issue})` : ''
|
|
200
|
+
blockers.push(
|
|
201
|
+
`dispatch-gate conflicts-with: ${taskLabel} conflicts with ${c.id}${issueStr}, whose PR is open or in-flight — not dispatchable until it merges.`
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Prior-task archival / row-adjacency predicate REMOVED (2026-07-13).
|
|
207
|
+
// The retired predicate required every earlier table row's full archival (Issue
|
|
208
|
+
// closed, PR merged, provenance posted) regardless of whether that row was
|
|
209
|
+
// a declared dependency automated the provenance-posting signal
|
|
210
|
+
// this existed to protect, so the blanket row-order block outlived its
|
|
211
|
+
// justification. `input.priorTask` is still accepted (dormant) for caller
|
|
212
|
+
// compatibility; see the type's doc comment above.
|
|
213
|
+
|
|
214
|
+
// Prior-tranche archival, per project named in Project(s).
|
|
215
|
+
for (const proj of input.priorTrancheArchival) {
|
|
216
|
+
if (proj.priorTrancheSlug !== null && !proj.archived) {
|
|
217
|
+
blockers.push(
|
|
218
|
+
`dispatch-gate prior-tranche-archival: project \`${proj.project}\`'s previous tranche \`${proj.priorTrancheSlug}\` is not archived — the Tranche Archivist must run before new work on this product.`
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { ready: blockers.length === 0, blockers }
|
|
224
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `.vinaya/doc-owners` manifest parser and the C5 code→doc
|
|
3
|
+
* coverage evaluator (state-machine.md Section 15). Pure — I/O (reading the
|
|
4
|
+
* manifest off disk, checking pointer existence) is injected by the caller.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { isCodeFile } from './file-classify'
|
|
8
|
+
import { WAIVER_LABEL } from './waiver-label'
|
|
9
|
+
|
|
10
|
+
export const DOC_OWNERS_PATH = '.vinaya/doc-owners'
|
|
11
|
+
|
|
12
|
+
export type DocOwnersBinding = { glob: string; pointer: string; lineNum: number }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a caller found where the manifest should be.
|
|
16
|
+
*
|
|
17
|
+
* `absent` — a repo that never configured doc ownership. Legitimately dormant.
|
|
18
|
+
* `empty` — a manifest that exists but has nothing in it. This is the shape a
|
|
19
|
+
* misresolved repo root produces, and reporting it as success is how a broken
|
|
20
|
+
* derivation passes for a real one. It is a refusal, not a dormancy.
|
|
21
|
+
* `present` — parse it.
|
|
22
|
+
*
|
|
23
|
+
* Split out of `verify-dispatch --surfaces` so the three-way decision is
|
|
24
|
+
* testable: the CLI chdirs to the repo root before reading, so the branch is
|
|
25
|
+
* unreachable from a test as long as it lives inside the command.
|
|
26
|
+
*/
|
|
27
|
+
export type DocOwnersManifestState = 'absent' | 'empty' | 'present'
|
|
28
|
+
|
|
29
|
+
export function classifyDocOwnersManifest(content: string | null): DocOwnersManifestState {
|
|
30
|
+
if (content === null) return 'absent'
|
|
31
|
+
return content.trim() === '' ? 'empty' : 'present'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type C5Result = { errors: string[]; notes: string[] }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse the doc-owners file. Each non-blank, non-comment line is
|
|
38
|
+
* `<code-glob> <doc-pointer>`
|
|
39
|
+
*
|
|
40
|
+
* Comments start with `#`. Pointer is in-repo path, path#anchor, or URL.
|
|
41
|
+
* Returns the parsed bindings + any malformed-line errors.
|
|
42
|
+
*/
|
|
43
|
+
export function parseDocOwners(content: string): { bindings: DocOwnersBinding[]; errors: string[] } {
|
|
44
|
+
const bindings: DocOwnersBinding[] = []
|
|
45
|
+
const errors: string[] = []
|
|
46
|
+
const lines = content.split('\n')
|
|
47
|
+
for (let i = 0; i < lines.length; i++) {
|
|
48
|
+
const raw = lines[i] ?? ''
|
|
49
|
+
const stripped = raw.replace(/#.*$/, '').trim()
|
|
50
|
+
if (!stripped) continue
|
|
51
|
+
const parts = stripped.split(/\s+/)
|
|
52
|
+
if (parts.length < 2) {
|
|
53
|
+
errors.push(
|
|
54
|
+
`C5 doc-owners-parse: ${DOC_OWNERS_PATH}:${i + 1} — malformed binding (expected "<glob> <pointer>", got "${raw.trim()}").`
|
|
55
|
+
)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
const [glob, ...pointerParts] = parts
|
|
59
|
+
bindings.push({ glob: glob ?? '', pointer: pointerParts.join(' '), lineNum: i + 1 })
|
|
60
|
+
}
|
|
61
|
+
return { bindings, errors }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Translate a doc-owners glob to a RegExp. Deliberately simple — only `*` and
|
|
66
|
+
* `**` are special; every other character is literal so Next.js dynamic-route
|
|
67
|
+
* segments like `[username]` match without escaping.
|
|
68
|
+
*/
|
|
69
|
+
export function globToRegex(pat: string): RegExp {
|
|
70
|
+
let re = '^'
|
|
71
|
+
let i = 0
|
|
72
|
+
while (i < pat.length) {
|
|
73
|
+
const c = pat[i]
|
|
74
|
+
if (c === '*') {
|
|
75
|
+
if (pat[i + 1] === '*') {
|
|
76
|
+
re += '.*'
|
|
77
|
+
i += 2
|
|
78
|
+
} else {
|
|
79
|
+
re += '[^/]*'
|
|
80
|
+
i += 1
|
|
81
|
+
}
|
|
82
|
+
} else if (/[.+^$|(){}[\]\\?]/.test(c as string)) {
|
|
83
|
+
re += `\\${c}`
|
|
84
|
+
i += 1
|
|
85
|
+
} else {
|
|
86
|
+
re += c
|
|
87
|
+
i += 1
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
re += '$'
|
|
91
|
+
return new RegExp(re)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isUrlPointer(p: string): boolean {
|
|
95
|
+
return /^https?:\/\//.test(p)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function pointerToPath(p: string): string {
|
|
99
|
+
const idx = p.indexOf('#')
|
|
100
|
+
return idx === -1 ? p : p.slice(0, idx)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type DocAck = { surface: string; note: string }
|
|
104
|
+
export type DocNeutral = { surface: string; note: string }
|
|
105
|
+
|
|
106
|
+
// Separator between <pointer> and <note>. Tolerates em-dash (—),
|
|
107
|
+
// en-dash (–), or a plain ASCII hyphen-minus (-) with REQUIRED surrounding
|
|
108
|
+
// whitespace. Required whitespace around `-` disambiguates the separator
|
|
109
|
+
// from hyphens that legitimately appear inside pointers (e.g. `aeg-root`,
|
|
110
|
+
// `.claude/skills/ui-components/SKILL.md`). Non-greedy `(.+?)` for the
|
|
111
|
+
// pointer plus this anchored separator means the pointer naturally stops
|
|
112
|
+
// at the first valid separator without literally excluding `-` from
|
|
113
|
+
// the pointer character set.
|
|
114
|
+
const SEPARATOR = /(?:[ \t]*[—–][ \t]*|[ \t]+-[ \t]+)/.source
|
|
115
|
+
|
|
116
|
+
/** Shared by `Doc-ack:` and `Doc-neutral:` — both are `<field>: <pointer> <sep> <note>` PR-body lines. */
|
|
117
|
+
function readPointerNoteField(body: string, field: string): { surface: string; note: string }[] {
|
|
118
|
+
const out: { surface: string; note: string }[] = []
|
|
119
|
+
const re = new RegExp(`^[ \\t]*${field}[ \\t]*:[ \\t]*(.+?)${SEPARATOR}(.+?)[ \\t]*$`, 'gim')
|
|
120
|
+
for (const m of body.matchAll(re)) {
|
|
121
|
+
out.push({ surface: (m[1] ?? '').trim(), note: (m[2] ?? '').trim() })
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function readDocAcks(body: string): DocAck[] {
|
|
127
|
+
return readPointerNoteField(body, 'Doc-ack')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `Doc-neutral: <pointer> — <note>` — declares that the doc a fired binding
|
|
132
|
+
* would otherwise require is not owed because the matched code change is
|
|
133
|
+
* mechanically neutral (comment/whitespace-only). Mirrors `Doc-ack:`'s
|
|
134
|
+
* grammar deliberately, so it reads as the same PR-body-field family.
|
|
135
|
+
*
|
|
136
|
+
* The declaration alone is never sufficient — see `isMechanicallyNeutralDiff`
|
|
137
|
+
* below, which `evaluateC5` requires as corroborating evidence before this
|
|
138
|
+
* declaration satisfies a fired binding. An unevidenced declaration is a
|
|
139
|
+
* distinct failure (`C5 doc-neutral-unverified`), not a silent pass — that
|
|
140
|
+
* asymmetry is what keeps this from being a self-serve exemption.
|
|
141
|
+
*/
|
|
142
|
+
export function readDocNeutrals(body: string): DocNeutral[] {
|
|
143
|
+
return readPointerNoteField(body, 'Doc-neutral')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Full-line comment markers, scoped per source-language file extension. A
|
|
148
|
+
* marker is listed for a language ONLY if no legitimate code construct in
|
|
149
|
+
* that language can start a trimmed line with it — that constraint is what
|
|
150
|
+
* keeps this fail-closed rather than a bare per-line prefix table (review
|
|
151
|
+
* finding, task 4 code review): a flat asterisk/hash/double-dash/block-
|
|
152
|
+
* comment-delimiter table misclassifies real code as comment — a TS/JS
|
|
153
|
+
* generator method (`*gen() {`), a TS/JS private class field
|
|
154
|
+
* (`#count = 0`), SQL's `--` also being C-style pre-decrement (`--i;`), and
|
|
155
|
+
* a block-comment-close delimiter hiding trailing code on the same line are
|
|
156
|
+
* all real collisions, not hypothetical ones. `//` is the only marker kept
|
|
157
|
+
* for TS/JS/JSX because no such language construct starts a trimmed line
|
|
158
|
+
* with `//`. An unrecognized extension gets no safe marker at all, so
|
|
159
|
+
* `isMechanicallyNeutralDiff` can never classify it neutral — fail-closed
|
|
160
|
+
* under uncertainty, not an attempt at full per-language comment/string-
|
|
161
|
+
* region parsing (out of this task's bounded "comment/whitespace-only"
|
|
162
|
+
* scope).
|
|
163
|
+
*/
|
|
164
|
+
const LANGUAGE_COMMENT_PREFIXES: ReadonlyArray<{ test: RegExp; prefixes: readonly string[] }> = [
|
|
165
|
+
{ test: /\.(ts|tsx|js|jsx|mjs|cjs)$/, prefixes: ['//'] },
|
|
166
|
+
{ test: /(^|\/)(pre-push|pre-commit|pre-merge-commit)$/, prefixes: ['#'] },
|
|
167
|
+
{ test: /\.(sh|bash)$/, prefixes: ['#'] },
|
|
168
|
+
{ test: /\.ya?ml$/, prefixes: ['#'] },
|
|
169
|
+
{ test: /\.py$/, prefixes: ['#'] }
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
function commentPrefixesForPath(path: string): readonly string[] {
|
|
173
|
+
for (const { test, prefixes } of LANGUAGE_COMMENT_PREFIXES) {
|
|
174
|
+
if (test.test(path)) return prefixes
|
|
175
|
+
}
|
|
176
|
+
return []
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isNeutralDiffContentLine(line: string, prefixes: readonly string[]): boolean {
|
|
180
|
+
const t = line.trim()
|
|
181
|
+
if (t === '') return true
|
|
182
|
+
// A shebang is comment-shaped but changes what interpreter runs the
|
|
183
|
+
// file — real behavior, never neutral, even though it starts with `#`.
|
|
184
|
+
if (t.startsWith('#!')) return false
|
|
185
|
+
return prefixes.some((p) => t.startsWith(p))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The evidence half of the neutral-edit path: given a unified diff for one
|
|
190
|
+
* file and that file's path (for language-scoped comment-marker selection),
|
|
191
|
+
* true only if every added/removed line is comment-only or whitespace-only.
|
|
192
|
+
* Context lines and the `+++`/`---`/`@@` headers are not evidence either
|
|
193
|
+
* way. A diff with zero +/- content lines is not "a change" at all, so it
|
|
194
|
+
* returns false rather than vacuously true — there must be at least one
|
|
195
|
+
* actual edited line for a neutrality claim to mean anything. An
|
|
196
|
+
* unrecognized file extension (`commentPrefixesForPath` returns `[]`)
|
|
197
|
+
* always returns false — no fired binding on an unrecognized language can
|
|
198
|
+
* take the Doc-neutral path.
|
|
199
|
+
*/
|
|
200
|
+
export function isMechanicallyNeutralDiff(diffText: string, path: string): boolean {
|
|
201
|
+
const prefixes = commentPrefixesForPath(path)
|
|
202
|
+
let sawChange = false
|
|
203
|
+
for (const raw of diffText.split('\n')) {
|
|
204
|
+
if (raw.startsWith('+++ ') || raw.startsWith('--- ') || raw.startsWith('@@')) continue
|
|
205
|
+
if (raw.startsWith('+') || raw.startsWith('-')) {
|
|
206
|
+
if (!isNeutralDiffContentLine(raw.slice(1), prefixes)) return false
|
|
207
|
+
sawChange = true
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return sawChange
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Pure evaluator for the C5 doc-coverage check. The runtime wrapper reads
|
|
215
|
+
* `.vinaya/doc-owners` from disk and `PR_BODY` from env; this
|
|
216
|
+
* function takes both as inputs and an injectable file-exists for unit tests.
|
|
217
|
+
*
|
|
218
|
+
* Dormancy: a null `docOwnersContent` (absent file) OR no glob matching any
|
|
219
|
+
* changed code file produces an empty result — no errors, no notes.
|
|
220
|
+
*
|
|
221
|
+
* `waiverActive` is a single PR-wide boolean, not a per-binding
|
|
222
|
+
* pointer/reason lookup — it's the caller-resolved result of
|
|
223
|
+
* `isWaiverLabelActorVerified`, itself a mechanized read of a forge fact.
|
|
224
|
+
* There is no agent-emittable `Doc-waiver:` string anymore; a waiver is
|
|
225
|
+
* either verified true PR-wide, or not.
|
|
226
|
+
*
|
|
227
|
+
* `getDiff` is optional and injectable (unit tests; also absent whenever a
|
|
228
|
+
* caller has no diff-content source) — it resolves the unified diff for one
|
|
229
|
+
* matched code file, feeding the `Doc-neutral:` evidence check below. Its
|
|
230
|
+
* absence never produces a pass: a declared-but-unevidenced neutral claim
|
|
231
|
+
* fails the same as if it evidence didn't check out, preserving silence =
|
|
232
|
+
* failure (state-machine.md Section 15).
|
|
233
|
+
*/
|
|
234
|
+
export function evaluateC5(
|
|
235
|
+
changed: string[],
|
|
236
|
+
docOwnersContent: string | null,
|
|
237
|
+
prBody: string,
|
|
238
|
+
fileExists: (p: string) => boolean,
|
|
239
|
+
waiverActive: boolean,
|
|
240
|
+
getDiff?: (p: string) => string | null
|
|
241
|
+
): C5Result {
|
|
242
|
+
const out: C5Result = { errors: [], notes: [] }
|
|
243
|
+
|
|
244
|
+
if (docOwnersContent === null) return out
|
|
245
|
+
|
|
246
|
+
const { bindings, errors: parseErrors } = parseDocOwners(docOwnersContent)
|
|
247
|
+
for (const e of parseErrors) out.errors.push(e)
|
|
248
|
+
if (bindings.length === 0) return out
|
|
249
|
+
|
|
250
|
+
const codeFiles = changed.filter(isCodeFile)
|
|
251
|
+
const fired: { binding: DocOwnersBinding; matchedFiles: string[] }[] = []
|
|
252
|
+
for (const b of bindings) {
|
|
253
|
+
const re = globToRegex(b.glob)
|
|
254
|
+
const matchedFiles = codeFiles.filter((f) => re.test(f))
|
|
255
|
+
if (matchedFiles.length > 0) fired.push({ binding: b, matchedFiles })
|
|
256
|
+
}
|
|
257
|
+
if (fired.length === 0) return out
|
|
258
|
+
|
|
259
|
+
const acks = readDocAcks(prBody)
|
|
260
|
+
const neutrals = readDocNeutrals(prBody)
|
|
261
|
+
|
|
262
|
+
for (const { binding: b, matchedFiles } of fired) {
|
|
263
|
+
if (waiverActive) {
|
|
264
|
+
out.notes.push(`C5 doc-waiver active for ${b.pointer} (binding ${DOC_OWNERS_PATH}:${b.lineNum})`)
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (isUrlPointer(b.pointer)) {
|
|
269
|
+
const acked = acks.some((a) => a.surface === b.pointer)
|
|
270
|
+
if (!acked) {
|
|
271
|
+
out.errors.push(
|
|
272
|
+
`C5 doc-coverage: code change matched ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}). External pointer requires \`Doc-ack: ${b.pointer} — <note>\` in the PR body, or an actor-verified \`${WAIVER_LABEL}\` label to skip.`
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const pointerPath = pointerToPath(b.pointer)
|
|
279
|
+
if (!fileExists(pointerPath)) {
|
|
280
|
+
out.errors.push(
|
|
281
|
+
`C5 doc-owners-dangling: ${DOC_OWNERS_PATH}:${b.lineNum} points to ${b.pointer}, which does not exist on disk. Fix the binding or add the doc.`
|
|
282
|
+
)
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (changed.includes(pointerPath)) continue
|
|
287
|
+
|
|
288
|
+
const declared = neutrals.find((n) => n.surface === b.pointer)
|
|
289
|
+
if (declared) {
|
|
290
|
+
const evidenced =
|
|
291
|
+
getDiff !== undefined &&
|
|
292
|
+
matchedFiles.every((f) => {
|
|
293
|
+
const diff = getDiff(f)
|
|
294
|
+
return diff !== null && isMechanicallyNeutralDiff(diff, f)
|
|
295
|
+
})
|
|
296
|
+
if (evidenced) {
|
|
297
|
+
out.notes.push(
|
|
298
|
+
`C5 doc-neutral: ${b.pointer} not required for ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\`) — declared neutral ("${declared.note}"), diff of ${matchedFiles.join(', ')} confirmed comment/whitespace-only.`
|
|
299
|
+
)
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
out.errors.push(
|
|
303
|
+
`C5 doc-neutral-unverified: ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}) declared \`Doc-neutral: ${b.pointer} — ${declared.note}\`, but the diff of ${matchedFiles.join(', ')} contains changes beyond comments/whitespace. Update ${pointerPath}, or have a principal apply the \`${WAIVER_LABEL}\` label.`
|
|
304
|
+
)
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
out.errors.push(
|
|
309
|
+
`C5 doc-coverage: code change matched ${DOC_OWNERS_PATH}:${b.lineNum} (glob \`${b.glob}\` → ${b.pointer}), but ${pointerPath} is not in the PR diff. Update it, declare \`Doc-neutral: ${b.pointer} — <why>\` if the change is genuinely mechanically-neutral, or have a principal apply the \`${WAIVER_LABEL}\` label.`
|
|
310
|
+
)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return out
|
|
314
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Doc, DocNav, DocSection } from './types'
|
|
2
|
+
|
|
3
|
+
export type BuildDocNavOptions = {
|
|
4
|
+
sectionOrder?: string[]
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function sectionIdOf(label: string): string {
|
|
8
|
+
return label
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
11
|
+
.replace(/^-+|-+$/g, '')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildDocNav(docs: Doc[], opts: BuildDocNavOptions = {}): DocNav {
|
|
15
|
+
const bySection = new Map<string, Doc[]>()
|
|
16
|
+
for (const doc of docs) {
|
|
17
|
+
const list = bySection.get(doc.section) ?? []
|
|
18
|
+
list.push(doc)
|
|
19
|
+
bySection.set(doc.section, list)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ordered: string[] = []
|
|
23
|
+
if (opts.sectionOrder) {
|
|
24
|
+
for (const label of opts.sectionOrder) if (bySection.has(label)) ordered.push(label)
|
|
25
|
+
}
|
|
26
|
+
for (const label of bySection.keys()) if (!ordered.includes(label)) ordered.push(label)
|
|
27
|
+
|
|
28
|
+
const sections: DocSection[] = ordered.map((label) => {
|
|
29
|
+
const items = (bySection.get(label) ?? []).slice().sort((a, b) => {
|
|
30
|
+
if (a.order !== b.order) return a.order - b.order
|
|
31
|
+
return a.title.localeCompare(b.title)
|
|
32
|
+
})
|
|
33
|
+
return { id: sectionIdOf(label), label, docs: items }
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const flat = sections.flatMap((s) => s.docs)
|
|
37
|
+
return { sections, flat }
|
|
38
|
+
}
|