@attalabs/vinaya 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -3
- package/aeg-root/contracts/archivist-tranche-archivist.md +2 -2
- package/aeg-root/contracts/brief-developer.md +1 -0
- package/aeg-root/contracts/developer-reviewer.md +3 -3
- package/aeg-root/contracts/reviewer-archivist.md +3 -3
- package/aeg-root/enforcement.md +26 -20
- package/aeg-root/process.md +8 -4
- package/aeg-root/roles/archivist.md +2 -2
- package/aeg-root/roles/brief-author.md +9 -3
- package/aeg-root/roles/developer.md +63 -31
- package/aeg-root/roles/planner.md +1 -1
- package/aeg-root/roles/principal.md +14 -0
- package/aeg-root/roles/reviewer.md +32 -15
- package/aeg-root/roles/security.md +15 -5
- package/aeg-root/skills/aeg/SKILL.md +9 -5
- package/aeg-root/skills/aeg-roles/SKILL.md +2 -2
- package/aeg-root/skills/brief-authoring/SKILL.md +39 -27
- package/aeg-root/state-machine.md +9 -11
- package/aeg-root/templates/brief-template.md +6 -6
- package/aeg-root/templates/pr-report-template.md +10 -7
- package/aeg-root/tranche-model.md +6 -2
- package/dist/checks/bin/check-body-bare-digits.js +552 -139
- package/dist/checks/bin/check-branch-topology.js +603 -144
- package/dist/checks/bin/check-brief-shape.js +593 -142
- package/dist/checks/bin/check-changeset-coverage.js +935 -161
- package/dist/checks/bin/check-closes-n.js +603 -144
- package/dist/checks/bin/check-coherence.js +614 -147
- package/dist/checks/bin/check-dead-branch-push.js +552 -139
- package/dist/checks/bin/check-dispatch-readiness.js +612 -145
- package/dist/checks/bin/check-doc-coverage-push.js +689 -218
- package/dist/checks/bin/check-doc-coverage.js +689 -218
- package/dist/checks/bin/check-doctrine-no-procedures.js +4794 -0
- package/dist/checks/bin/check-doctrine-portability.js +684 -146
- package/dist/checks/bin/check-evidence-fresh.js +994 -174
- package/dist/checks/bin/check-exec-bits.js +4931 -0
- package/dist/checks/bin/check-first-push-dispatch.js +603 -144
- package/dist/checks/bin/check-issue-assignment.js +603 -144
- package/dist/checks/bin/check-main-branch-refusal.js +552 -139
- package/dist/checks/bin/check-no-disk-state.js +552 -139
- package/dist/checks/bin/check-pr-report-density.js +4461 -0
- package/dist/checks/bin/check-quoted-command.js +621 -155
- package/dist/checks/bin/check-reader-resolvable-prose.js +617 -154
- package/dist/checks/bin/check-registry-gates.js +595 -144
- package/dist/checks/bin/check-retired-vocabulary.js +617 -154
- package/dist/checks/bin/check-review-gate.js +670 -141
- package/dist/checks/bin/check-single-plan-pr.js +552 -139
- package/dist/checks/bin/check-test-plan.js +553 -140
- package/dist/checks/bin/check-token-collection-wired.js +552 -139
- package/dist/checks/bin/check-token-report.js +552 -139
- package/dist/checks/bin/check-workspace-escape.js +985 -145
- package/dist/index.js +8500 -5518
- package/package.json +1 -1
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/actions.ts +173 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/anchored-region.ts +113 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/archive-task.ts +206 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/baseline-capture.ts +65 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/blast-radius-domains.ts +192 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/branch-topology-gate.ts +85 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-validation.ts +511 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/claude-code-transcript.ts +460 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/coherence-checks.ts +787 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-audit.ts +55 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dead-branch-push-guard.ts +77 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-section7.ts +66 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/derive-tranche.ts +94 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/diagram-model.ts +372 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/direct-main-push.ts +39 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/dispatch-gate.ts +224 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doc-owners.ts +314 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/build-doc-nav.ts +38 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/docs-coherence.ts +136 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/index.ts +29 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/legacy-anchors.ts +48 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/nav-helpers.ts +17 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/node-route.ts +134 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/parse-doc.ts +43 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/published-prose.ts +306 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/surfaced-manifest.ts +89 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/docs/types.ts +33 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +170 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-source.ts +23 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/ensure-label.ts +28 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/file-classify.ts +51 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/first-push-dispatch-gate.ts +80 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/gate-audience.ts +168 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +301 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-assignment.ts +102 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/issue-validation.ts +746 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/leftover-detection.ts +68 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/local-anchor-coverage.ts +245 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/main-branch-refusal.ts +64 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/manifest-validity.ts +81 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/markdown-table.ts +76 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/metering-io-guard.ts +119 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/milestone-validation.ts +240 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/no-disk-state.ts +50 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-ledger.ts +77 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-registry.ts +105 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-token-report.ts +165 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/parse-tranche.ts +183 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/pr-tier.ts +81 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/premise-check.ts +125 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/quoted-command.ts +275 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/reader-resolvable-prose.ts +320 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-checks.ts +255 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-parse.ts +164 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/registry-scaffold.ts +216 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/report-tokens.ts +261 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/retired-vocabulary.ts +163 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-gate.ts +269 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/single-plan-pr.ts +73 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-machine-model.ts +269 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/state-source.ts +14 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/status-block.ts +8 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/sum-ledger.ts +26 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/symbol-collisions.ts +78 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-gate.ts +104 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/test-plan-section.ts +65 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/types.ts +174 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/verdict-extraction.ts +140 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/vocabulary-citation.ts +114 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/waiver-label.ts +52 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/workspace-escape.ts +172 -0
- package/studio-standalone/apps/vinaya-studio/web/.next/BUILD_ID +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/build-manifest.json +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/prerender-manifest.json +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.html +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/api/coherence/route.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/backlog/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/[name]/tranches/[slug]/tasks/[taskId]/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/projects/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page/server-reference-manifest.json +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page.js.nft.json +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/app/studio/tranches/page_client-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/[root-of-the-server]__02a-3g8._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/1q96_modules_@clerk_nextjs_dist_esm_app-router_client_keyless-creator-reader_0lom2js.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0053k9k._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0112h-k._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0o771t1._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__0puovz5._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1hs0dcu._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/{[root-of-the-server]__069-_41._.js → [root-of-the-server]__1mcitst._.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{37hnluhzj9bei.js → 0jer_drkl1e4r.js} +4 -4
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 20x-t0a0v129z.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.js → 2ou_81ahqw31e.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
- /package/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/{aeg-core → vinaya/studio-standalone/_node_modules/@attalabs/aeg-core}/bin/verify-coherence.ts +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_buildManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_clientMiddlewareManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → 5xy_GA7G5KRuE3HDumIiw}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planner→Brief Issue-rationale grammar. Pure — no `fs`, no `fetch`,
|
|
3
|
+
* no `process.env`. The tool-layer gate (`bin/open-issue.ts`, invoked because
|
|
4
|
+
* the `check-forge-gates.sh` hook denies raw `gh issue create`) calls
|
|
5
|
+
* `checkIssueRationale` before any task Issue can reach the forge.
|
|
6
|
+
*
|
|
7
|
+
* A task Issue's body must carry every producer field of the
|
|
8
|
+
* `aeg-root/contracts/planner-brief.md` contract — the eight Planner's
|
|
9
|
+
* rationale fields. makes cutting the Issue with its rationale the
|
|
10
|
+
* canonical plan act; an Issue without the full rationale forces the Brief
|
|
11
|
+
* Author to re-derive the Planner's dig cold, the exact loss the contract
|
|
12
|
+
* exists to prevent. Presence-only, like `brief-validation.ts`: content
|
|
13
|
+
* quality stays a judgment call; existence does not.
|
|
14
|
+
*
|
|
15
|
+
* Applies to task Issues only (label `vinaya/tranche:<slug>`) — the caller decides
|
|
16
|
+
* applicability from the labels; this module only checks the body.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { hasLabel, LABELS, projectFieldFromBody, projectsFromBody, SECTION_HEADER } from '@attalabs/aeg-forge-state'
|
|
20
|
+
import { stripCode } from './anchored-region'
|
|
21
|
+
|
|
22
|
+
export type IssueSectionResult = { status: 'pass' | 'fail'; errors: string[] }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Tolerant field detector: accepts the two live rationale styles —
|
|
26
|
+
* `**Field** — …` bold-inline (e.g. Issue #309) and `### Field` headings
|
|
27
|
+
* (e.g. Issue #219). Case-insensitive.
|
|
28
|
+
*/
|
|
29
|
+
function hasRationaleField(body: string, labelPattern: string): boolean {
|
|
30
|
+
// `labelPattern` is grouped. Ungrouped, its own top-level `|` (present in
|
|
31
|
+
// several RATIONALE_FIELDS entries, e.g. `Dependency rationale|Depends[- ]on`)
|
|
32
|
+
// splits the WHOLE alternation instead of just the label — turning the
|
|
33
|
+
// second half into a bare, unanchored match with no prefix requirement at
|
|
34
|
+
// all. Found live: "Depends on" matching mid-sentence prose with no `**`/
|
|
35
|
+
// heading marker anywhere near it, misreporting old Issues that never had
|
|
36
|
+
// this field as "malformed" instead of "missing".
|
|
37
|
+
const re = new RegExp(`(?:\\*\\*|^#{1,4}\\s+)\\s*(?:${labelPattern})`, 'im')
|
|
38
|
+
return re.test(body)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The eight producer fields of the planner-brief contract, with tolerant label patterns. */
|
|
42
|
+
const RATIONALE_FIELDS: Array<{ name: string; pattern: string }> = [
|
|
43
|
+
{ name: 'Boundary', pattern: 'Boundary' },
|
|
44
|
+
{ name: 'Sizing', pattern: 'Sizing' },
|
|
45
|
+
{ name: 'Project(s) + blast radius', pattern: 'Project\\(s\\)|Project(?:s)?\\s*\\+|blast radius' },
|
|
46
|
+
{ name: 'Dependency rationale', pattern: 'Dependency rationale|Depends[- ]on' },
|
|
47
|
+
{ name: 'Traps to avoid', pattern: 'Traps' },
|
|
48
|
+
{ name: 'Suggested agent-class', pattern: '(?:Suggested\\s+)?agent-class' },
|
|
49
|
+
{ name: 'Stop-and-escalate', pattern: 'Stop-and-escalate' },
|
|
50
|
+
{ name: 'Docs to keep coherent', pattern: 'Docs to keep coherent|§7' }
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const DEPENDENCY_RATIONALE_FIELD_NAME = 'Dependency rationale'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every one of the eight Planner's-rationale fields must be present in a task
|
|
57
|
+
* Issue's body. One error line per missing field, mirroring
|
|
58
|
+
* `checkBriefSections`'s error style.
|
|
59
|
+
*
|
|
60
|
+
* `Dependency rationale` carries a second, stricter requirement the other
|
|
61
|
+
* seven fields do not: `amendRationaleDeps` (`@attalabs/aeg-forge-state`, the ONLY
|
|
62
|
+
* sanctioned way to edit `Depends-on`/`Conflicts-with`) locates this section
|
|
63
|
+
* by the exact anchor `SECTION_HEADER` — `**Dependency rationale**` with the
|
|
64
|
+
* bold closing immediately after the label. A body written
|
|
65
|
+
* `**Dependency rationale:** …` (colon inside the bold) satisfies the tolerant
|
|
66
|
+
* detector above but not `SECTION_HEADER`, so it passes here and then throws
|
|
67
|
+
* on the only sanctioned edit path. Importing `SECTION_HEADER` rather than a
|
|
68
|
+
* second hand-written regex keeps this one grammar.
|
|
69
|
+
*/
|
|
70
|
+
export function checkIssueRationale(body: string): IssueSectionResult {
|
|
71
|
+
const errors: string[] = []
|
|
72
|
+
for (const f of RATIONALE_FIELDS) {
|
|
73
|
+
if (!hasRationaleField(body, f.pattern)) {
|
|
74
|
+
errors.push(
|
|
75
|
+
`issue-validation ${f.name}: rationale field not found in the Issue body — every task Issue carries the full Planner's rationale (aeg-root/contracts/planner-brief.md).`
|
|
76
|
+
)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (f.name === DEPENDENCY_RATIONALE_FIELD_NAME && !SECTION_HEADER.test(body)) {
|
|
80
|
+
errors.push(
|
|
81
|
+
`issue-validation ${f.name}: rationale field found, but not in the form amend-deps requires — ` +
|
|
82
|
+
'write `**Dependency rationale** — …`, not `**Dependency rationale:** …`. ' +
|
|
83
|
+
'`amendRationaleDeps` (the only sanctioned way to edit Depends-on/Conflicts-with) locates this ' +
|
|
84
|
+
'section by the exact anchor `**Dependency rationale**`; a colon inside the bold breaks that match ' +
|
|
85
|
+
'and the Issue becomes unamendable.'
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { status: errors.length > 0 ? 'fail' : 'pass', errors }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** true when any label marks this as a task Issue (the rationale contract applies). */
|
|
93
|
+
export function isTaskIssueLabelSet(labels: string[]): boolean {
|
|
94
|
+
return hasLabel('tranche', labels)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every `vinaya/type:*` label id, in `labels.ts` order — the source of truth this check reads, never a second list. */
|
|
98
|
+
const TYPE_LABEL_IDS = LABELS.filter((l) => l.category === 'type').map((l) => l.id)
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* **The task-type axis.** A task Issue must carry exactly one `vinaya/type:*`
|
|
102
|
+
* label — the same commit-type vocabulary `developer.md`'s commit conventions
|
|
103
|
+
* declare, applied to the Issue instead of the commit. Zero means the task
|
|
104
|
+
* was never classified; two or more means two classifications compete and
|
|
105
|
+
* nothing downstream can pick between them.
|
|
106
|
+
*
|
|
107
|
+
* Non-task Issues (no tranche label) pass trivially, the same way every
|
|
108
|
+
* sibling content check treats them — the rationale contract, and everything
|
|
109
|
+
* built on it, applies to task Issues only.
|
|
110
|
+
*
|
|
111
|
+
* **Caller must invoke this at Issue CREATION only, never on `edit`.** The
|
|
112
|
+
* label is mandatory forward from this axis's own merge, not retroactively —
|
|
113
|
+
* a task Issue cut before the merge legitimately carries none, and
|
|
114
|
+
* `open-issue.ts` is the only sanctioned edit path for ANY Issue body, so
|
|
115
|
+
* calling this on every edit would refuse an unrelated edit (a typo fix, a
|
|
116
|
+
* dependency bump) to any pre-existing Issue for lacking a label nothing
|
|
117
|
+
* ever asked it to carry — a forced backfill through the back door. This
|
|
118
|
+
* function itself is pure and stateless (it cannot see create vs. edit); the
|
|
119
|
+
* gating lives in the caller (`open-issue.ts`'s `isEdit` branch).
|
|
120
|
+
*/
|
|
121
|
+
export function checkIssueType(_body: string, labels: string[]): IssueSectionResult {
|
|
122
|
+
if (!isTaskIssueLabelSet(labels)) return { status: 'pass', errors: [] }
|
|
123
|
+
const present = TYPE_LABEL_IDS.filter((id) => labels.includes(id))
|
|
124
|
+
if (present.length === 1) return { status: 'pass', errors: [] }
|
|
125
|
+
const found = present.length === 0 ? 'none of them' : `${present.length} of them (${present.join(', ')})`
|
|
126
|
+
return {
|
|
127
|
+
status: 'fail',
|
|
128
|
+
errors: [
|
|
129
|
+
`issue-validation task type: a task Issue must carry exactly one \`vinaya/type:*\` label, and this one carries ${found}. Valid ids: ${TYPE_LABEL_IDS.join(', ')}.`
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Content checks (A/B/D block, C warns)
|
|
136
|
+
//
|
|
137
|
+
// `checkIssueRationale` above checks the eight fields are PRESENT and
|
|
138
|
+
// well-formed. It never checks what they SAY against the surface the task
|
|
139
|
+
// touches — and three task Issues in `vinaya-pages-v2` (#621/#622/#626) passed
|
|
140
|
+
// it while being wrong in three distinct ways: a `packages/ui` edit declared
|
|
141
|
+
// `Project: vinaya` only (blast radius under-declared, so the review fans out
|
|
142
|
+
// through one product's lens instead of every consumer's); a `## References`
|
|
143
|
+
// block copied brief-time content into the Issue, where it goes stale before
|
|
144
|
+
// work starts; and nothing forced the rationale to name the docs/skills it
|
|
145
|
+
// touches, because the skill-check hook fires on file edits and a forge write
|
|
146
|
+
// edits no file. The checks below are those three failures turned into
|
|
147
|
+
// deterministic functions on the surface they happened on.
|
|
148
|
+
//
|
|
149
|
+
// EVERY ONE OF THEM READS BLOCK-STRIPPED TEXT, via the single exported
|
|
150
|
+
// `stripCode` — never a second regex (PR #617's rule). A rationale that quotes
|
|
151
|
+
// `## References`, `Premise:`, or a `packages/ui` path inside a **fence** is
|
|
152
|
+
// documenting, not leaking, and must not trip anything; GitHub's own parsers
|
|
153
|
+
// ignore code the same way.
|
|
154
|
+
//
|
|
155
|
+
// They differ on **inline spans**, and the split is not cosmetic:
|
|
156
|
+
//
|
|
157
|
+
// - B looks for brief-shaped *headings*, which never live in a span, so it
|
|
158
|
+
// takes the full default strip.
|
|
159
|
+
// - A/C/D look for *paths*, and prose writes paths in backticks by
|
|
160
|
+
// convention — #621 declares its own surface as "edits `packages/ui`". Run
|
|
161
|
+
// span-blind, A matches nothing on the very Issues it was built from and
|
|
162
|
+
// ships as a gate that always passes, which is worse than no gate. So they
|
|
163
|
+
// read `PATH_TEXT`: fences and indented blocks gone, spans intact.
|
|
164
|
+
//
|
|
165
|
+
// Accepted cost: brief content that leaks *entirely inside* a fence is
|
|
166
|
+
// invisible to B. Same trade every code-aware gate here makes; the alternative
|
|
167
|
+
// is the fence-blind false-positive machine #617 removed.
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Block-stripped, span-preserving body text — the reading every path-shaped
|
|
172
|
+
* check uses. See the note above for why paths cannot be scanned span-blind.
|
|
173
|
+
*/
|
|
174
|
+
const PATH_TEXT = (body: string): string => stripCode(body, { inlineSpans: 'keep' })
|
|
175
|
+
|
|
176
|
+
/** A registry row reduced to what the blast-radius check needs — `parseRegistry` output is assignable. */
|
|
177
|
+
export type ProjectPath = { name: string; path: string }
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A C0 or C1 control character — `ESC` (and therefore every ANSI/OSC terminal
|
|
181
|
+
* escape), `BEL`, and the rest.
|
|
182
|
+
*
|
|
183
|
+
* Tested by code point rather than by a character-class regex on purpose: a
|
|
184
|
+
* regex spelling this range is itself a lint violation
|
|
185
|
+
* (`noControlCharactersInRegex`), and the rule is right — the readable way to
|
|
186
|
+
* say "control character" is to name the code points.
|
|
187
|
+
*/
|
|
188
|
+
function isControlCodePoint(codePoint: number): boolean {
|
|
189
|
+
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Characters that change how the rest of a line *renders* without printing
|
|
194
|
+
* anything themselves — the Trojan-Source class.
|
|
195
|
+
*
|
|
196
|
+
* Stripping C0/C1 closes ANSI and OSC, but it is not the whole of "a hostile
|
|
197
|
+
* value cannot repaint or hide the failure being reported": a bidi override
|
|
198
|
+
* (U+202E) reverses the rendered name in the operator's terminal, and a
|
|
199
|
+
* zero-width character (U+200B) splits a name so it reads as a registered one.
|
|
200
|
+
* Same untrusted sources, same goal, so they are dropped by the same pass.
|
|
201
|
+
*
|
|
202
|
+
* U+200B–200F zero width space/joiners, LRM/RLM
|
|
203
|
+
* U+202A–202E bidi embedding and override
|
|
204
|
+
* U+2066–2069 bidi isolates
|
|
205
|
+
* U+FEFF byte-order mark used as a zero-width no-break space
|
|
206
|
+
*/
|
|
207
|
+
function isDisplayControlCodePoint(codePoint: number): boolean {
|
|
208
|
+
return (
|
|
209
|
+
(codePoint >= 0x200b && codePoint <= 0x200f) ||
|
|
210
|
+
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
|
211
|
+
(codePoint >= 0x2066 && codePoint <= 0x2069) ||
|
|
212
|
+
codePoint === 0xfeff
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* How many residue values one error message will name before summarising the
|
|
218
|
+
* rest. `field.unparsed` is one entry per comma-separated segment, so a single
|
|
219
|
+
* body of repeated `%,` pairs yields tens of thousands of them; uncapped they
|
|
220
|
+
* render into one enormous string that lands in `CheckFailure.reason` and in a
|
|
221
|
+
* blocking gate's `--json` output. Naming the first few is what an author needs
|
|
222
|
+
* to find the line; the count carries the rest.
|
|
223
|
+
*/
|
|
224
|
+
const MESSAGE_VALUE_COUNT_MAX = 5
|
|
225
|
+
|
|
226
|
+
/** Renders a residue list for an error message: the first few values, then a count. */
|
|
227
|
+
function residueForMessage(values: string[]): string {
|
|
228
|
+
const shown = values.slice(0, MESSAGE_VALUE_COUNT_MAX).map((v) => `\`${forMessage(v)}\``)
|
|
229
|
+
const rest = values.length - shown.length
|
|
230
|
+
return rest > 0 ? `${shown.join(', ')} and ${rest} more` : shown.join(', ')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** How much of one untrusted value an error message will carry before eliding. */
|
|
234
|
+
const MESSAGE_VALUE_MAX = 64
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Renders an untrusted string into an error message.
|
|
238
|
+
*
|
|
239
|
+
* **Neither side of this gate's message is validated at its source.** Registered
|
|
240
|
+
* names come from `parseRegistry`, which by design "is forgiving" and applies no
|
|
241
|
+
* shape check at all — a name is whatever text sat in a markdown table cell. The
|
|
242
|
+
* declared side is a task Issue's body. Vinaya ships inside the published
|
|
243
|
+
* `@attalabs/vinaya` tarball and runs against *guest* repos, so both are attacker-
|
|
244
|
+
* reachable: a hostile `.vinaya/projects.md` row (or Issue body) carrying `ESC`
|
|
245
|
+
* renders ANSI/OSC sequences straight through a Vinaya error into the operator's
|
|
246
|
+
* terminal, where they can repaint or hide the very failure being reported.
|
|
247
|
+
*
|
|
248
|
+
* Sanitising here rather than at `parseRegistry` is deliberate: the registry's
|
|
249
|
+
* tolerance is load-bearing (a typo'd row must not crash Studio), and a name that
|
|
250
|
+
* is merely *odd* must still resolve for exact-match purposes. The constraint
|
|
251
|
+
* belongs where the value crosses into a rendered message, which is here.
|
|
252
|
+
*
|
|
253
|
+
* Strips control characters, collapses whitespace to single spaces (so a value
|
|
254
|
+
* cannot span lines and forge a second error line), and elides past
|
|
255
|
+
* `MESSAGE_VALUE_MAX`. Everything a well-formed project name is made of survives
|
|
256
|
+
* untouched — this is sanitation, not redaction.
|
|
257
|
+
*/
|
|
258
|
+
function forMessage(value: string): string {
|
|
259
|
+
// Whitespace collapses FIRST, so a newline or tab becomes a space rather than
|
|
260
|
+
// vanishing and welding two words together — and a multi-line value cannot
|
|
261
|
+
// forge what looks like a second error line.
|
|
262
|
+
const kept: string[] = []
|
|
263
|
+
for (const ch of value.replace(/\s+/g, ' ')) {
|
|
264
|
+
const codePoint = ch.codePointAt(0) ?? 0
|
|
265
|
+
if (isControlCodePoint(codePoint) || isDisplayControlCodePoint(codePoint)) continue
|
|
266
|
+
kept.push(ch)
|
|
267
|
+
}
|
|
268
|
+
// Elide by CODE POINT, not by UTF-16 index. `slice` on a string counts code
|
|
269
|
+
// units, so a cut landing inside an astral character emits a lone surrogate —
|
|
270
|
+
// a malformed string, from the function whose job is to make this value safe.
|
|
271
|
+
const cleaned = kept.join('').trim()
|
|
272
|
+
if (cleaned.length === 0) return '(unprintable)'
|
|
273
|
+
const points = Array.from(cleaned)
|
|
274
|
+
return points.length > MESSAGE_VALUE_MAX ? `${points.slice(0, MESSAGE_VALUE_MAX).join('')}…` : cleaned
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The projects a task Issue declares — its body's `**Project:**` field, and
|
|
279
|
+
* only that. Project is a **field, not a label** (doctrine): #614 dropped the
|
|
280
|
+
* `project:*` labels outright, and `@attalabs/aeg-forge-state`'s `list-tasks.ts`
|
|
281
|
+
* derives a task's project from the same field, so the two agree by
|
|
282
|
+
* construction. `labels` stays in the signature because callers pass it and
|
|
283
|
+
* the applicability question (`isTaskIssueLabelSet`) is label-shaped.
|
|
284
|
+
*/
|
|
285
|
+
export function declaredProjects(body: string, _labels: string[]): string[] {
|
|
286
|
+
const field = /(?:\*\*)?Project(?:\(s\))?(?:\*\*)?\s*:\s*(?:\*\*)?\s*([^\n]+)/i.exec(PATH_TEXT(body))
|
|
287
|
+
const fromBody = (field?.[1] ?? '')
|
|
288
|
+
.split(/[,/]/)
|
|
289
|
+
// Trim the markup and sentence punctuation a field value carries in prose —
|
|
290
|
+
// the value is routinely written as "`Project: vinaya`." (backticked, with
|
|
291
|
+
// the sentence's full stop inside the span, which `PATH_TEXT` preserves).
|
|
292
|
+
.map((s) => s.replace(/\*\*/g, '').replace(/[`.;]/g, '').trim())
|
|
293
|
+
// The field's value may trail into prose ("vinaya. **BUT edits …**"); keep
|
|
294
|
+
// the bare-name shapes a registry row can actually carry.
|
|
295
|
+
.filter((s) => /^[a-z0-9][a-z0-9-]*$/i.test(s))
|
|
296
|
+
return [...new Set(fromBody.filter((s) => s.length > 0))]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* **Every declared project resolves against the registry.** `planner.md` states
|
|
301
|
+
* this as a hard gate — *"Unregistered project or a `Project:` that doesn't
|
|
302
|
+
* resolve against `projects.md` → refuse"* — and until this function nothing
|
|
303
|
+
* mechanized it. Found live on 2026-08-12 on a draft plan declaring
|
|
304
|
+
* `Project: aeg-core, aeg-types, vinaya`; `aeg-types` has no registry row and
|
|
305
|
+
* the plan passed every gate.
|
|
306
|
+
*
|
|
307
|
+
* `.vinaya/projects.md` is the sole authority for valid names ("A `Project:`
|
|
308
|
+
* value is valid iff every name in it is a row above"), so this check only asks
|
|
309
|
+
* membership — never whether the *right* projects were chosen, which stays a
|
|
310
|
+
* review judgment like the rest of this module.
|
|
311
|
+
*
|
|
312
|
+
* **It calls `projectsFromBody` — the same function, not a matching regex.**
|
|
313
|
+
* `@attalabs/aeg-forge-state`'s `list-tasks.ts` is the repo's authority for what a
|
|
314
|
+
* task's project *is*: it is what fills `Task.projects`, and therefore what
|
|
315
|
+
* drives the board, dispatch, and doc fan-out. A gate that decides a project is
|
|
316
|
+
* unregistered must be reading the identical name the derivation resolved, or
|
|
317
|
+
* the two can disagree about what the task even declares — so this shares the
|
|
318
|
+
* parser by construction rather than by agreement. That is the same discipline
|
|
319
|
+
* `parseRationaleDeps`/`SECTION_HEADER` already enforce for dependency edges,
|
|
320
|
+
* and the import direction is the existing one (`aeg-core → aeg-forge-state`,
|
|
321
|
+
* as in `archive-task.ts`); nothing new is layered.
|
|
322
|
+
*
|
|
323
|
+
* **Never parse the `Project(s) + blast radius` prose heading here.** That
|
|
324
|
+
* heading is narrative that happens to mention project names alongside file
|
|
325
|
+
* paths; `PROJECT_FIELD` excludes it deliberately ("nothing there puts a `:`
|
|
326
|
+
* straight after the name"). Reading it makes the gate blind to the real
|
|
327
|
+
* declaration and invents projects out of the paths sharing the line. Equally,
|
|
328
|
+
* do not add a second regex that "also handles" the footer field — two parsers
|
|
329
|
+
* that agree today is exactly how the gate and the derivation drift apart.
|
|
330
|
+
*
|
|
331
|
+
* Matching is **exact, case-sensitive**. Every downstream consumer of a
|
|
332
|
+
* project name compares it literally — `verify-dispatch`'s
|
|
333
|
+
* `t.projects.includes(project)`, the board link, the doc fan-out — so
|
|
334
|
+
* case-folding here would pass `Project: Vinaya` while the whole rest of the
|
|
335
|
+
* system resolves it to nothing. Refusing it is the honest answer; the message
|
|
336
|
+
* names the row it differs from only in case, so the fix is obvious.
|
|
337
|
+
*
|
|
338
|
+
* A body with no `**Project:**` line **passes**. Whether the field exists is
|
|
339
|
+
* already `checkIssueRationale`'s job (`Project(s) + blast radius` is one of the
|
|
340
|
+
* eight required fields); duplicating it here would put one failure behind two
|
|
341
|
+
* gates with two different messages. This check answers only "do the declared
|
|
342
|
+
* names resolve".
|
|
343
|
+
*
|
|
344
|
+
* **A declared value that resolves to no name FAILS** — the distinction the
|
|
345
|
+
* parser's old bare `string[]` could not express. Every value used to be filtered
|
|
346
|
+
* through the slug shape and dropped without trace, so `**Project:** notaproject.`
|
|
347
|
+
* (trailing full stop) and a fully backticked or bolded value each arrived here as
|
|
348
|
+
* an empty list, identical to a body that declares nothing. The gate cannot refuse
|
|
349
|
+
* a name it never receives, so it passed **vacuously** on exactly the bodies it
|
|
350
|
+
* exists to catch. `projectFieldFromBody` now separates "no field" (still a pass)
|
|
351
|
+
* from "a field present that resolves to nothing" (a fail, naming the residue when
|
|
352
|
+
* there is one and calling the field empty when there is not) and from "a field
|
|
353
|
+
* this reader could not see" (an unterminated fence — also a fail, see above).
|
|
354
|
+
*
|
|
355
|
+
* **What the corpus does and does not say.** Measured across every task Issue
|
|
356
|
+
* in this repo's forge: zero carry any of those shapes, so this closed a
|
|
357
|
+
* fail-open without turning a single live body red. That is a statement about
|
|
358
|
+
* bodies that exist, not a proof that none can slip past — a constructed body
|
|
359
|
+
* can carry a shape the live corpus happens not to.
|
|
360
|
+
*
|
|
361
|
+
* **A `Project:` line inside CODE — a balanced fence, or a ≥4-column-indented
|
|
362
|
+
* block after a blank line — is an example, not a declaration, and is not
|
|
363
|
+
* read**, by the same rule `stripCode` already applies to every other
|
|
364
|
+
* code-aware gate in this file. This is deliberate, not a gap: it matches how
|
|
365
|
+
* the forge itself renders the line, so refusing to read it is refusing to read
|
|
366
|
+
* what GitHub also treats as code. Only an UNTERMINATED fence differs — it has
|
|
367
|
+
* no natural end, so `stripCode` blanks everything after it including the
|
|
368
|
+
* body's foot, and that swallowed region fails closed rather than reading as
|
|
369
|
+
* absent (see `hasUnterminatedFence` above). An indented block has no
|
|
370
|
+
* equivalent unterminated state — it always ends, either at a dedent or at the
|
|
371
|
+
* body's own end — so it stays a pass like any other example; pinned by test
|
|
372
|
+
* (`list-tasks.test.ts`) so the difference is a recorded decision, not a silent
|
|
373
|
+
* surprise.
|
|
374
|
+
*
|
|
375
|
+
* A known remaining gap of a DIFFERENT class, out of scope here because it is
|
|
376
|
+
* the field's grammar rather than its code-blindness: a `Project:` line inside
|
|
377
|
+
* an HTML comment still outranks the real declaration, since a comment is not
|
|
378
|
+
* code and `stripCode` correctly leaves it.
|
|
379
|
+
*
|
|
380
|
+
* **What reaches the message is constrained** (`forMessage`). Both the declared
|
|
381
|
+
* value and the registered names are untrusted — `parseRegistry` validates
|
|
382
|
+
* nothing, and Vinaya runs against guest repos — so neither is rendered raw.
|
|
383
|
+
*
|
|
384
|
+
* Dormant when `registeredNames` is empty (no `.vinaya/projects.md` on disk) —
|
|
385
|
+
* the same seam-is-dormant-when-absent shape `checkBlastRadiusScope` and
|
|
386
|
+
* `doc-owners` use. A single-project repo has no registry by design, and a
|
|
387
|
+
* check with no source of truth must not invent one. Both bin callers warn when
|
|
388
|
+
* they hand over an empty registry, so the dormancy is never silent.
|
|
389
|
+
*/
|
|
390
|
+
export function checkProjectsRegistered(
|
|
391
|
+
body: string,
|
|
392
|
+
_labels: string[],
|
|
393
|
+
registeredNames: string[]
|
|
394
|
+
): IssueSectionResult {
|
|
395
|
+
if (registeredNames.length === 0) return { status: 'pass', errors: [] }
|
|
396
|
+
const field = projectFieldFromBody(body)
|
|
397
|
+
if (!field.declared) return { status: 'pass', errors: [] }
|
|
398
|
+
const known = new Set(registeredNames.map((n) => n.trim()))
|
|
399
|
+
const registeredList = [...registeredNames].map(forMessage).join(', ')
|
|
400
|
+
const errors: string[] = []
|
|
401
|
+
|
|
402
|
+
// An unterminated fence ran to end of body and swallowed the region the field
|
|
403
|
+
// lives in, so the value below is what the RAW body says and cannot be trusted
|
|
404
|
+
// — inside a swallowed region a quoted example and a real declaration are
|
|
405
|
+
// indistinguishable. Fail closed and point at the malformed fence: accusing
|
|
406
|
+
// the name of not being a project would be wrong (it may be a perfectly good
|
|
407
|
+
// registered one) and would send the author to fix the wrong line.
|
|
408
|
+
if (field.unreadable) {
|
|
409
|
+
return {
|
|
410
|
+
status: 'fail',
|
|
411
|
+
errors: [
|
|
412
|
+
`issue-validation project registry: the body has an unterminated code fence, so its \`**Project:**\` field could not be read reliably — the fence runs to end of body (CommonMark, and how the forge renders it), swallowing everything after it including the foot field where the declaration lives by convention. Read from the raw body the field says ${residueForMessage(field.unparsed) || '(nothing)'}, but a line inside a swallowed region may be a quoted example rather than a real declaration, so this gate refuses rather than guessing which. Balance the fences — every opening run of backticks or tildes needs a closing run at least as long — and the field reads normally.`
|
|
413
|
+
]
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const unregistered = field.names.filter((p) => !known.has(p))
|
|
418
|
+
if (unregistered.length > 0) {
|
|
419
|
+
// A name differing from a real row only in case is the likeliest typo, and the
|
|
420
|
+
// least obvious from the registered list alone — call it out by name.
|
|
421
|
+
const caseHints = unregistered
|
|
422
|
+
.map((p) => {
|
|
423
|
+
const row = [...known].find((k) => k.toLowerCase() === p.toLowerCase())
|
|
424
|
+
return row ? `\`${forMessage(p)}\` differs from the registered \`${forMessage(row)}\` only in case` : null
|
|
425
|
+
})
|
|
426
|
+
.filter((h): h is string => h !== null)
|
|
427
|
+
errors.push(
|
|
428
|
+
`issue-validation project registry: the \`**Project:**\` field declares ${unregistered.map(forMessage).join(', ')} — no such row in \`.vinaya/projects.md\`, which is the authority for valid project names (registered: ${registeredList}). Fix the name, or register the project with \`vinaya init product <name> --path <folder>\` first; an unregistered project has no specs to read and no per-project state to update.${caseHints.length > 0 ? ` Note: ${caseHints.join('; ')} — project names are matched exactly, because every downstream consumer compares them literally.` : ''} This reads the same field \`projectsFromBody\` derives the task's project from, so a name here that is not a row is a task that resolves to a project that does not exist.`
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// The fail-open this check was blind to. A value the parser cannot turn into a
|
|
433
|
+
// name never reached the loop above, so the gate had nothing to refuse and
|
|
434
|
+
// passed — on a body that declares a project as loudly as any other. Silence
|
|
435
|
+
// here is indistinguishable from "this task declares no project", and the two
|
|
436
|
+
// mean opposite things: one is a deliberate omission, the other is a
|
|
437
|
+
// declaration nothing in the system can resolve.
|
|
438
|
+
//
|
|
439
|
+
// Keyed on "declared and resolved to NOTHING", not on the residue: an empty
|
|
440
|
+
// `**Project:**` line yields no name and no residue either, and is the same
|
|
441
|
+
// vacuous pass one shape further along. The residue is named when there is
|
|
442
|
+
// one, because it is the whole of the fix — but its absence is not a pass.
|
|
443
|
+
if (field.names.length === 0) {
|
|
444
|
+
const residue = field.unparsed.length > 0 ? ` — ${residueForMessage(field.unparsed)}` : ' — the field is empty'
|
|
445
|
+
errors.push(
|
|
446
|
+
`issue-validation project registry: the \`**Project:**\` field is present but resolves to no project name${residue} (registered: ${registeredList}). A project name is a slug (\`[a-z0-9][a-z0-9-]*\`, matched exactly); prose, a parenthetical, or a sentence in this field resolves to no project at all, and this gate cannot check a name it never receives — which is how a declaration like this used to pass. Write the registered name on its own, or register the project with \`vinaya init product <name> --path <folder>\` first. If the task genuinely touches no registered project, omit the field rather than explaining its absence inside it — \`checkIssueRationale\` already requires the \`Project(s) + blast radius\` narrative field for that.`
|
|
447
|
+
)
|
|
448
|
+
} else if (field.unparsed.length > 0) {
|
|
449
|
+
errors.push(
|
|
450
|
+
`issue-validation project registry: the \`**Project:**\` field declares a value that is not a project name — ${residueForMessage(field.unparsed)} (registered: ${registeredList}). The rest of the field parsed, so this is a name the gate silently could not check rather than a field it could not read at all. A project name is a slug (\`[a-z0-9][a-z0-9-]*\`, matched exactly); write it on its own, or drop it if it names no project.`
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return errors.length > 0 ? { status: 'fail', errors } : { status: 'pass', errors: [] }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Does the text name a path that puts this task **inside** the collision
|
|
459
|
+
* domain `path`? Matches the domain as a whole path token — `packages/ui` hits
|
|
460
|
+
* `packages/ui/topbar/index.tsx` and a bare `packages/ui`, never
|
|
461
|
+
* `packages/ui-next`.
|
|
462
|
+
*
|
|
463
|
+
* **A cited document is not a touched domain.** Every rationale points at docs
|
|
464
|
+
* for provenance — "the registry row in `.vinaya/projects.md`",
|
|
465
|
+
* "per `packages/ui/README.md`" — and counting those as edits fails correct
|
|
466
|
+
* plans wholesale (it fired on all three of #621/#622/#626 for a projects.md
|
|
467
|
+
* citation none of them edits). So an occurrence whose full path token ends in
|
|
468
|
+
* a doc extension does not count; a bare domain reference, or any non-doc path
|
|
469
|
+
* under it, does. Citing `packages/ui/README.md` *and* editing
|
|
470
|
+
* `packages/ui/topbar/index.tsx` still counts — the check looks for any one
|
|
471
|
+
* qualifying occurrence, not the first.
|
|
472
|
+
*/
|
|
473
|
+
const DOC_EXTENSION_RE = /\.(?:md|mdx|txt)$/i
|
|
474
|
+
|
|
475
|
+
function namesPath(text: string, path: string): boolean {
|
|
476
|
+
const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
477
|
+
const occurrences = new RegExp(`(?:^|[^\\w./-])(${escaped}(?:/[\\w.@-]+)*)(?![\\w-])`, 'gm')
|
|
478
|
+
for (const m of text.matchAll(occurrences)) {
|
|
479
|
+
if (!DOC_EXTENSION_RE.test(m[1] as string)) return true
|
|
480
|
+
}
|
|
481
|
+
return false
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** An explicit, deliberate acknowledgment that the task's reach crosses products. */
|
|
485
|
+
const BLAST_RADIUS_ACK_RE = /(?:\*\*)?blast-radius-ack(?:\*\*)?\s*[:—–-]/i
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* **A — blast radius under-declared.** If the rationale names a path under a
|
|
489
|
+
* collision domain that none of the declared projects owns, the task reaches
|
|
490
|
+
* further than its `Project:` set admits. `projects.md` makes that set the
|
|
491
|
+
* review fan-out ("more projects = more review lenses = proportionally more
|
|
492
|
+
* rigor"), so a shared-primitive change carrying one product's label is
|
|
493
|
+
* reviewed through one lens and under-governs the regression its own prose
|
|
494
|
+
* usually admits in the same breath (#621: "**BUT edits `packages/ui`** … any
|
|
495
|
+
* topbar change is seen by every product").
|
|
496
|
+
*
|
|
497
|
+
* Two ways to satisfy it, both deliberate: list the other consumers in the
|
|
498
|
+
* project field (the fan-out actually widens), or write a `blast-radius-ack:`
|
|
499
|
+
* line (the reach is acknowledged and the Planner has decided one lens is
|
|
500
|
+
* enough). Silence is the only failure.
|
|
501
|
+
*
|
|
502
|
+
* **The multi-project bypass counts registry-validated names only.** A name
|
|
503
|
+
* with no row in `.vinaya/projects.md` resolves to no specs, no state and no
|
|
504
|
+
* reviewer, so it widens nothing — counting it lets a fictional name buy the
|
|
505
|
+
* benefit of the doubt this bypass exists to give real ones. Found live on a
|
|
506
|
+
* draft plan declaring three projects, one of which had no registry row: it
|
|
507
|
+
* cleared the bypass, and the check never ran on a genuinely shared edit.
|
|
508
|
+
* `checkProjectsRegistered` refuses that name in its own right; this function
|
|
509
|
+
* merely declines to be fooled by it, so the two stay independent.
|
|
510
|
+
*
|
|
511
|
+
* **Ownership, not mere listing.** A domain that IS a declared project's own
|
|
512
|
+
* registered path is owned by it — `Project: aeg-core` editing
|
|
513
|
+
* `packages/aeg-core` declares its blast radius exactly, and must not be
|
|
514
|
+
* failed. Without this, the check would block the legitimately single-project
|
|
515
|
+
* shared edit, which is a gate that blocks valid work.
|
|
516
|
+
*
|
|
517
|
+
* **The declared set is read through `projectsFromBody`** — the same parser
|
|
518
|
+
* `checkProjectsRegistered` and `@attalabs/aeg-forge-state`'s `list-tasks.ts` use,
|
|
519
|
+
* shared by construction rather than by agreement. It reads the line-anchored
|
|
520
|
+
* project field and only that. The previous body-wide read took the first
|
|
521
|
+
* field-shaped token *anywhere* in the body, which is routinely prose in Sizing
|
|
522
|
+
* or Boundary rather than the declaration: prose carries file paths, and a path
|
|
523
|
+
* fragment parses as an invented project name (#870's own blast-radius line
|
|
524
|
+
* yielded a project called `src`). Two parsers for one field is how the gate and
|
|
525
|
+
* the derivation come to disagree about what a task even declares — so do not
|
|
526
|
+
* add a second regex here, nor a pre-clean step that makes one "usually" agree.
|
|
527
|
+
*
|
|
528
|
+
* Dormant when `sharedPackages` is empty. As of `open-issue.ts`'s
|
|
529
|
+
* `readSharedPackages`, that only happens on a repo with no `packages/*`
|
|
530
|
+
* workspace member, none of the built-in cross-cutting defaults present, and
|
|
531
|
+
* no `vinaya.config.json` `blastRadius.extraDomains` — a genuinely edge-case
|
|
532
|
+
* repo, not the common "adopter never wrote the file" case this dormancy
|
|
533
|
+
* used to hide. This function itself stays source-blind —
|
|
534
|
+
* it takes the resolved list, never reads disk — the same
|
|
535
|
+
* seam-is-dormant-when-absent shape `doc-owners` uses. The check cannot be
|
|
536
|
+
* deterministic without its source of truth, and inventing one inline is worse
|
|
537
|
+
* than not running.
|
|
538
|
+
*/
|
|
539
|
+
export function checkBlastRadiusScope(
|
|
540
|
+
body: string,
|
|
541
|
+
_labels: string[],
|
|
542
|
+
sharedPackages: string[],
|
|
543
|
+
projectPaths: ProjectPath[]
|
|
544
|
+
): IssueSectionResult {
|
|
545
|
+
if (sharedPackages.length === 0) return { status: 'pass', errors: [] }
|
|
546
|
+
// Scoped to the two fields that declare the task's OWN surface. Scanning the
|
|
547
|
+
// whole body fails correct plans in bulk: a rationale names packages for many
|
|
548
|
+
// reasons that are not edits — a dependency it imports unchanged (#591/#599
|
|
549
|
+
// name `packages/aeg-core` because Vinaya's CLI imports it), a trap to avoid,
|
|
550
|
+
// an Origin note. A full-body scan flagged 46 of 166 historical task Issues,
|
|
551
|
+
// nearly all of them correctly-scoped work. Boundary and Project(s) + blast
|
|
552
|
+
// radius are where a task states what it touches, so that is where a
|
|
553
|
+
// touch-claim is load-bearing enough to block on.
|
|
554
|
+
const text = [
|
|
555
|
+
rationaleFieldText(PATH_TEXT(body), 'Boundary'),
|
|
556
|
+
rationaleFieldText(PATH_TEXT(body), 'Project\\(s\\)|Project(?:s)?\\s*\\+|blast radius')
|
|
557
|
+
].join('\n')
|
|
558
|
+
const named = sharedPackages.filter((d) => namesPath(text, d))
|
|
559
|
+
if (named.length === 0) return { status: 'pass', errors: [] }
|
|
560
|
+
|
|
561
|
+
// Registry-validated, not merely declared. The bypass below widens the review
|
|
562
|
+
// fan-out on the Planner's word that a second project reviews the change — a
|
|
563
|
+
// name with no row in `.vinaya/projects.md` buys no lens, so it cannot buy the
|
|
564
|
+
// bypass either. Found live on a draft plan declaring three names, one of them
|
|
565
|
+
// fictional: it cleared the bypass and this check never ran.
|
|
566
|
+
const registered = new Set(projectPaths.map((p) => p.name.trim()))
|
|
567
|
+
const declared = projectsFromBody(body)
|
|
568
|
+
const projects = declared.filter((p) => registered.has(p))
|
|
569
|
+
const ownedPaths = projectPaths.filter((p) => projects.includes(p.name)).map((p) => p.path.replace(/\/+$/, ''))
|
|
570
|
+
const unowned = named.filter((d) => !ownedPaths.some((owned) => d === owned || d.startsWith(`${owned}/`)))
|
|
571
|
+
if (unowned.length === 0) return { status: 'pass', errors: [] }
|
|
572
|
+
if (projects.length > 1) return { status: 'pass', errors: [] }
|
|
573
|
+
// The ack is looked for in the WHOLE body, not the scoped surface fields — it
|
|
574
|
+
// is a deliberate statement about the task, and a Planner may reasonably put
|
|
575
|
+
// it under Stop-and-escalate or Traps rather than inside Boundary.
|
|
576
|
+
if (BLAST_RADIUS_ACK_RE.test(PATH_TEXT(body))) return { status: 'pass', errors: [] }
|
|
577
|
+
|
|
578
|
+
// Name the dropped names. Without this the message reads "a single project"
|
|
579
|
+
// at an author looking at three, and the real fix (register the name, or use
|
|
580
|
+
// the registered one) is invisible from here.
|
|
581
|
+
const unregistered = declared.filter((p) => !registered.has(p))
|
|
582
|
+
return {
|
|
583
|
+
status: 'fail',
|
|
584
|
+
errors: [
|
|
585
|
+
`issue-validation blast radius: the rationale names ${unowned.join(', ')} — a shared collision domain no declared project (${projects.join(', ') || 'none'}) owns — but declares a single registered project and no \`blast-radius-ack:\` line.${unregistered.length > 0 ? ` Not counted: ${unregistered.join(', ')} — no row in \`.vinaya/projects.md\`, so it adds no review lens.` : ''} Project(s) drives the review fan-out (.vinaya/projects.md); list every consumer in the blast radius, or add \`blast-radius-ack: <why one lens is enough>\`.`
|
|
586
|
+
]
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Headings/fields that belong to a **brief**, never to an Issue. Brief-authoring
|
|
592
|
+
* is explicit that a brief is never put in the task's forge Issue — it would go
|
|
593
|
+
* stale before work starts, and then two artifacts disagree about the same task
|
|
594
|
+
* with nothing to arbitrate them.
|
|
595
|
+
*/
|
|
596
|
+
const BRIEF_MARKERS: Array<{ name: string; pattern: RegExp }> = [
|
|
597
|
+
{ name: '## References', pattern: /(?:^#{1,6}\s*|\*\*)\s*References\b/im },
|
|
598
|
+
{ name: 'Technical surface map', pattern: /(?:^#{1,6}\s*|\*\*)\s*Technical surface map\b/im },
|
|
599
|
+
{ name: 'Premise', pattern: /(?:^#{1,6}\s*|\*\*)\s*Premise(?:\*\*)?\s*[:—–]/im },
|
|
600
|
+
{ name: 'Step 0', pattern: /(?:^#{1,6}\s*|\*\*)\s*Step 0\b/im },
|
|
601
|
+
{ name: 'Test Plan', pattern: /(?:^#{1,6}\s*|\*\*)\s*Test Plan\b/im }
|
|
602
|
+
]
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* **B — brief content in the Issue.** Fails when the code-stripped body carries
|
|
606
|
+
* a brief-shaped heading. The Issue is the Planner's durable rationale; the
|
|
607
|
+
* brief is the Brief Author's just-in-time execution context, authored against
|
|
608
|
+
* the surface as it exists at dispatch. Copying the second into the first
|
|
609
|
+
* creates a stale copy nobody re-reads and nobody updates.
|
|
610
|
+
*/
|
|
611
|
+
export function checkNoBriefContent(body: string): IssueSectionResult {
|
|
612
|
+
const text = stripCode(body)
|
|
613
|
+
const errors = BRIEF_MARKERS.filter((m) => m.pattern.test(text)).map(
|
|
614
|
+
(m) =>
|
|
615
|
+
`issue-validation brief content: the Issue body carries a brief-shaped "${m.name}" section. Brief-time content (surface pointers, skills-to-read, premise, test plan) belongs in the brief, not the Issue — it goes stale before work starts (aeg-root/skills/brief-authoring/SKILL.md). Move it to the brief.`
|
|
616
|
+
)
|
|
617
|
+
return { status: errors.length > 0 ? 'fail' : 'pass', errors }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Slices one rationale field's prose: from its label to the next bold/heading
|
|
622
|
+
* field or the end. Tolerates both live styles, exactly like
|
|
623
|
+
* `hasRationaleField`.
|
|
624
|
+
*/
|
|
625
|
+
function rationaleFieldText(text: string, labelPattern: string): string {
|
|
626
|
+
// `labelPattern` is grouped. Ungrouped, its own `|` splits the WHOLE regex
|
|
627
|
+
// instead of just the label — `Docs to keep coherent|§7` compiled as
|
|
628
|
+
// "(**|#) Docs to keep coherent" OR "§7[^\n]*…", so the Docs branch matched
|
|
629
|
+
// the bare label and captured nothing, and D silently graded every Issue on
|
|
630
|
+
// its Traps field alone (#622 failed on exactly this).
|
|
631
|
+
// The terminator is `(?![\s\S])` — a real end-of-INPUT assertion — not `$`.
|
|
632
|
+
// The `m` flag is required for the `^#{1,4}` heading form, and under `m` a
|
|
633
|
+
// `$` matches end-of-LINE, so the lazy body satisfied the lookahead
|
|
634
|
+
// immediately and every heading-style field (`### §7`, #219) sliced to its
|
|
635
|
+
// own label with zero content. D then saw an empty field and failed Issues
|
|
636
|
+
// that name their docs perfectly well, one line further down.
|
|
637
|
+
const re = new RegExp(
|
|
638
|
+
`(?:\\*\\*|^#{1,4}\\s+)\\s*(?:${labelPattern})[^\\n]*\\n?([\\s\\S]*?)(?=\\n\\s*(?:\\*\\*[A-Z]|#{1,4}\\s)|(?![\\s\\S]))`,
|
|
639
|
+
'im'
|
|
640
|
+
)
|
|
641
|
+
const m = re.exec(text)
|
|
642
|
+
return m ? m[0] : ''
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Concrete doc surfaces — the artifact of having actually read the surface, not
|
|
647
|
+
* a paraphrase of it.
|
|
648
|
+
*
|
|
649
|
+
* Deliberately generous about *shape*, strict about *concreteness*. A first,
|
|
650
|
+
* narrower version (only `aeg-root/…`, `.claude/skills/…`, `apps/<x>/CLAUDE.md`,
|
|
651
|
+
* `apps/<x>/specs/…`) failed 66 of 166 historical task Issues, and the samples
|
|
652
|
+
* were not agents skipping the read — they were real docs written in shapes the
|
|
653
|
+
* pattern did not enumerate: `.claude/rules/ui-patterns.md` (rules, not
|
|
654
|
+
* skills), a bare `docs-index.md`, `packages/<x>/README.md`. Each omission would
|
|
655
|
+
* have blocked a correct plan. So: any repo-rooted `.md`/`.mdx` path, any
|
|
656
|
+
* `.claude/**` doc, and the handful of bare filenames that are unambiguous
|
|
657
|
+
* repo-level documents. What it still refuses is the actual failure — a field
|
|
658
|
+
* that names no document at all.
|
|
659
|
+
*/
|
|
660
|
+
const DOC_PATH_RE =
|
|
661
|
+
/(?:(?:aeg-root|apps|packages|specs|docs|tools|\.claude|\.github)\/[\w./@-]*\.(?:md|mdx)|\.claude\/(?:skills|rules)\/[\w./-]+|\b(?:docs-index|decisions|projects|state-machine|enforcement|process|README|CLAUDE)\.md\b|\b[\w-]+-(?:spec|decisions|backlog)\.md\b)/i
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* The doc-less-surface exemption, shaped after `brief-validation`'s
|
|
665
|
+
* `Test Plan: unit-tests-only` sentinel: an explicit, greppable opt-out a human
|
|
666
|
+
* chose, never an empty field that merely looks like one. "Docs to keep
|
|
667
|
+
* coherent: none" is what an agent writes when it did not look; the sentinel is
|
|
668
|
+
* what a Planner writes when it looked and there was nothing.
|
|
669
|
+
*/
|
|
670
|
+
const NO_DOC_SURFACE_RE = /(?:\*\*)?\s*no-doc-surface/i
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* **D — no read-obligation signal.** The root cause of A and B both: nothing
|
|
674
|
+
* forced the Planner to read the docs and skills governing the surface it was
|
|
675
|
+
* planning, because the skill-check hook fires on file edits and cutting an
|
|
676
|
+
* Issue edits no file. Requiring a concrete doc path in `Docs to keep coherent`
|
|
677
|
+
* / `Traps to avoid` makes the read leave an artifact — you cannot name
|
|
678
|
+
* `.claude/skills/ui-library-system/SKILL.md` as the design anchor without
|
|
679
|
+
* having gone looking for it.
|
|
680
|
+
*
|
|
681
|
+
* Presence-only, like the rest of this module: whether the named doc is the
|
|
682
|
+
* *right* one stays a judgment call for review. That it exists at all does not.
|
|
683
|
+
*/
|
|
684
|
+
export function checkRationaleNamesDocs(body: string): IssueSectionResult {
|
|
685
|
+
const text = PATH_TEXT(body)
|
|
686
|
+
const scope = [rationaleFieldText(text, 'Docs to keep coherent|§7'), rationaleFieldText(text, 'Traps')].join('\n')
|
|
687
|
+
if (NO_DOC_SURFACE_RE.test(scope)) return { status: 'pass', errors: [] }
|
|
688
|
+
if (DOC_PATH_RE.test(scope)) return { status: 'pass', errors: [] }
|
|
689
|
+
return {
|
|
690
|
+
status: 'fail',
|
|
691
|
+
errors: [
|
|
692
|
+
'issue-validation docs read: neither "Docs to keep coherent" nor "Traps to avoid" names a concrete doc path (aeg-root/…, .claude/skills/…, apps/*/CLAUDE.md, apps/*/specs/…). Naming one is the artifact of having read the surface being planned — the forge write triggers no skill-check hook, so this field is the only read-obligation signal. For a genuinely doc-less surface, write the explicit `no-doc-surface` sentinel.'
|
|
693
|
+
]
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** One open task Issue, reduced to what the conflict-completeness warning needs. */
|
|
698
|
+
export type TaskIssueFacts = {
|
|
699
|
+
/** How the Issue is referred to in a `Conflicts-with` edge — its number, or its task id. */
|
|
700
|
+
ref: string
|
|
701
|
+
body: string
|
|
702
|
+
/** Already-parsed `Conflicts-with` ids (`parseRationaleDeps`) — never re-parsed here. */
|
|
703
|
+
conflictsWith: string[]
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** True when either side's declared edges name the other — `#621`, `621` and `8` all count. */
|
|
707
|
+
function edgesNameEachOther(a: TaskIssueFacts, b: TaskIssueFacts): boolean {
|
|
708
|
+
const norm = (s: string) => s.replace(/^#/, '').trim()
|
|
709
|
+
return a.conflictsWith.map(norm).includes(norm(b.ref)) || b.conflictsWith.map(norm).includes(norm(a.ref))
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* **C — conflict completeness. WARN-ONLY, by construction.** Two open task
|
|
714
|
+
* Issues naming the same collision domain and declaring no mutual
|
|
715
|
+
* `Conflicts-with` edge are *probably* a missed serialization — but an Issue
|
|
716
|
+
* does not declare a precise file surface, so "names the same domain" is a
|
|
717
|
+
* hint, not a fact. Failing on a hint would make the gate refuse correct plans,
|
|
718
|
+
* so this prints and never blocks. It is also why AEG's conflict rule is
|
|
719
|
+
* declared-and-static in the first place: a real answer needs a live
|
|
720
|
+
* task→changed-files map, the mutable state the model eliminates
|
|
721
|
+
* (`tranche-model.md` §5).
|
|
722
|
+
*/
|
|
723
|
+
export function checkConflictCompleteness(
|
|
724
|
+
subject: TaskIssueFacts,
|
|
725
|
+
siblings: TaskIssueFacts[],
|
|
726
|
+
sharedPackages: string[]
|
|
727
|
+
): string[] {
|
|
728
|
+
if (sharedPackages.length === 0) return []
|
|
729
|
+
const domainsOf = (facts: TaskIssueFacts) => {
|
|
730
|
+
const text = PATH_TEXT(facts.body)
|
|
731
|
+
return sharedPackages.filter((d) => namesPath(text, d))
|
|
732
|
+
}
|
|
733
|
+
const mine = domainsOf(subject)
|
|
734
|
+
if (mine.length === 0) return []
|
|
735
|
+
const warnings: string[] = []
|
|
736
|
+
for (const sibling of siblings) {
|
|
737
|
+
if (sibling.ref === subject.ref) continue
|
|
738
|
+
const shared = domainsOf(sibling).filter((d) => mine.includes(d))
|
|
739
|
+
if (shared.length === 0) continue
|
|
740
|
+
if (edgesNameEachOther(subject, sibling)) continue
|
|
741
|
+
warnings.push(
|
|
742
|
+
`issue-validation conflict completeness: this Issue and ${sibling.ref} both name ${shared.join(', ')} but neither declares the other in Conflicts-with. If they can run in parallel, say so; otherwise declare the edge (aeg-root/tranche-model.md §5).`
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
return warnings
|
|
746
|
+
}
|