@attalabs/vinaya 0.23.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/aeg-root/enforcement.md +1 -1
- package/aeg-root/roles/developer.md +1 -1
- package/dist/checks/bin/check-body-bare-digits.js +7 -1
- package/dist/checks/bin/check-branch-topology.js +27 -1
- package/dist/checks/bin/check-brief-shape.js +7 -1
- package/dist/checks/bin/check-changeset-coverage.js +19 -1
- package/dist/checks/bin/check-closes-n.js +27 -1
- package/dist/checks/bin/check-coherence.js +27 -1
- package/dist/checks/bin/check-dead-branch-push.js +7 -1
- package/dist/checks/bin/check-dispatch-readiness.js +27 -1
- package/dist/checks/bin/check-doc-coverage-push.js +7 -1
- package/dist/checks/bin/check-doc-coverage.js +7 -1
- package/dist/checks/bin/check-doctrine-no-procedures.js +7 -1
- package/dist/checks/bin/check-doctrine-portability.js +7 -1
- package/dist/checks/bin/check-evidence-fresh.js +7 -1
- package/dist/checks/bin/check-exec-bits.js +7 -1
- package/dist/checks/bin/check-first-push-dispatch.js +27 -1
- package/dist/checks/bin/check-issue-assignment.js +27 -1
- package/dist/checks/bin/check-main-branch-refusal.js +13 -2
- package/dist/checks/bin/check-no-disk-state.js +7 -1
- package/dist/checks/bin/check-pr-report-density.js +7 -1
- package/dist/checks/bin/check-quoted-command.js +38 -12
- package/dist/checks/bin/check-reader-resolvable-prose.js +38 -12
- package/dist/checks/bin/check-registry-gates.js +8 -2
- package/dist/checks/bin/check-retired-vocabulary.js +38 -12
- package/dist/checks/bin/check-review-gate.js +7 -1
- package/dist/checks/bin/check-single-plan-pr.js +7 -1
- package/dist/checks/bin/check-test-plan.js +7 -1
- package/dist/checks/bin/check-token-collection-wired.js +7 -1
- package/dist/checks/bin/check-token-report.js +7 -1
- package/dist/checks/bin/check-workspace-escape.js +17 -3
- package/dist/index.js +405 -98
- package/package.json +2 -2
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/anchored-region.ts +9 -4
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/blast-radius-domains.ts +7 -1
- 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 +405 -4
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +19 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/consumer-enumeration.ts +75 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +44 -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 +128 -5
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +3 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +25 -3
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +17 -5
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-report-density.ts +116 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +35 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +3 -3
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +116 -7
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-status.ts +200 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +25 -12
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +70 -11
- 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]__1mcitst._.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/{0jer_drkl1e4r.js → 0zebsmmk1bvnb.js} +4 -4
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{20x-t0a0v129z.js → 1uogpj5w2n5ju.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{2ou_81ahqw31e.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/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/{5xy_GA7G5KRuE3HDumIiw → QY2GhiI47-765D9DHGy1I}/_buildManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{5xy_GA7G5KRuE3HDumIiw → QY2GhiI47-765D9DHGy1I}/_clientMiddlewareManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{5xy_GA7G5KRuE3HDumIiw → QY2GhiI47-765D9DHGy1I}/_ssgManifest.js +0 -0
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { type AnchorField, anchoredRegion, stripCode } from './anchored-region'
|
|
16
|
-
import { parsePremiseBlock } from './premise-check'
|
|
16
|
+
import { parsePremiseBlock, premiseBlockText } from './premise-check'
|
|
17
17
|
import { locateTestPlanSection } from './test-plan-section'
|
|
18
18
|
|
|
19
19
|
export type BriefSectionResult = { status: 'pass' | 'fail'; errors: string[] }
|
|
@@ -126,14 +126,37 @@ function testPlanRegion(prBody: string): string {
|
|
|
126
126
|
* judge whether tags are correctly assigned or whether `unit-tests-only` is
|
|
127
127
|
* justified by the surface map.
|
|
128
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
|
+
*/
|
|
129
141
|
export function checkTestPlan(prBody: string): BriefSectionResult {
|
|
130
|
-
const
|
|
142
|
+
const located = locateTestPlanSection(prBody)
|
|
143
|
+
const region = located.found ? located.section : prBody
|
|
131
144
|
if (TEST_PLAN_UNIT_TESTS_ONLY_RE.test(region)) return { status: 'pass', errors: [] }
|
|
132
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: [] }
|
|
133
156
|
return {
|
|
134
157
|
status: 'fail',
|
|
135
158
|
errors: [
|
|
136
|
-
'brief-validation Test Plan: no Test Plan section found — expected `Test Plan: unit-tests-only`, or at least one `**[agent]**`/`**[principal]**`-tagged checklist item.'
|
|
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.'
|
|
137
160
|
]
|
|
138
161
|
}
|
|
139
162
|
}
|
|
@@ -466,6 +489,370 @@ export function inferBranchFromBody(prBody: string): string {
|
|
|
466
489
|
return m?.[1] ?? ''
|
|
467
490
|
}
|
|
468
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
|
+
|
|
469
856
|
/** Composition knobs for `checkBriefSections` — see each field. */
|
|
470
857
|
export type BriefSectionsOptions = {
|
|
471
858
|
/**
|
|
@@ -480,6 +867,15 @@ export type BriefSectionsOptions = {
|
|
|
480
867
|
* linkage is a task-branch obligation; brief completeness is not.
|
|
481
868
|
*/
|
|
482
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[]
|
|
483
879
|
}
|
|
484
880
|
|
|
485
881
|
/**
|
|
@@ -492,7 +888,7 @@ export function checkBriefSections(
|
|
|
492
888
|
readTier: (body: string) => 0 | 1 | 3 | null,
|
|
493
889
|
options: BriefSectionsOptions = {}
|
|
494
890
|
): { errors: string[] } {
|
|
495
|
-
const { requireClosesN = true } = options
|
|
891
|
+
const { requireClosesN = true, consumersOf = () => [] } = options
|
|
496
892
|
const results = [
|
|
497
893
|
checkTierField(prBody, readTier),
|
|
498
894
|
checkTestPlan(prBody),
|
|
@@ -505,6 +901,11 @@ export function checkBriefSections(
|
|
|
505
901
|
checkAutonomyClause(prBody),
|
|
506
902
|
checkProjectField(prBody),
|
|
507
903
|
checkForField(prBody),
|
|
904
|
+
checkNoUnpinnedCodeClaims(prBody),
|
|
905
|
+
checkNoAgentBoxes(prBody),
|
|
906
|
+
checkCommandsCarryOutput(prBody),
|
|
907
|
+
checkConsumerTests(prBody, consumersOf),
|
|
908
|
+
checkDefeatCases(prBody),
|
|
508
909
|
...(requireClosesN ? [checkClosesN(prBody)] : [])
|
|
509
910
|
]
|
|
510
911
|
return { errors: results.flatMap((r) => r.errors) }
|
|
@@ -379,6 +379,25 @@ export function checkD1(
|
|
|
379
379
|
const depEntry = resolveDepEntry(dep, e.trancheSlug, issueToEntry, taskToEntry)
|
|
380
380
|
if (!depEntry) continue // unknown dep — not a D1 concern
|
|
381
381
|
|
|
382
|
+
// A self-dependency is unsatisfiable by construction, so it is never a
|
|
383
|
+
// real D1 state — it is proof of a parser defect upstream. Reported as
|
|
384
|
+
// an INTERNAL error rather than as "is not closed", which reads as a
|
|
385
|
+
// legitimate unmet dependency and invites routing around the gate.
|
|
386
|
+
// Mirrors `isSelfDependency` in `dispatch-gate.ts`; deliberately a
|
|
387
|
+
// second small local predicate rather than a shared export, since the
|
|
388
|
+
// two modules match on different fact shapes.
|
|
389
|
+
const sameTask = depEntry.trancheSlug === e.trancheSlug && depEntry.task.id === e.task.id
|
|
390
|
+
const sameIssue = depEntry.task.issue !== null && e.task.issue !== null && depEntry.task.issue === e.task.issue
|
|
391
|
+
if (sameTask || sameIssue) {
|
|
392
|
+
failures.push({
|
|
393
|
+
issue: e.task.issue,
|
|
394
|
+
tranche: e.trancheSlug,
|
|
395
|
+
task: e.task.id,
|
|
396
|
+
reason: `INTERNAL: parsed a self-dependency (depends-on ${dep}) — this is a parser bug in parseRationaleDeps, not a real dependency. Please report it upstream.`
|
|
397
|
+
})
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
|
|
382
401
|
const depFacts = depEntry.facts
|
|
383
402
|
const depClosed = depFacts?.issueState === 'closed'
|
|
384
403
|
if (!depClosed) {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `checkConsumerTests`'s consumer enumeration (`brief-validation.ts`, task 10,
|
|
3
|
+
* Issue #385; corrected by the round-2 ruling item 3) — every workspace
|
|
4
|
+
* member whose own `package.json` depends on `@attalabs/<pkg>`, for "every
|
|
5
|
+
* workspace package depending on `@attalabs/<pkg>`" to mean what it says.
|
|
6
|
+
*
|
|
7
|
+
* The first cut reused `deriveWorkspacePackageDomains` (`blast-radius-domains.ts`),
|
|
8
|
+
* which deliberately collapses to `packages/*` members only — correct for
|
|
9
|
+
* that module's own collision-domain purpose, wrong here: it silently
|
|
10
|
+
* exempted `apps/cli`, a real `@attalabs/aeg-core` consumer, from a rule
|
|
11
|
+
* that promises "every workspace package" (found live reviewing this PR's
|
|
12
|
+
* own diff). This module resolves every workspace glob instead —
|
|
13
|
+
* `apps/*` and `packages/*` alike — via `resolveWorkspaceEntry`, the same
|
|
14
|
+
* glob grammar `deriveWorkspacePackageDomains` uses, reused rather than
|
|
15
|
+
* re-derived a second time.
|
|
16
|
+
*
|
|
17
|
+
* Zero I/O: `listDirs`/`readManifest` are injected by each of the two real
|
|
18
|
+
* entry points — `packages/aeg-core/bin/verify-brief.ts` (authoring-time,
|
|
19
|
+
* pre-dispatch) and `apps/cli`'s `check-brief-shape.ts` (CI) — so both share
|
|
20
|
+
* ONE enumeration rather than two independently-maintained copies that can
|
|
21
|
+
* silently diverge (round-2 ruling item 4).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { resolveWorkspaceEntry } from './blast-radius-domains'
|
|
25
|
+
|
|
26
|
+
export type PackageManifest = { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every workspace member directory (`apps/cli`, `packages/aeg-core`, …),
|
|
30
|
+
* resolved from `workspaces` (positive entries minus any `!`-negated ones,
|
|
31
|
+
* same semantics as `deriveWorkspacePackageDomains`) — but NOT collapsed to
|
|
32
|
+
* `packages/*` only, since a real consumer can live anywhere in the
|
|
33
|
+
* workspace.
|
|
34
|
+
*/
|
|
35
|
+
export function deriveWorkspaceMemberDirs(workspaces: string[], listDirs: (dir: string) => string[]): string[] {
|
|
36
|
+
const positive = workspaces.filter((entry) => !entry.startsWith('!'))
|
|
37
|
+
const negative = workspaces.filter((entry) => entry.startsWith('!')).map((entry) => entry.slice(1))
|
|
38
|
+
|
|
39
|
+
const excluded = new Set<string>()
|
|
40
|
+
for (const entry of negative) {
|
|
41
|
+
for (const member of resolveWorkspaceEntry(entry, listDirs)) excluded.add(member)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const members = new Set<string>()
|
|
45
|
+
for (const entry of positive) {
|
|
46
|
+
for (const member of resolveWorkspaceEntry(entry, listDirs)) {
|
|
47
|
+
if (!excluded.has(member)) members.add(member)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [...members].sort()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Builds `checkConsumerTests`'s `consumersOf` callback: for a package `pkg`
|
|
55
|
+
* (as named in a brief's `§4`, e.g. `packages/aeg-core/` → `'aeg-core'`),
|
|
56
|
+
* every workspace member directory (self excluded) whose own manifest lists
|
|
57
|
+
* `@attalabs/<pkg>` in `dependencies` or `devDependencies`.
|
|
58
|
+
*/
|
|
59
|
+
export function buildConsumersOf(
|
|
60
|
+
workspaces: string[],
|
|
61
|
+
listDirs: (dir: string) => string[],
|
|
62
|
+
readManifest: (dir: string) => PackageManifest | null
|
|
63
|
+
): (pkg: string) => string[] {
|
|
64
|
+
const members = deriveWorkspaceMemberDirs(workspaces, listDirs)
|
|
65
|
+
|
|
66
|
+
return (pkg: string): string[] => {
|
|
67
|
+
const target = `@attalabs/${pkg}`
|
|
68
|
+
return members.filter((dir) => {
|
|
69
|
+
if (dir === `packages/${pkg}`) return false
|
|
70
|
+
const manifest = readManifest(dir)
|
|
71
|
+
if (!manifest) return false
|
|
72
|
+
return target in (manifest.dependencies ?? {}) || target in (manifest.devDependencies ?? {})
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -145,6 +145,39 @@ export type DispatchGateInput = {
|
|
|
145
145
|
|
|
146
146
|
export type DispatchResult = { ready: boolean; blockers: string[] }
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* True when a parsed `depends-on` edge points back at its own host task.
|
|
150
|
+
*
|
|
151
|
+
* A self-dependency is unsatisfiable by construction, so it is never a real
|
|
152
|
+
* gate state — its presence is proof of a defect in the edge text or in its
|
|
153
|
+
* resolution. `parseRationaleDeps` (`@attalabs/aeg-forge-state`) reads only
|
|
154
|
+
* a labeled `Depends-on:`/`Conflicts-with:` field's own comma-separated,
|
|
155
|
+
* id-shaped tokens; since Issue #347 an unlabeled/bare span elsewhere in the
|
|
156
|
+
* "Dependency rationale" section is never read as an edge, whatever its
|
|
157
|
+
* shape (`parse-rationale-deps.ts`'s own module comment states the rule). A
|
|
158
|
+
* slug-qualified token in that list (`aeg-governance-hardening #368`) still
|
|
159
|
+
* resolves its trailing bare number against the NAMED tranche, not the host
|
|
160
|
+
* — a resolver bug there is what would land a task depending on itself, on
|
|
161
|
+
* that tranche's own task `1`.
|
|
162
|
+
*
|
|
163
|
+
* Reported as an INTERNAL error rather than through the ordinary
|
|
164
|
+
* "not merged yet" branch below, because that message reads as a legitimate
|
|
165
|
+
* serialization and invites the reader to route around it: two Developer
|
|
166
|
+
* agents faced with exactly this gate concluded it was a false positive and
|
|
167
|
+
* committed with `--no-verify` to get past their hooks. Naming it a tool bug
|
|
168
|
+
* converts "agent improvises around a nonsense gate" into "agent escalates,"
|
|
169
|
+
* which is the behavior the gate architecture assumes.
|
|
170
|
+
*
|
|
171
|
+
* Two arms, because the edge can carry either shape: a resolved Issue number
|
|
172
|
+
* equal to this task's own, or — the live case — a bare same-tranche id equal
|
|
173
|
+
* to this task's own id.
|
|
174
|
+
*/
|
|
175
|
+
function isSelfDependency(dep: DispatchDependsOnFact, task: Task, issue: DispatchIssueFact): boolean {
|
|
176
|
+
if (dep.issue !== null && issue !== null && dep.issue === issue.number) return true
|
|
177
|
+
const bare = dep.id.trim().replace(/^#/, '')
|
|
178
|
+
return bare.toLowerCase() === task.id.trim().toLowerCase()
|
|
179
|
+
}
|
|
180
|
+
|
|
148
181
|
export function checkDispatchReadiness(input: DispatchGateInput): DispatchResult {
|
|
149
182
|
const { trancheSlug, task } = input
|
|
150
183
|
const taskLabel = `task ${task.id} (tranche ${trancheSlug})`
|
|
@@ -174,6 +207,17 @@ export function checkDispatchReadiness(input: DispatchGateInput): DispatchResult
|
|
|
174
207
|
// `vinaya-engine-v1` 21, #99): a second, narrower path for a dependency
|
|
175
208
|
// Issue closed directly rather than via a merged PR.
|
|
176
209
|
for (const dep of input.dependsOn) {
|
|
210
|
+
// Evaluated BEFORE the unresolvable branch: a self-reference that also
|
|
211
|
+
// failed to resolve is still a parser bug, and reporting it as an
|
|
212
|
+
// unresolvable edge would send the reader to correct edge text that is
|
|
213
|
+
// not actually the fault.
|
|
214
|
+
if (isSelfDependency(dep, task, input.issue)) {
|
|
215
|
+
const issueStr = input.issue !== null ? `#${input.issue.number}` : '?'
|
|
216
|
+
blockers.push(
|
|
217
|
+
`dispatch-gate INTERNAL: parsed a self-dependency for task ${task.id} (${issueStr}) — this is a parser bug in parseRationaleDeps, not a real dependency. Please report it upstream. Re-run once the rationale is corrected or the fix ships.`
|
|
218
|
+
)
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
177
221
|
if (dep.resolved === false) {
|
|
178
222
|
// Distinct from the "not merged yet" branch below (#196): this edge
|
|
179
223
|
// never resolved to any tranche/task/Issue at all, so a "not merged"
|