@attalabs/vinaya 0.22.0 → 0.24.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 +14 -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 +559 -140
- package/dist/checks/bin/check-branch-topology.js +630 -145
- package/dist/checks/bin/check-brief-shape.js +600 -143
- package/dist/checks/bin/check-changeset-coverage.js +942 -162
- package/dist/checks/bin/check-closes-n.js +630 -145
- package/dist/checks/bin/check-coherence.js +641 -148
- package/dist/checks/bin/check-dead-branch-push.js +559 -140
- package/dist/checks/bin/check-dispatch-readiness.js +639 -146
- package/dist/checks/bin/check-doc-coverage-push.js +696 -219
- package/dist/checks/bin/check-doc-coverage.js +696 -219
- package/dist/checks/bin/check-doctrine-no-procedures.js +4800 -0
- package/dist/checks/bin/check-doctrine-portability.js +691 -147
- package/dist/checks/bin/check-evidence-fresh.js +1048 -222
- package/dist/checks/bin/check-exec-bits.js +4937 -0
- package/dist/checks/bin/check-first-push-dispatch.js +630 -145
- package/dist/checks/bin/check-issue-assignment.js +630 -145
- package/dist/checks/bin/check-main-branch-refusal.js +565 -141
- package/dist/checks/bin/check-no-disk-state.js +559 -140
- package/dist/checks/bin/check-pr-report-density.js +4467 -0
- package/dist/checks/bin/check-quoted-command.js +656 -164
- package/dist/checks/bin/check-reader-resolvable-prose.js +652 -163
- package/dist/checks/bin/check-registry-gates.js +602 -145
- package/dist/checks/bin/check-retired-vocabulary.js +652 -163
- package/dist/checks/bin/check-review-gate.js +677 -142
- package/dist/checks/bin/check-single-plan-pr.js +559 -140
- package/dist/checks/bin/check-test-plan.js +560 -141
- package/dist/checks/bin/check-token-collection-wired.js +559 -140
- package/dist/checks/bin/check-token-report.js +559 -140
- package/dist/checks/bin/check-workspace-escape.js +992 -146
- package/dist/index.js +8884 -5627
- 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 +118 -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 +198 -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-render.ts +462 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-validation.ts +912 -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 +806 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/consumer-enumeration.ts +75 -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 +268 -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-no-procedures.ts +94 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +293 -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 +171 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +323 -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 +177 -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-report-density.ts +116 -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 +160 -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 +378 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-status.ts +200 -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 +117 -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 +199 -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]__1pndh3_._.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 +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_projects_[name]_tranches_[slug]_05ufo8i._.js +3 -3
- 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 → 0zebsmmk1bvnb.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 1uogpj5w2n5ju.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.js → 3m1kgax7j2vgs.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/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 → QY2GhiI47-765D9DHGy1I}/_buildManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_clientMiddlewareManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brief→Developer brief-validation grammar (aeg-governance-hardening
|
|
3
|
+
* task 2). Pure — no `fs`, no `fetch`, no `process.env`. The CLI shim
|
|
4
|
+
* (`bin/verify-brief.ts`) reads `PR_BODY`, then
|
|
5
|
+
* calls `checkBriefSections`.
|
|
6
|
+
*
|
|
7
|
+
* Scope is presence-only (per the Planner's trap): this gate cannot judge
|
|
8
|
+
* whether a Test Plan item is truly scriptable, whether `unit-tests-only` is
|
|
9
|
+
* justified by the surface map, or whether a doc-update entry is factually
|
|
10
|
+
* correct — those remain Reviewer + Verification judgment. It only confirms each
|
|
11
|
+
* required brief section exists in the PR body, in the shape the
|
|
12
|
+
* `brief-authoring` skill and `brief-developer` contract define.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { type AnchorField, anchoredRegion, stripCode } from './anchored-region'
|
|
16
|
+
import { parsePremiseBlock, premiseBlockText } from './premise-check'
|
|
17
|
+
import { locateTestPlanSection } from './test-plan-section'
|
|
18
|
+
|
|
19
|
+
export type BriefSectionResult = { status: 'pass' | 'fail'; errors: string[] }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The PR body's header block: everything before the first h2+ heading. The
|
|
23
|
+
* canonical PR-body form (`roles/developer.md`) puts the metadata fields
|
|
24
|
+
* (Tier, For, Project, Closes) at the top, before `## Summary`.
|
|
25
|
+
* Anchoring field extraction here — shared with `archive-task.ts` — is what
|
|
26
|
+
* stops prose in later sections that merely *mentions* a field name (e.g. a
|
|
27
|
+
* "Decisions made" paragraph discussing the `Ticket:` field) from being
|
|
28
|
+
* parsed as the field itself. Regression from #311's first live archivist
|
|
29
|
+
* run, where exactly that happened.
|
|
30
|
+
*/
|
|
31
|
+
export function headerRegion(prBody: string): string {
|
|
32
|
+
const m = prBody.match(/^##\s/m)
|
|
33
|
+
return m?.index !== undefined ? prBody.slice(0, m.index) : prBody
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Tolerant header-field reader: accepts `Field: value` and `**Field:** value`,
|
|
38
|
+
* line-anchored, searched only in the header region. Stops at line end or a
|
|
39
|
+
* `·` metadata separator.
|
|
40
|
+
*
|
|
41
|
+
* When `anchor` is given and the body carries that anchor pair
|
|
42
|
+
* (`anchored-region.ts`, task 30), the pair replaces the header region as the
|
|
43
|
+
* one place the field is read from — a field-shaped line anywhere else in the
|
|
44
|
+
* body is ignored. Bodies without the pair parse exactly as before.
|
|
45
|
+
*/
|
|
46
|
+
function headerField(prBody: string, labelPattern: string, anchor?: AnchorField): string | null {
|
|
47
|
+
const anchored = anchor !== undefined ? anchoredRegion(prBody, anchor) : null
|
|
48
|
+
const region = anchored ?? headerRegion(prBody)
|
|
49
|
+
const re = new RegExp(`^(?:\\*\\*)?\\s*${labelPattern}\\s*(?:\\*\\*)?\\s*:\\s*(?:\\*\\*)?\\s*([^\\n·]+)`, 'im')
|
|
50
|
+
const m = region.match(re)
|
|
51
|
+
if (!m) return null
|
|
52
|
+
const value = (m[1] as string).trim()
|
|
53
|
+
return value.length > 0 ? value : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Strips markdown emphasis markers and collapses whitespace, for tolerant phrase matching. */
|
|
57
|
+
function normalize(text: string): string {
|
|
58
|
+
return text.replace(/[*_]/g, '').replace(/\s+/g, ' ')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function headingCheck(prBody: string, keywordPattern: string, sectionName: string): BriefSectionResult {
|
|
62
|
+
const re = new RegExp(`^#{1,4}\\s*(?:\\*\\*)?(?:\\d+[a-z]?\\.\\s*)?[^\\n]*${keywordPattern}`, 'im')
|
|
63
|
+
if (re.test(prBody)) return { status: 'pass', errors: [] }
|
|
64
|
+
return {
|
|
65
|
+
status: 'fail',
|
|
66
|
+
errors: [`brief-validation ${sectionName}: no "${sectionName}" section found in the PR body.`]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Tier field — reuses the canonical `@attalabs/aeg-core` parser (do not re-implement). */
|
|
71
|
+
export function checkTierField(prBody: string, readTier: (body: string) => 0 | 1 | 3 | null): BriefSectionResult {
|
|
72
|
+
if (readTier(prBody) !== null) return { status: 'pass', errors: [] }
|
|
73
|
+
return {
|
|
74
|
+
status: 'fail',
|
|
75
|
+
errors: [
|
|
76
|
+
'brief-validation tier: no `Tier:` field found in the PR body (expected `Tier: 0|1|3` or `**Tier:** 0|1|3`).'
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The `unit-tests-only` sentinel, tolerating the markdown emphasis every other
|
|
83
|
+
* brief field carries (`**Tier:**`, `**Project:**`, …). All three of
|
|
84
|
+
* `Test Plan: unit-tests-only`, `**Test Plan:** unit-tests-only` and
|
|
85
|
+
* `**Test Plan**: unit-tests-only` match.
|
|
86
|
+
*
|
|
87
|
+
* The bold forms used to fail: the pattern was `/Test Plan\s*:\s*unit-tests-only/i`,
|
|
88
|
+
* and `\s*` cannot cross the `**` sitting between the colon and the value — so
|
|
89
|
+
* `brief-authoring/SKILL.md` §9's own canonical example (`**Test Plan:** unit-tests-only`)
|
|
90
|
+
* was rejected by the gate that documents it, and any brief copying the skill
|
|
91
|
+
* verbatim bounced. Two checks read this sentinel and both were wrong in
|
|
92
|
+
* different directions: `checkTestPlan` false-FAILED a well-formed brief, while
|
|
93
|
+
* `checkTestPlanExclusivity` silently false-PASSED a self-contradictory one (a
|
|
94
|
+
* bolded `unit-tests-only` **plus** tagged checkboxes never tripped it, because
|
|
95
|
+
* its own guard clause never matched either) — the exact combination that guard
|
|
96
|
+
* was built for in #340. Fixing the doc instead of the pattern was rejected:
|
|
97
|
+
* bold is the house convention for brief fields, so an unbolded Test Plan is the
|
|
98
|
+
* odd one out and drifts back the moment someone tidies it.
|
|
99
|
+
*/
|
|
100
|
+
const TEST_PLAN_UNIT_TESTS_ONLY_RE = /(?:\*\*)?Test Plan(?:\*\*)?\s*:\s*(?:\*\*)?\s*unit-tests-only/i
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The Test Plan section text these three checks scan — never the raw body.
|
|
104
|
+
* Delegates to `locateTestPlanSection` (the same locator `test-plan.ts`'s
|
|
105
|
+
* merge-gate check uses) so an `AEG:TEST-PLAN` anchor pair is authoritative
|
|
106
|
+
* here exactly as it already is there. Without this, a body that anchors its
|
|
107
|
+
* real Test Plan AND carries a pasted verbatim reference copy of the brief
|
|
108
|
+
* (the template's own instruction) has its checkbox lines double-counted:
|
|
109
|
+
* `checkTestPlanExclusivity`/`checkPrincipalPlaceholder` scanned the whole
|
|
110
|
+
* body and fired on checkboxes living only in the quoted reference copy,
|
|
111
|
+
* disagreeing with `test-plan.ts` about which bytes are the real field
|
|
112
|
+
* (found live: a well-formed anchored brief failed `brief-shape` while
|
|
113
|
+
* `test-plan` passed the same body). Falls back to the full body when no
|
|
114
|
+
* section is locatable, matching every other anchor consumer's "anchors are
|
|
115
|
+
* additive, never required" contract.
|
|
116
|
+
*/
|
|
117
|
+
function testPlanRegion(prBody: string): string {
|
|
118
|
+
const located = locateTestPlanSection(prBody)
|
|
119
|
+
return located.found ? located.section : prBody
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Test Plan — pass iff the body contains the `Test Plan: unit-tests-only`
|
|
124
|
+
* sentinel (bolded or not — see `TEST_PLAN_UNIT_TESTS_ONLY_RE`), OR at least one
|
|
125
|
+
* `**[agent]**`/`**[principal]**`-tagged checklist line. Presence-only: does not
|
|
126
|
+
* judge whether tags are correctly assigned or whether `unit-tests-only` is
|
|
127
|
+
* justified by the surface map.
|
|
128
|
+
*/
|
|
129
|
+
/**
|
|
130
|
+
* Presence-only Test Plan detector — three acceptable shapes since task 12
|
|
131
|
+
* (#387) rendered the `[agent]` half of a Test Plan as a fenced command list
|
|
132
|
+
* rather than a checkbox: the `unit-tests-only` sentinel; a
|
|
133
|
+
* `**[agent]**`/`**[principal]**`-tagged checkbox line (the pre-#387 shape,
|
|
134
|
+
* still valid on a PR below `AGENT_BOXES_REFUSED_SINCE_PR` and for
|
|
135
|
+
* `[principal]` items on every PR); or a fenced code block anywhere in the
|
|
136
|
+
* Test Plan region (the fenced-command-list shape). This gate does not judge
|
|
137
|
+
* WHICH shape a given PR number must use — `checkNoAgentBoxes`, below, is the
|
|
138
|
+
* rule that refuses a checkbox `[agent]` item on a PR at or above the rollout
|
|
139
|
+
* constant; this one only asks "is there a Test Plan at all."
|
|
140
|
+
*/
|
|
141
|
+
export function checkTestPlan(prBody: string): BriefSectionResult {
|
|
142
|
+
const located = locateTestPlanSection(prBody)
|
|
143
|
+
const region = located.found ? located.section : prBody
|
|
144
|
+
if (TEST_PLAN_UNIT_TESTS_ONLY_RE.test(region)) return { status: 'pass', errors: [] }
|
|
145
|
+
if (/\*\*\[(?:agent|principal)\]\*\*/.test(region)) return { status: 'pass', errors: [] }
|
|
146
|
+
// Fenced-block detection only counts when the section was genuinely
|
|
147
|
+
// LOCATED, never on the whole-body fallback: `testPlanRegion`'s fallback
|
|
148
|
+
// exists so the sentinel/tag checks above still work on a body with no
|
|
149
|
+
// recognizable heading, but a fence can appear anywhere in a brief (Step
|
|
150
|
+
// 0's own worktree command, a Part's own command block) — trusting ANY
|
|
151
|
+
// fence in the whole body would pass a brief whose Test Plan section was
|
|
152
|
+
// deleted outright, as long as it kept some unrelated fence elsewhere
|
|
153
|
+
// (found live, fixing this same rule: `checkBriefSections`'s own "missing
|
|
154
|
+
// Test Plan" regression fixture stopped failing until this guard landed).
|
|
155
|
+
if (located.found && extractFencedBlocks(region).length > 0) return { status: 'pass', errors: [] }
|
|
156
|
+
return {
|
|
157
|
+
status: 'fail',
|
|
158
|
+
errors: [
|
|
159
|
+
'brief-validation Test Plan: no Test Plan section found — expected `Test Plan: unit-tests-only`, a fenced `[agent]` command list, or at least one `**[agent]**`/`**[principal]**`-tagged checklist item.'
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Test Plan shape guard (aeg-governance-hardening task 20 follow-up, #340) —
|
|
166
|
+
* `Test Plan: unit-tests-only` and a tagged checkbox item are mutually
|
|
167
|
+
* exclusive per brief-authoring §9 ("The two fields are coupled; Brief
|
|
168
|
+
* Validation cross-checks them"): `unit-tests-only` declares there is no
|
|
169
|
+
* checklist because there is nothing runtime to check off, so a body
|
|
170
|
+
* carrying both is self-contradictory. Regression source: PR #363's own
|
|
171
|
+
* original brief declared `unit-tests-only` while its Test Plan also listed
|
|
172
|
+
* `- [x]`/`- [ ]` `[agent]`/`[principal]` items — a combination this gate
|
|
173
|
+
* previously let through.
|
|
174
|
+
*/
|
|
175
|
+
export function checkTestPlanExclusivity(prBody: string): BriefSectionResult {
|
|
176
|
+
const region = testPlanRegion(prBody)
|
|
177
|
+
if (!TEST_PLAN_UNIT_TESTS_ONLY_RE.test(region)) return { status: 'pass', errors: [] }
|
|
178
|
+
if (/^-\s*\[[ xX]\]\s*\*{2}\[(?:agent|principal)\]\*{2}/im.test(region)) {
|
|
179
|
+
return {
|
|
180
|
+
status: 'fail',
|
|
181
|
+
errors: [
|
|
182
|
+
'brief-validation Test Plan shape: `Test Plan: unit-tests-only` and a `- [ ]`/`- [x]` tagged checkbox item are mutually exclusive (brief-authoring §9) — declare one form, not both.'
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { status: 'pass', errors: [] }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Principal-placeholder guard (aeg-governance-hardening task 20 follow-up,
|
|
191
|
+
* #340) — a `**[principal]**` checkbox item whose content is a
|
|
192
|
+
* none-placeholder ("None — …") is untickable by construction: nobody can
|
|
193
|
+
* check a box asserting there is nothing to verify, so it blocks the merge
|
|
194
|
+
* gate forever. If a brief genuinely has no principal-runnable surface, the
|
|
195
|
+
* item must be omitted entirely, not declared and left permanently unticked.
|
|
196
|
+
*/
|
|
197
|
+
export function checkPrincipalPlaceholder(prBody: string): BriefSectionResult {
|
|
198
|
+
const region = testPlanRegion(prBody)
|
|
199
|
+
const lineRe = /^-\s*\[[ xX]\]\s*\*{2}\[principal\]\*{2}(.*)$/gim
|
|
200
|
+
for (const m of region.matchAll(lineRe)) {
|
|
201
|
+
const content = m[1] ?? ''
|
|
202
|
+
if (/^\s*None\b/i.test(content)) {
|
|
203
|
+
return {
|
|
204
|
+
status: 'fail',
|
|
205
|
+
errors: [
|
|
206
|
+
'brief-validation Test Plan shape: a `**[principal]**` checkbox item is a "None" placeholder — if there is no principal-runnable surface, omit the item entirely; an untickable placeholder box blocks the merge gate forever.'
|
|
207
|
+
]
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { status: 'pass', errors: [] }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Premise coverage (this task, aeg-governance-hardening 11, #324) — pass iff
|
|
216
|
+
* either (a) at least one `Premise:` assertion's path matches a file in
|
|
217
|
+
* `surfaceFiles` (the §4 surface map's file list — in practice, the PR's
|
|
218
|
+
* actual changed-file list, the same diff-derived-truth philosophy
|
|
219
|
+
* `deriveTierFromDiff` already uses), or (b) `surfaceFiles` is empty (a
|
|
220
|
+
* Tier 0 brief with zero runtime/code surface has nothing to pin — mirrors
|
|
221
|
+
* `checkTestPlan`'s `unit-tests-only` exemption). Presence-only: does not
|
|
222
|
+
* judge whether the pinned premise is the *right* one to have pinned.
|
|
223
|
+
*/
|
|
224
|
+
export function checkPremiseCoverage(prBody: string, surfaceFiles: string[]): BriefSectionResult {
|
|
225
|
+
if (surfaceFiles.length === 0) return { status: 'pass', errors: [] }
|
|
226
|
+
const assertions = parsePremiseBlock(prBody)
|
|
227
|
+
if (assertions.some((a) => surfaceFiles.includes(a.path))) return { status: 'pass', errors: [] }
|
|
228
|
+
return {
|
|
229
|
+
status: 'fail',
|
|
230
|
+
errors: [
|
|
231
|
+
'brief-validation Premise: no `Premise:` assertion found whose path matches a file in the surface map — a brief with a real code surface must pin at least one premise (aeg-governance-hardening task 11).'
|
|
232
|
+
]
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function checkSurfaceMap(prBody: string): BriefSectionResult {
|
|
237
|
+
return headingCheck(prBody, '(?:technical\\s+)?surface map', 'Technical surface map')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function checkDocUpdateList(prBody: string): BriefSectionResult {
|
|
241
|
+
return headingCheck(prBody, '(?:documentation|doc)[- ]update(?:\\s+list)?', 'Documentation-update list')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function checkWorktreeStep0(prBody: string): BriefSectionResult {
|
|
245
|
+
if (/git worktree add/.test(prBody)) return { status: 'pass', errors: [] }
|
|
246
|
+
return {
|
|
247
|
+
status: 'fail',
|
|
248
|
+
errors: ['brief-validation worktree Step 0: no `git worktree add` command found in the PR body.']
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function checkStopConditions(prBody: string): BriefSectionResult {
|
|
253
|
+
return headingCheck(prBody, 'stop conditions', 'Stop conditions')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Autonomy clause — matches the standing clause's core phrase, case-insensitively,
|
|
258
|
+
* tolerant of whitespace and emphasis markup (`**Autonomy:**` vs `Autonomy:`).
|
|
259
|
+
* Per the Planner's trap, this is deliberately verbatim-ish rather than a loose
|
|
260
|
+
* "mentions autonomy somewhere" match — the phrase is the actual invariant.
|
|
261
|
+
*/
|
|
262
|
+
export function checkAutonomyClause(prBody: string): BriefSectionResult {
|
|
263
|
+
const normalized = normalize(prBody).toLowerCase()
|
|
264
|
+
if (/do not stop to ask clarifying questions/.test(normalized)) return { status: 'pass', errors: [] }
|
|
265
|
+
return {
|
|
266
|
+
status: 'fail',
|
|
267
|
+
errors: [
|
|
268
|
+
'brief-validation autonomy clause: the standing autonomy clause ("Do not stop to ask clarifying questions...") was not found in the PR body.'
|
|
269
|
+
]
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Project field — required in a multi-project repo (`brief-authoring` skill,
|
|
275
|
+
* `planner-brief` contract: the field routes the Developer to the right specs
|
|
276
|
+
* and tells the Reviewer whose behavior to verify). Must appear in the header
|
|
277
|
+
* block, where the Archivist's provenance assembly also reads it — gate and
|
|
278
|
+
* archivist share `headerRegion`, so a body that passes this gate can never
|
|
279
|
+
* produce a DANGLING Project field in provenance. Added after #311 merged
|
|
280
|
+
* without it: the Developer satisfied exactly the sections this gate checked
|
|
281
|
+
* and dropped everything it didn't — whatever the gate doesn't enforce, agents
|
|
282
|
+
* will eventually omit (contract-gate parity is the fix, not discipline).
|
|
283
|
+
*/
|
|
284
|
+
export function checkProjectField(prBody: string): BriefSectionResult {
|
|
285
|
+
if (headerField(prBody, 'Project(?:\\(s\\))?', 'PROJECT') !== null) return { status: 'pass', errors: [] }
|
|
286
|
+
return {
|
|
287
|
+
status: 'fail',
|
|
288
|
+
errors: [
|
|
289
|
+
'brief-validation Project: no `Project:` field found in the PR body header block (before the first `##` heading). Required per the brief-developer contract — expected `Project: <name>[, <name>]` or `**Project:** …`.'
|
|
290
|
+
]
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* For/model field — the brief's mandatory `For:` header (`brief-authoring`
|
|
296
|
+
* skill: "the `For:` + `Reason:` lines are mandatory"). AEG forbids
|
|
297
|
+
* commit-trailer attribution, so this line is the provenance block's only
|
|
298
|
+
* source for Model/agent — same headerRegion parity rationale as
|
|
299
|
+
* `checkProjectField`.
|
|
300
|
+
*/
|
|
301
|
+
export function checkForField(prBody: string): BriefSectionResult {
|
|
302
|
+
if (headerField(prBody, 'For') !== null) return { status: 'pass', errors: [] }
|
|
303
|
+
return {
|
|
304
|
+
status: 'fail',
|
|
305
|
+
errors: [
|
|
306
|
+
'brief-validation For: no `For:` field found in the PR body header block (before the first `##` heading). Required per the brief-authoring skill — expected `For: <model + environment>` or `**For:** …`.'
|
|
307
|
+
]
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* `Closes #N` gate — the match runs on **code-stripped** body text (fenced
|
|
313
|
+
* blocks + inline spans removed via `stripCode`), so it agrees byte-for-byte
|
|
314
|
+
* with GitHub's own auto-close parser, which also ignores `Closes #N` inside
|
|
315
|
+
* code. Without the strip, a body whose only closing reference is backticked
|
|
316
|
+
* (`` `Closes #600` ``) passed this gate green yet merged **without** closing
|
|
317
|
+
* its Issue — stranding #600 (PR #608) and #601 (PR #611) and reddening every
|
|
318
|
+
* open PR via the A3 `auto-close-misfire` oracle. "verify-docs green" must
|
|
319
|
+
* imply "GitHub will auto-close"; stripping code here is what makes it so.
|
|
320
|
+
*
|
|
321
|
+
* The separator groups are **bounded** (`\s{0,8}`, not `\s*`). Two adjacent
|
|
322
|
+
* unbounded `\s*` around an optional `:` backtrack quadratically on a body of
|
|
323
|
+
* the shape `closes` + long whitespace + no `#` — ~2.65 s at GitHub's
|
|
324
|
+
* 65,536-char body cap, and this function runs the pattern twice on the fail
|
|
325
|
+
* path (PR #617 security pass). A constant bound makes the work per start
|
|
326
|
+
* position constant, so the scan is linear in body length. Eight is far past
|
|
327
|
+
* any real separator; a body needing more is malformed by the brief's own
|
|
328
|
+
* convention (a bare ref inside the `AEG:CLOSES` anchor) and fails with an
|
|
329
|
+
* actionable message rather than silently stranding its Issue.
|
|
330
|
+
*/
|
|
331
|
+
export function checkClosesN(prBody: string): BriefSectionResult {
|
|
332
|
+
const closesPattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s{0,8}:?\s{0,8}#\d+/i
|
|
333
|
+
if (closesPattern.test(stripCode(prBody))) {
|
|
334
|
+
return { status: 'pass', errors: [] }
|
|
335
|
+
}
|
|
336
|
+
// Distinguish "only inside code" (actionable — move it out) from "absent entirely".
|
|
337
|
+
if (closesPattern.test(prBody)) {
|
|
338
|
+
return {
|
|
339
|
+
status: 'fail',
|
|
340
|
+
errors: [
|
|
341
|
+
"brief-validation Closes #N: `Closes #N` found only inside a code span — GitHub won't auto-close it. Put a bare `Closes #N` on its own line inside the `AEG:CLOSES` anchor."
|
|
342
|
+
]
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
status: 'fail',
|
|
347
|
+
errors: ['brief-validation Closes #N: no `Closes #<N>` (or Fixes/Resolves) reference found in the PR body.']
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The commit-type vocabulary this repo enforces — the commitlint type set
|
|
353
|
+
* plus `Plan` (plan PRs; deliberately excluded from the separate
|
|
354
|
+
* `vinaya/type:*` label axis, since a commit type and a task type are
|
|
355
|
+
* different things — see `packages/aeg-forge-state/src/labels.ts`). The one
|
|
356
|
+
* list: `COMMIT_TYPE_STYLE` below is derived from it, and the `commit-msg`
|
|
357
|
+
* hook (`apps/cli/src/commands/commit-msg.ts`) reads `COMMIT_TYPES` for its
|
|
358
|
+
* own error text — neither carries a second, independently-typed copy.
|
|
359
|
+
*/
|
|
360
|
+
export const COMMIT_TYPES = [
|
|
361
|
+
'Build',
|
|
362
|
+
'Chore',
|
|
363
|
+
'Docs',
|
|
364
|
+
'Feat',
|
|
365
|
+
'Fix',
|
|
366
|
+
'Perf',
|
|
367
|
+
'Plan',
|
|
368
|
+
'Refactor',
|
|
369
|
+
'Revert',
|
|
370
|
+
'Style',
|
|
371
|
+
'Test'
|
|
372
|
+
] as const
|
|
373
|
+
|
|
374
|
+
export const COMMIT_TYPE_STYLE = new RegExp(`^(${COMMIT_TYPES.join('|')})(\\([a-z0-9-]+\\))?: \\S`)
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Forge-title grammar — the two title forms this repo actually uses:
|
|
378
|
+
* 1. Commit-style: `Type: description` or `Type(scope): description`, with
|
|
379
|
+
* the commitlint type set plus `Plan` (plan PRs).
|
|
380
|
+
* 2. Task-style: `[tranche] id — description` (task Issues and task PRs).
|
|
381
|
+
* Anything else is refused: titles ride into merge commits and the forge's
|
|
382
|
+
* derived views, so they carry the same grammar obligation as commit messages
|
|
383
|
+
* (husky/commitlint parity, applied at the wrapper).
|
|
384
|
+
*/
|
|
385
|
+
export function checkForgeTitle(title: string): BriefSectionResult {
|
|
386
|
+
const taskStyle = /^\[[a-z0-9._-]+\] \S+ — \S/
|
|
387
|
+
if (COMMIT_TYPE_STYLE.test(title) || taskStyle.test(title)) return { status: 'pass', errors: [] }
|
|
388
|
+
return {
|
|
389
|
+
status: 'fail',
|
|
390
|
+
errors: [
|
|
391
|
+
`brief-validation title: "${title}" matches neither title grammar — expected \`Type: description\` / \`Type(scope): description\` (commitlint types + Plan) or \`[tranche] id — description\` (task form).`
|
|
392
|
+
]
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Plan-PR Closes guard — a `plan/*` PR body must never carry a
|
|
398
|
+
* `Closes #N` reference. Three confirmed live incidents (#294→#293,
|
|
399
|
+
* #298→#297, #288→#287) show a plan PR's `Closes #N` prematurely closing
|
|
400
|
+
* the task Issue when the *plan* merged — before the task itself ever ran.
|
|
401
|
+
* `roles/planner.md`'s Plan-PR close-out section already forbids this in
|
|
402
|
+
* prose ("a plan PR creates Issues; it does not resolve one"); this is the
|
|
403
|
+
* mechanical gate. Separate from `checkBriefSections` (it needs the branch,
|
|
404
|
+
* not just the body, and it's a guard against a forbidden shape, not a
|
|
405
|
+
* presence check) — the shim runs it before the non-task-branch bypass, since
|
|
406
|
+
* a `plan/*` branch would otherwise never reach a brief-shape check at all.
|
|
407
|
+
* Plan branches without `Closes` continue to bypass, as today.
|
|
408
|
+
*/
|
|
409
|
+
export function checkPlanPrNoCloses(branch: string, prBody: string): BriefSectionResult {
|
|
410
|
+
if (!branch.startsWith('plan/')) return { status: 'pass', errors: [] }
|
|
411
|
+
if (!/\bcloses\s+#\d+/i.test(prBody)) return { status: 'pass', errors: [] }
|
|
412
|
+
return {
|
|
413
|
+
status: 'fail',
|
|
414
|
+
errors: [
|
|
415
|
+
'brief-validation plan-PR guard: a `plan/*` PR must not carry `Closes #N` — a plan PR creates Issues, it does not resolve one (roles/planner.md). Remove the `Closes #N` reference; the task Issue closes from the task PR that does the work.'
|
|
416
|
+
]
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The four markers that make a body *a brief* rather than an ordinary PR
|
|
422
|
+
* description. Each is an existing section detector, reused unchanged — so a
|
|
423
|
+
* body that trips this detector is, by construction, a body the section checks
|
|
424
|
+
* were written to grade.
|
|
425
|
+
*
|
|
426
|
+
* Test Plan is deliberately **not** a marker even though it is a required brief
|
|
427
|
+
* section: the canonical PR-body template gives every AEG PR a Test plan with
|
|
428
|
+
* `**[agent]**` tags, so it is the one section an ordinary non-brief PR
|
|
429
|
+
* plausibly carries. The four kept here are brief grammar and nothing else — a
|
|
430
|
+
* dependency bump has none of them, and needing *two* means one stray phrase
|
|
431
|
+
* can never force a brief onto a PR that was never meant to carry one.
|
|
432
|
+
*/
|
|
433
|
+
const BRIEF_SHAPE_MARKERS = [checkSurfaceMap, checkDocUpdateList, checkStopConditions, checkAutonomyClause] as const
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* A task branch, per the topology naming convention (`task/<tranche>/<n>`).
|
|
437
|
+
* The one shared copy — `bin/verify-brief.ts`, `test-plan-gate.ts`, and
|
|
438
|
+
* `archive-task.ts` each still define this pattern locally (out of this
|
|
439
|
+
* export's blast radius; not deduped onto it here), but a new consumer
|
|
440
|
+
* (`check-brief-shape.ts`, #870) reuses this one rather than adding a
|
|
441
|
+
* fourth copy.
|
|
442
|
+
*/
|
|
443
|
+
const TASK_BRANCH_PATTERN = /^task\/[^/]+\/[^/]+$/
|
|
444
|
+
|
|
445
|
+
export function isTaskBranch(branch: string): boolean {
|
|
446
|
+
return TASK_BRANCH_PATTERN.test(branch)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Is this PR body a brief? — the predicate that replaces the branch name as
|
|
451
|
+
* `verify-brief`'s trigger for running `checkBriefSections`.
|
|
452
|
+
*
|
|
453
|
+
* The old rule was "validate iff the branch is `task/<tranche>/<n>`", which meant a
|
|
454
|
+
* standalone `fix/*` brief bypassed **every** section check. Confirmed live: a
|
|
455
|
+
* fix brief on `fix/studio-tranche-href` shipped with no §7
|
|
456
|
+
* documentation-update list, and `checkDocUpdateList` — the checker that exists
|
|
457
|
+
* for exactly that — never ran, because the branch wasn't a task branch. The
|
|
458
|
+
* exemption still has to exist (an ordinary one-line dependency-bump PR has no
|
|
459
|
+
* brief and must not be forced to grow one), so the rule becomes: *if a body is
|
|
460
|
+
* a brief, it must be a complete brief, whatever the branch is called.*
|
|
461
|
+
*
|
|
462
|
+
* Detection runs on `stripCode(prBody)` (the single shared stripper from
|
|
463
|
+
* `anchored-region`, per #617's "one stripper, never a duplicated regex"). A PR
|
|
464
|
+
* that *quotes* a brief inside a fence — "here's a sample brief: ``` …Technical
|
|
465
|
+
* surface map… ```" — is discussing a brief, not carrying one, and must stay
|
|
466
|
+
* exempt; matching on raw text would force-validate it. Note this also rules out
|
|
467
|
+
* the worktree `git worktree add … -b` line as a marker: Step 0 lives inside a
|
|
468
|
+
* fence in every real brief, so it never survives the strip.
|
|
469
|
+
*
|
|
470
|
+
* Threshold is ≥2 of four rather than any-one so that a brief missing one
|
|
471
|
+
* section is still detected as a brief — the failure mode this gate exists to
|
|
472
|
+
* catch. The `fix/studio-tranche-href` body trips three with §7 absent.
|
|
473
|
+
*/
|
|
474
|
+
export function isBriefShaped(prBody: string): boolean {
|
|
475
|
+
const stripped = stripCode(prBody)
|
|
476
|
+
return BRIEF_SHAPE_MARKERS.filter((check) => check(stripped).status === 'pass').length >= 2
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The branch a brief declares for itself, read from its Step 0 command. At
|
|
481
|
+
* authoring time there is no `BRANCH` env var and no branch yet — but every
|
|
482
|
+
* brief carries `git worktree add <path> -b <branch> origin/main`, so the brief
|
|
483
|
+
* states what it is going to be, and `verify-brief --body-file` can grade it as
|
|
484
|
+
* that. Matched on the RAW body, not `stripCode`'s output: Step 0 lives inside a
|
|
485
|
+
* fence in every real brief, so the stripped text never contains it.
|
|
486
|
+
*/
|
|
487
|
+
export function inferBranchFromBody(prBody: string): string {
|
|
488
|
+
const m = prBody.match(/git worktree add\s+\S+\s+-b\s+(\S+)/)
|
|
489
|
+
return m?.[1] ?? ''
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The shell-command-word vocabulary this task's two prose-shape rules share
|
|
494
|
+
* (`checkCommandsCarryOutput` and `doctrine-no-procedures.ts`'s sweep) — one
|
|
495
|
+
* list, so the two rules can never silently diverge on what counts as "a
|
|
496
|
+
* command". Exported for that reuse, not for callers to extend at runtime.
|
|
497
|
+
*/
|
|
498
|
+
export const COMMAND_WORDS = ['export', 'bun', 'gh', 'git', 'grep', 'sed', 'cat', 'diff', 'vinaya'] as const
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Rollout PR number for the four brief-shape rules this task added
|
|
502
|
+
* (`checkNoUnpinnedCodeClaims`, `checkCommandsCarryOutput`,
|
|
503
|
+
* `checkConsumerTests`, `checkDefeatCases`). A PR numbered below this is
|
|
504
|
+
* grandfathered: the CI shim (`check-brief-shape.ts`) reports a finding from
|
|
505
|
+
* one of these four rules as informational, never a failure. A PR at or
|
|
506
|
+
* above it is held to the rule for real. `verify-brief.ts` (authoring time,
|
|
507
|
+
* pre-dispatch) has no PR number and applies all four unconditionally —
|
|
508
|
+
* grandfathering is a CI-only rollout concern, not a grammar relaxation
|
|
509
|
+
* (round-2 ruling addendum 1).
|
|
510
|
+
*/
|
|
511
|
+
export const BRIEF_RULES_SINCE_PR = 394
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Rollout PR number for `checkNoAgentBoxes`, below — a second, distinct
|
|
515
|
+
* cutover from `BRIEF_RULES_SINCE_PR` above, per the Principal's ruling
|
|
516
|
+
* (2026-09-03, after PR #395): an agent never ticks a box or edits a PR
|
|
517
|
+
* body, so the `[agent]` half of a Test Plan stops being checkboxes and
|
|
518
|
+
* becomes a fenced command list (task 12, #387). A PR numbered below this
|
|
519
|
+
* is grandfathered — its checkbox `[agent]` items were written before the
|
|
520
|
+
* ruling and are reported informationally, never a failure. `verify-brief.ts`
|
|
521
|
+
* (authoring time, pre-dispatch) has no PR number and applies the rule
|
|
522
|
+
* unconditionally, same grandfathering-is-CI-only posture as
|
|
523
|
+
* `BRIEF_RULES_SINCE_PR`.
|
|
524
|
+
*/
|
|
525
|
+
export const AGENT_BOXES_REFUSED_SINCE_PR = 396
|
|
526
|
+
|
|
527
|
+
const AGENT_BOX_LINE_RE = /^-\s*\[[ xX]\]\s*\*{2}\[agent\]\*{2}/im
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Refuses a Test Plan whose `[agent]` half is still a checkbox item — the
|
|
531
|
+
* shape the fenced-command-list rule (task 12, #387) replaces. Presence-only
|
|
532
|
+
* within the Test Plan region (`testPlanRegion`), like every sibling check
|
|
533
|
+
* in this file: whether the fenced list a body carries instead is any GOOD
|
|
534
|
+
* is a Reviewer/Verification judgment, not this gate's.
|
|
535
|
+
*/
|
|
536
|
+
export function checkNoAgentBoxes(prBody: string): BriefSectionResult {
|
|
537
|
+
const region = testPlanRegion(prBody)
|
|
538
|
+
if (!AGENT_BOX_LINE_RE.test(region)) return { status: 'pass', errors: [] }
|
|
539
|
+
return {
|
|
540
|
+
status: 'fail',
|
|
541
|
+
errors: [
|
|
542
|
+
'brief-validation no agent boxes: the Test Plan carries a checkbox `[agent]` item — an agent never ticks a box or edits a PR body (Principal ruling, 2026-09-03, after PR #395). Render the `[agent]` half as a fenced command list instead; `vinaya pr report` runs each command from the PR head and writes its actual output into the AEG:EVIDENCE block. `[principal]` checkboxes are unaffected.'
|
|
543
|
+
]
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* True for an error message produced by one of the five grandfatherable
|
|
549
|
+
* rules below (the original four, plus `checkNoAgentBoxes`) — classified by
|
|
550
|
+
* each rule's own distinct message prefix, since `checkBriefSections`
|
|
551
|
+
* aggregates every sub-check's errors into one flat `string[]` and this is
|
|
552
|
+
* the only reader that ever needs to tell them apart from the rest.
|
|
553
|
+
*/
|
|
554
|
+
export function isGrandfatherableBriefRuleError(message: string): boolean {
|
|
555
|
+
return rolloutThresholdFor(message) !== null
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* The rollout constant a given error message is grandfathered against, or
|
|
560
|
+
* `null` for a message this rollout scheme does not cover (always blocking,
|
|
561
|
+
* on every PR). Two thresholds exist today (`BRIEF_RULES_SINCE_PR`,
|
|
562
|
+
* `AGENT_BOXES_REFUSED_SINCE_PR`) — a message is matched to whichever rule
|
|
563
|
+
* produced it, never a single global cutover, so a later third threshold
|
|
564
|
+
* can be added here without touching either existing one's grandfather
|
|
565
|
+
* window.
|
|
566
|
+
*/
|
|
567
|
+
function rolloutThresholdFor(message: string): number | null {
|
|
568
|
+
if (
|
|
569
|
+
message.startsWith('brief-validation unpinned code claim:') ||
|
|
570
|
+
message.startsWith('brief-validation commands carry output:') ||
|
|
571
|
+
message.startsWith('brief-validation consumer tests:') ||
|
|
572
|
+
message.startsWith('brief-validation defeat cases:')
|
|
573
|
+
) {
|
|
574
|
+
return BRIEF_RULES_SINCE_PR
|
|
575
|
+
}
|
|
576
|
+
if (message.startsWith('brief-validation no agent boxes:')) return AGENT_BOXES_REFUSED_SINCE_PR
|
|
577
|
+
return null
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Splits `checkBriefSections`'s flat error list into `blocking` (fails the
|
|
582
|
+
* check) and `info` (printed, never a failure) — the CI shim
|
|
583
|
+
* (`check-brief-shape.ts`) is the only caller, but the split is a pure
|
|
584
|
+
* function of `(errors, prNumber)` so it is unit-testable directly.
|
|
585
|
+
*
|
|
586
|
+
* `prNumber === null` (no `PR_NUMBER`, or an unparseable one) is NOT
|
|
587
|
+
* grandfathered — only a real, parsed number below a message's own
|
|
588
|
+
* threshold (`rolloutThresholdFor`) is. Fail-closed: a check that can't tell
|
|
589
|
+
* which PR it's grading must not quietly waive rules it has no number to
|
|
590
|
+
* check against.
|
|
591
|
+
*/
|
|
592
|
+
export function partitionBriefErrorsByRollout(
|
|
593
|
+
errors: string[],
|
|
594
|
+
prNumber: number | null
|
|
595
|
+
): { blocking: string[]; info: string[] } {
|
|
596
|
+
const blocking: string[] = []
|
|
597
|
+
const info: string[] = []
|
|
598
|
+
for (const e of errors) {
|
|
599
|
+
const threshold = rolloutThresholdFor(e)
|
|
600
|
+
const grandfathered = threshold !== null && prNumber !== null && prNumber < threshold
|
|
601
|
+
;(grandfathered ? info : blocking).push(e)
|
|
602
|
+
}
|
|
603
|
+
return { blocking, info }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* One fenced block's raw span, content, and language tag — `start`/`end` are
|
|
608
|
+
* char offsets into the original text. `lang` is the fence's info-string,
|
|
609
|
+
* lowercased and trimmed to its first word (` ```ts ` → `'ts'`, ` ``` ` →
|
|
610
|
+
* `''`) — `doctrine-no-procedures.ts` uses it to exempt a non-shell block
|
|
611
|
+
* (e.g. a TypeScript illustration whose two `export` lines are not a
|
|
612
|
+
* command sequence, task 10 round-2 ruling item 2).
|
|
613
|
+
*/
|
|
614
|
+
export type FencedBlock = { start: number; end: number; lang: string; content: string }
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Every fenced (``` or ~~~) code block in `text`, in document order, with its
|
|
618
|
+
* raw content, language tag, and char-offset span. Tolerant of the 3-space
|
|
619
|
+
* list-item indentation every brief's own numbered steps use (` \`\`\``) —
|
|
620
|
+
* the fence marker need not sit at column 0. Not a full CommonMark
|
|
621
|
+
* implementation (no nested-fence-length edge cases beyond "the closer
|
|
622
|
+
* repeats the opener's exact run"), which this repo's own doctrine/brief
|
|
623
|
+
* prose never exercises.
|
|
624
|
+
*
|
|
625
|
+
* Shared by `checkCommandsCarryOutput` (below) and `doctrine-no-procedures.ts`
|
|
626
|
+
* — one fence scanner, never a second copy of this pattern.
|
|
627
|
+
*/
|
|
628
|
+
export function extractFencedBlocks(text: string): FencedBlock[] {
|
|
629
|
+
const re = /^[ \t]*(`{3,}|~{3,})([^\n]*)\n([\s\S]*?)^[ \t]*\1[ \t]*$/gm
|
|
630
|
+
const blocks: FencedBlock[] = []
|
|
631
|
+
let m: RegExpExecArray | null = re.exec(text)
|
|
632
|
+
while (m !== null) {
|
|
633
|
+
const lang = (m[2] as string).trim().toLowerCase().split(/\s+/)[0] ?? ''
|
|
634
|
+
blocks.push({ start: m.index, end: m.index + m[0].length, lang, content: m[3] as string })
|
|
635
|
+
m = re.exec(text)
|
|
636
|
+
}
|
|
637
|
+
return blocks
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function firstNonBlankLine(text: string): string {
|
|
641
|
+
const line = text.split('\n').find((l) => l.trim().length > 0)
|
|
642
|
+
return line?.trim() ?? ''
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* A file-and-line reference — `<path>.<ext>:<digits>` — the shape a brief
|
|
647
|
+
* states a code fact by pointer instead of by an executed command or a
|
|
648
|
+
* `Premise:` pin. Extension list is this repo's own doctrine/code file kinds;
|
|
649
|
+
* deliberately not "any word after a dot" (that would also catch a semver
|
|
650
|
+
* string or a decimal figure with a trailing count).
|
|
651
|
+
*/
|
|
652
|
+
const FILE_LINE_RE = /\b[\w./-]+\.(?:tsx?|jsx?|mjs|cjs|md|mdx|json|ya?ml|sh|py|toml):\d+\b/g
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Rule (i) (task 10, Issue #385) — a brief states no code fact as a bare
|
|
656
|
+
* `file:line` prose pointer; it either pins the fact in `Premise:` (re-
|
|
657
|
+
* asserted at dispatch, `verify-dispatch --premise`) or shows the executed
|
|
658
|
+
* command whose output names the line. A pointer that survives outside both
|
|
659
|
+
* homes is a claim nobody re-checks — exactly the class task 9's rule
|
|
660
|
+
* forbids in prose (PR #382's `security-archivist.md:88` mention, backticked
|
|
661
|
+
* but never pinned nor shown as command output).
|
|
662
|
+
*
|
|
663
|
+
* Scans `stripCode(…, { inlineSpans: 'keep' })`: fenced/indented **blocks**
|
|
664
|
+
* are removed (a worked example's own line numbers are not a live claim),
|
|
665
|
+
* but a single-backtick inline span is left as literal text — the PR #382
|
|
666
|
+
* sentence was exactly an inline span, and this rule must still catch it.
|
|
667
|
+
* This is why `maskCode` (which blanks inline spans too) is the wrong tool
|
|
668
|
+
* here, unlike `checkClosesN`/`isBriefShaped` elsewhere in this file.
|
|
669
|
+
*/
|
|
670
|
+
export function checkNoUnpinnedCodeClaims(prBody: string): BriefSectionResult {
|
|
671
|
+
const premiseText = premiseBlockText(prBody)
|
|
672
|
+
const withoutPremise = premiseText ? prBody.replace(premiseText, '') : prBody
|
|
673
|
+
const scanned = stripCode(withoutPremise, { inlineSpans: 'keep' })
|
|
674
|
+
const matches = scanned.match(FILE_LINE_RE) ?? []
|
|
675
|
+
if (matches.length === 0) return { status: 'pass', errors: [] }
|
|
676
|
+
return {
|
|
677
|
+
status: 'fail',
|
|
678
|
+
errors: matches.map(
|
|
679
|
+
(m) =>
|
|
680
|
+
`brief-validation unpinned code claim: "${m}" states a code fact by file-and-line reference outside a \`Premise:\` pin and outside a fenced code block — pin it in \`Premise:\`, or show the executed command and its output instead.`
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Section `num`'s own text (the heading line's content, ending at the next
|
|
687
|
+
* heading of any level 1-4) — `§4`/`§5`/`§6` share this numbered-heading
|
|
688
|
+
* shape with `headingCheck`'s keyword form, so this mirrors that pattern
|
|
689
|
+
* rather than inventing a second heading grammar. `null` when no such
|
|
690
|
+
* section exists (the composer's other checks already judge whether a
|
|
691
|
+
* required section is missing; these two rules apply only when it is
|
|
692
|
+
* present).
|
|
693
|
+
*/
|
|
694
|
+
function extractNumberedSection(prBody: string, num: number): string | null {
|
|
695
|
+
const startRe = new RegExp(`^#{1,4}\\s*(?:\\*\\*)?${num}[a-z]?\\.\\s`, 'im')
|
|
696
|
+
const start = startRe.exec(prBody)
|
|
697
|
+
if (!start) return null
|
|
698
|
+
const afterStart = start.index + start[0].length
|
|
699
|
+
const rest = prBody.slice(afterStart)
|
|
700
|
+
const next = /^#{1,4}\s/m.exec(rest)
|
|
701
|
+
return next ? rest.slice(0, next.index) : rest
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function commandWordOf(line: string): string {
|
|
705
|
+
return line.trim().split(/\s+/)[0] ?? ''
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function isCommandBlock(content: string): boolean {
|
|
709
|
+
return (COMMAND_WORDS as readonly string[]).includes(commandWordOf(firstNonBlankLine(content)))
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** The Step 0 exemption — `git worktree add …` is a command with no separate output block by convention. */
|
|
713
|
+
function isStep0Block(content: string): boolean {
|
|
714
|
+
return firstNonBlankLine(content).startsWith('git worktree add')
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Rule (ii) (task 10, Issue #385; tightened by the round-2 ruling item 1) —
|
|
719
|
+
* in a brief's `§5` (Pre-flight checks) or `§6` (Numbered parts), a fenced
|
|
720
|
+
* block that opens with a shell command must be immediately followed by
|
|
721
|
+
* another fenced block that does NOT itself open with a shell command — its
|
|
722
|
+
* output. The Step `0` `git worktree add` block is exempt: it is a setup
|
|
723
|
+
* command with no output to show, by the convention every brief's own
|
|
724
|
+
* pre-flight step already follows.
|
|
725
|
+
*
|
|
726
|
+
* **The immediately-following fence, never merely "a later one".** The first
|
|
727
|
+
* cut of this rule accepted `blocks[i + 1]` existing at all, which let three
|
|
728
|
+
* undocumented command blocks in a row pass as long as a single trailing
|
|
729
|
+
* output fence sat after the last of them — each of the first two command
|
|
730
|
+
* blocks' own "next fence" was itself another un-followed command, and the
|
|
731
|
+
* rule never noticed (round-2 ruling, found live reviewing this PR's own
|
|
732
|
+
* diff). A command block's output claim is only satisfied by the very next
|
|
733
|
+
* fence, and only if that fence is not itself a command.
|
|
734
|
+
*
|
|
735
|
+
* Deliberately loose about what sits *between* the two fences (the prose
|
|
736
|
+
* separating them, never counted) — this brief's own pre-flight steps
|
|
737
|
+
* (`§5` items 4-9) interleave a sentence of prose between a command fence
|
|
738
|
+
* and its output fence ("Output the Brief Author obtained at authoring
|
|
739
|
+
* time…"), and the rule must not fail the brief that documents it.
|
|
740
|
+
*/
|
|
741
|
+
export function checkCommandsCarryOutput(prBody: string): BriefSectionResult {
|
|
742
|
+
const errors: string[] = []
|
|
743
|
+
for (const [label, num] of [
|
|
744
|
+
['5', 5],
|
|
745
|
+
['6', 6]
|
|
746
|
+
] as const) {
|
|
747
|
+
const section = extractNumberedSection(prBody, num)
|
|
748
|
+
if (!section) continue
|
|
749
|
+
const blocks = extractFencedBlocks(section)
|
|
750
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
751
|
+
const block = blocks[i] as FencedBlock
|
|
752
|
+
if (isStep0Block(block.content) || !isCommandBlock(block.content)) continue
|
|
753
|
+
const next = blocks[i + 1]
|
|
754
|
+
if (!next || isCommandBlock(next.content)) {
|
|
755
|
+
errors.push(
|
|
756
|
+
`brief-validation commands carry output: §${label} has a command block ("${firstNonBlankLine(block.content)}") not immediately followed by a non-command output block — the very next fenced block must hold that command's actual output, not another command.`
|
|
757
|
+
)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return errors.length === 0 ? { status: 'pass', errors: [] } : { status: 'fail', errors }
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const CONSUMER_TESTS_SENTINEL_RE = /consumer-tests\s*:\s*none\s*[-—–]\s*\S/i
|
|
765
|
+
|
|
766
|
+
/** Every distinct `packages/<pkg>/` reference in `text` — the packages a §4 surface map names. */
|
|
767
|
+
function packagesNamedIn(text: string): string[] {
|
|
768
|
+
const re = /\bpackages\/([A-Za-z0-9_-]+)\//g
|
|
769
|
+
const pkgs = new Set<string>()
|
|
770
|
+
let m: RegExpExecArray | null = re.exec(text)
|
|
771
|
+
while (m !== null) {
|
|
772
|
+
pkgs.add(m[1] as string)
|
|
773
|
+
m = re.exec(text)
|
|
774
|
+
}
|
|
775
|
+
return [...pkgs]
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Whether `text` names a test-shaped path (`*.test.<ext>`) under workspace directory `consumerDir` (e.g. `apps/cli`, `packages/sources`). */
|
|
779
|
+
function hasTestPathForConsumer(text: string, consumerDir: string): boolean {
|
|
780
|
+
const escaped = consumerDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
781
|
+
const re = new RegExp(`${escaped}\\/[\\w./-]*\\.test\\.[A-Za-z0-9]+`)
|
|
782
|
+
return re.test(text)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Rule (iii) (task 10, Issue #385) — a brief whose `§4` names a path under a
|
|
787
|
+
* shared `packages/<pkg>/` must also name, for every workspace package that
|
|
788
|
+
* depends on `@attalabs/<pkg>`, a test path proving that consumer still
|
|
789
|
+
* works, or the sentinel `consumer-tests: none — <reason>` opting out with a
|
|
790
|
+
* stated reason. A shared-package edit with no consumer awareness at all is
|
|
791
|
+
* exactly the gap task 9's rule targets alongside the unpinned-claim and
|
|
792
|
+
* missing-output shapes.
|
|
793
|
+
*
|
|
794
|
+
* `consumersOf` is injected (never `fs`/`package.json` reads here) — the CLI
|
|
795
|
+
* shim (`check-brief-shape.ts`) builds it once from the real workspace
|
|
796
|
+
* dependency graph. Both the trigger and its satisfying test path/sentinel
|
|
797
|
+
* are read from `§4` alone, never the whole body — scanning the whole body
|
|
798
|
+
* let a brief's own §2 prose, quoting the sentinel grammar as an example of
|
|
799
|
+
* the rule (exactly this task's own dispatched brief), silently satisfy the
|
|
800
|
+
* rule it was merely describing. `§4` is where a surface map states its
|
|
801
|
+
* blast radius; that is also where this rule's own answer belongs.
|
|
802
|
+
*/
|
|
803
|
+
export function checkConsumerTests(prBody: string, consumersOf: (pkg: string) => string[]): BriefSectionResult {
|
|
804
|
+
const section4 = extractNumberedSection(prBody, 4)
|
|
805
|
+
if (!section4) return { status: 'pass', errors: [] }
|
|
806
|
+
if (CONSUMER_TESTS_SENTINEL_RE.test(section4)) return { status: 'pass', errors: [] }
|
|
807
|
+
|
|
808
|
+
const errors: string[] = []
|
|
809
|
+
for (const pkg of packagesNamedIn(section4)) {
|
|
810
|
+
for (const consumerDir of consumersOf(pkg)) {
|
|
811
|
+
if (hasTestPathForConsumer(section4, consumerDir)) continue
|
|
812
|
+
errors.push(
|
|
813
|
+
`brief-validation consumer tests: §4 names a path under packages/${pkg}/, and ${consumerDir} depends on @attalabs/${pkg}, but no test path under ${consumerDir} is named in §4 — name one, or add \`consumer-tests: none — <reason>\`.`
|
|
814
|
+
)
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return errors.length === 0 ? { status: 'pass', errors: [] } : { status: 'fail', errors }
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** A check bin filename (`check-<slug>.ts`) — the shape both a §4 "Create"/"Modify" entry and a registry entry use for a check's own name. */
|
|
821
|
+
const CHECK_NAME_RE = /\bcheck-[a-z0-9-]+(?:\.ts)?\b/
|
|
822
|
+
|
|
823
|
+
/** A command that writes to the forge — as opposed to a read-only query — the class this rule wants a `Defeat cases:` line for. */
|
|
824
|
+
const FORGE_WRITE_COMMAND_RE =
|
|
825
|
+
/\bgh\s+(?:pr|issue)\s+(?:create|merge|close|comment|edit|review)\b|\bgit\s+push\b|\bvinaya\s+pr\s+(?:create|report\s+--write)\b/i
|
|
826
|
+
|
|
827
|
+
const DEFEAT_CASES_RE = /defeat cases\s*:/i
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Rule (v) — Principal ruling amending Issue #385: a brief whose `§4`
|
|
831
|
+
* names a check (a `check-<slug>.ts` bin, or its registry entry) or a
|
|
832
|
+
* forge-writing command (`gh pr create`, `git push`, `vinaya pr create`, …)
|
|
833
|
+
* must carry a `Defeat cases:` line in `§6` — the inputs that would defeat
|
|
834
|
+
* the check, or the ones a forge-writing command must not accidentally
|
|
835
|
+
* trigger on. A check or a write path shipped with no stated defeat case is
|
|
836
|
+
* exactly the kind of untested edge this task's own sibling rules exist to
|
|
837
|
+
* close for a brief's prose claims; this one closes it for the check/command
|
|
838
|
+
* itself.
|
|
839
|
+
*/
|
|
840
|
+
export function checkDefeatCases(prBody: string): BriefSectionResult {
|
|
841
|
+
const section4 = extractNumberedSection(prBody, 4)
|
|
842
|
+
if (!section4) return { status: 'pass', errors: [] }
|
|
843
|
+
if (!CHECK_NAME_RE.test(section4) && !FORGE_WRITE_COMMAND_RE.test(section4)) return { status: 'pass', errors: [] }
|
|
844
|
+
|
|
845
|
+
const section6 = extractNumberedSection(prBody, 6) ?? ''
|
|
846
|
+
if (DEFEAT_CASES_RE.test(section6)) return { status: 'pass', errors: [] }
|
|
847
|
+
|
|
848
|
+
return {
|
|
849
|
+
status: 'fail',
|
|
850
|
+
errors: [
|
|
851
|
+
'brief-validation defeat cases: §4 names a check or a forge-writing command, but §6 carries no `Defeat cases:` line — name the inputs that would defeat this check, or that this command must not accidentally trigger on.'
|
|
852
|
+
]
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Composition knobs for `checkBriefSections` — see each field. */
|
|
857
|
+
export type BriefSectionsOptions = {
|
|
858
|
+
/**
|
|
859
|
+
* Whether a `Closes #N` reference is required. Defaults to `true` (every
|
|
860
|
+
* existing caller keeps today's behavior).
|
|
861
|
+
*
|
|
862
|
+
* A task PR must close its Issue, so `verify-brief` leaves this on for
|
|
863
|
+
* `task/<tranche>/<n>`. A brief-shaped body on a non-task branch must not: a
|
|
864
|
+
* standalone fix brief has no task Issue to close, and a `plan/*` PR is
|
|
865
|
+
* *forbidden* to carry `Closes #N` by `checkPlanPrNoCloses` — so
|
|
866
|
+
* requiring it there would make the two gates jointly unsatisfiable. Issue
|
|
867
|
+
* linkage is a task-branch obligation; brief completeness is not.
|
|
868
|
+
*/
|
|
869
|
+
requireClosesN?: boolean
|
|
870
|
+
/**
|
|
871
|
+
* Consumer enumeration for `checkConsumerTests` (task 10) — workspace
|
|
872
|
+
* directories (e.g. `apps/cli`) whose `package.json` depends on
|
|
873
|
+
* `@attalabs/<pkg>`. Defaults to `() => []`, which makes rule (iii) a
|
|
874
|
+
* no-op — the callers that don't wire a real dependency graph (tests,
|
|
875
|
+
* `verify-brief.ts`) keep today's behavior rather than silently failing
|
|
876
|
+
* on an empty consumer list.
|
|
877
|
+
*/
|
|
878
|
+
consumersOf?: (pkg: string) => string[]
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Aggregates every section detector into one combined result — one error
|
|
883
|
+
* line per failing section, mirroring `doc-owners.ts`'s `parseDocOwners`
|
|
884
|
+
* error-message style.
|
|
885
|
+
*/
|
|
886
|
+
export function checkBriefSections(
|
|
887
|
+
prBody: string,
|
|
888
|
+
readTier: (body: string) => 0 | 1 | 3 | null,
|
|
889
|
+
options: BriefSectionsOptions = {}
|
|
890
|
+
): { errors: string[] } {
|
|
891
|
+
const { requireClosesN = true, consumersOf = () => [] } = options
|
|
892
|
+
const results = [
|
|
893
|
+
checkTierField(prBody, readTier),
|
|
894
|
+
checkTestPlan(prBody),
|
|
895
|
+
checkTestPlanExclusivity(prBody),
|
|
896
|
+
checkPrincipalPlaceholder(prBody),
|
|
897
|
+
checkSurfaceMap(prBody),
|
|
898
|
+
checkDocUpdateList(prBody),
|
|
899
|
+
checkWorktreeStep0(prBody),
|
|
900
|
+
checkStopConditions(prBody),
|
|
901
|
+
checkAutonomyClause(prBody),
|
|
902
|
+
checkProjectField(prBody),
|
|
903
|
+
checkForField(prBody),
|
|
904
|
+
checkNoUnpinnedCodeClaims(prBody),
|
|
905
|
+
checkNoAgentBoxes(prBody),
|
|
906
|
+
checkCommandsCarryOutput(prBody),
|
|
907
|
+
checkConsumerTests(prBody, consumersOf),
|
|
908
|
+
checkDefeatCases(prBody),
|
|
909
|
+
...(requireClosesN ? [checkClosesN(prBody)] : [])
|
|
910
|
+
]
|
|
911
|
+
return { errors: results.flatMap((r) => r.errors) }
|
|
912
|
+
}
|