@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,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dead-branch-push AUDIT (aeg-governance-hardening task 24, #364, Part 4 —
|
|
3
|
+
* item 2). Distinct from the ring-0 `dead-branch-push-guard.ts` (task 18):
|
|
4
|
+
* that predicate answers "should THIS push be allowed right now?" at push
|
|
5
|
+
* time; this one answers "did commits land on a branch AFTER its PR already
|
|
6
|
+
* resolved?" retrospectively, across every remote `task/*` branch — the
|
|
7
|
+
* ring-2 backstop for the class the ring-0 hook is fail-open/hook-dependent
|
|
8
|
+
* against (a writer without the hook installed, or a hook bypass). Pure —
|
|
9
|
+
* no `fs`, no `gh`/`git` shell-outs. The CLI shim (`bin/dead-branch-audit.ts`,
|
|
10
|
+
* joining task 23's `daily-drift` job) gathers each branch's most recent
|
|
11
|
+
* PR (any state) plus its resolution/latest-commit timestamps and passes
|
|
12
|
+
* them in here.
|
|
13
|
+
*
|
|
14
|
+
* Exists to catch the "six topology rows landed on a merged plan PR's
|
|
15
|
+
* branch" incident class (2026-07-03) server-side, within a day, instead of
|
|
16
|
+
* never — never-red discipline: `daily-drift` (the CI job that calls this)
|
|
17
|
+
* must never fail red.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type DeadBranchFact = {
|
|
21
|
+
branch: string
|
|
22
|
+
prNumber: number
|
|
23
|
+
prState: 'MERGED' | 'CLOSED'
|
|
24
|
+
/** ISO timestamp the branch's PR was merged (MERGED) or closed (CLOSED). */
|
|
25
|
+
resolvedAt: string
|
|
26
|
+
/** ISO commit date of the branch's current remote tip. */
|
|
27
|
+
latestCommitAt: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type DeadBranchPush = {
|
|
31
|
+
branch: string
|
|
32
|
+
prNumber: number
|
|
33
|
+
prState: 'MERGED' | 'CLOSED'
|
|
34
|
+
resolvedAt: string
|
|
35
|
+
latestCommitAt: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Flags a branch whose current tip commit is strictly newer than its own
|
|
40
|
+
* PR's resolution time — i.e. commits kept landing on a branch after the
|
|
41
|
+
* forge already considered its work done. A branch whose tip predates (or
|
|
42
|
+
* exactly matches) its PR's resolution is normal: the branch simply hasn't
|
|
43
|
+
* been deleted yet, which is not itself a violation.
|
|
44
|
+
*/
|
|
45
|
+
export function findDeadBranchPushes(facts: DeadBranchFact[]): DeadBranchPush[] {
|
|
46
|
+
return facts
|
|
47
|
+
.filter((f) => new Date(f.latestCommitAt).getTime() > new Date(f.resolvedAt).getTime())
|
|
48
|
+
.map(({ branch, prNumber, prState, resolvedAt, latestCommitAt }) => ({
|
|
49
|
+
branch,
|
|
50
|
+
prNumber,
|
|
51
|
+
prState,
|
|
52
|
+
resolvedAt,
|
|
53
|
+
latestCommitAt
|
|
54
|
+
}))
|
|
55
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dead-branch push guard (aeg-governance-hardening task 18, #335). Pure — no
|
|
3
|
+
* `fs`, no `gh`/`git` shell-outs. The CLI shim (`bin/check-push-target.ts`)
|
|
4
|
+
* makes the one batched forge call (`gh pr list --head <branch> --state all
|
|
5
|
+
* --json number,state --limit 1`), maps its result to a `PrStateFact`, and
|
|
6
|
+
* passes it in here.
|
|
7
|
+
*
|
|
8
|
+
* Exists to answer, deterministically, "is it safe to push more commits to
|
|
9
|
+
* this `task/<tranche>/<n>` branch?" — closing the live-fire incident
|
|
10
|
+
* class where stale local work landed on a branch whose PR had already
|
|
11
|
+
* merged, silently reopening/confusing forge state with no push-time signal.
|
|
12
|
+
*
|
|
13
|
+
* `UNKNOWN` (forge unreachable — network flake, auth issue, rate limit) maps
|
|
14
|
+
* to `allow`, the same as `NONE` (no PR exists yet — a brand-new branch
|
|
15
|
+
* legitimately has none). This is a deliberate fail-open choice: a guard
|
|
16
|
+
* that can block *all* pushes on a transient forge-reachability issue is
|
|
17
|
+
* worse than the bug it fixes. See `aeg-root/enforcement.md`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type PrStateFact = 'MERGED' | 'CLOSED' | 'OPEN' | 'NONE' | 'UNKNOWN'
|
|
21
|
+
|
|
22
|
+
export type DeadBranchPushInput = {
|
|
23
|
+
/** The `task/<tranche>/<n>` branch being pushed. */
|
|
24
|
+
branch: string
|
|
25
|
+
/** State of the branch's most recent PR (any state), or the fact that none/forge-unreachable applies. */
|
|
26
|
+
prState: PrStateFact
|
|
27
|
+
/** The PR number that `prState` refers to, or `null` when no PR exists / could not be determined. */
|
|
28
|
+
prNumber: number | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type DeadBranchPushVerdict = 'allow' | 'refuse'
|
|
32
|
+
|
|
33
|
+
export type DeadBranchPushResult = { verdict: DeadBranchPushVerdict; reason: string }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `allow` — `OPEN`/`NONE`/`UNKNOWN`: an in-flight PR, no PR yet, or the
|
|
37
|
+
* forge couldn't be reached — all legitimate reasons to push.
|
|
38
|
+
* `refuse` — `MERGED`/`CLOSED`: the branch's most recent PR is already
|
|
39
|
+
* resolved. Pushing here would silently resurrect resolved forge
|
|
40
|
+
* state instead of starting a fresh branch.
|
|
41
|
+
*/
|
|
42
|
+
export function checkDeadBranchPush(input: DeadBranchPushInput): DeadBranchPushResult {
|
|
43
|
+
const { branch, prState, prNumber } = input
|
|
44
|
+
|
|
45
|
+
if (prState === 'MERGED') {
|
|
46
|
+
return {
|
|
47
|
+
verdict: 'refuse',
|
|
48
|
+
reason: `Branch \`${branch}\` already has a MERGED PR (#${prNumber}) — this branch's work is done. Push a new task branch instead of pushing onto an already-resolved one.`
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (prState === 'CLOSED') {
|
|
53
|
+
return {
|
|
54
|
+
verdict: 'refuse',
|
|
55
|
+
reason: `Branch \`${branch}\` has a CLOSED PR (#${prNumber}) that was never merged — pushing here would resurrect abandoned/rejected work. Open a new task branch instead.`
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (prState === 'OPEN') {
|
|
60
|
+
return {
|
|
61
|
+
verdict: 'allow',
|
|
62
|
+
reason: `Branch \`${branch}\` has an OPEN PR (#${prNumber}) — pushing more commits to it is normal.`
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (prState === 'NONE') {
|
|
67
|
+
return {
|
|
68
|
+
verdict: 'allow',
|
|
69
|
+
reason: `Branch \`${branch}\` has no PR yet — a brand-new task branch legitimately has none.`
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
verdict: 'allow',
|
|
75
|
+
reason: `Could not determine \`${branch}\`'s PR state (forge unreachable) — failing open rather than blocking the push on a transient issue.`
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive the floor of a task's §7 doc-update list by matching its intended
|
|
3
|
+
* surface globs against `.vinaya/doc-owners` bindings (state-machine.md
|
|
4
|
+
* Section 15). Pure — parses doc-owners content via `parseDocOwners` (no
|
|
5
|
+
* second parser) and does segment-wise glob overlap between each intended
|
|
6
|
+
* surface and each binding's code glob. A Planner/Brief-Author aid only —
|
|
7
|
+
* not wired into any CI gate.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { parseDocOwners } from './doc-owners'
|
|
11
|
+
|
|
12
|
+
export type Section7Match = { surface: string; glob: string; pointer: string; lineNum: number }
|
|
13
|
+
|
|
14
|
+
export function deriveSection7(
|
|
15
|
+
intendedSurfaces: string[],
|
|
16
|
+
docOwnersContent: string
|
|
17
|
+
): { pointers: string[]; matches: Section7Match[]; errors: string[] } {
|
|
18
|
+
const { bindings, errors } = parseDocOwners(docOwnersContent)
|
|
19
|
+
const matches: Section7Match[] = []
|
|
20
|
+
for (const surface of intendedSurfaces) {
|
|
21
|
+
for (const binding of bindings) {
|
|
22
|
+
if (globsOverlap(binding.glob, surface)) {
|
|
23
|
+
matches.push({ surface, glob: binding.glob, pointer: binding.pointer, lineNum: binding.lineNum })
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pointers: string[] = []
|
|
29
|
+
for (const m of matches) {
|
|
30
|
+
if (!pointers.includes(m.pointer)) pointers.push(m.pointer)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { pointers, matches, errors }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* True if two doc-owners-grammar globs can match at least one common path,
|
|
38
|
+
* compared segment-by-segment (split on `/`). `**` absorbs zero or more
|
|
39
|
+
* remaining segments on either side — checked at each step, even once one
|
|
40
|
+
* side has run out of segments, so a trailing `**` still absorbs. `*`
|
|
41
|
+
* matches exactly one segment regardless of the other side's literal/`*`.
|
|
42
|
+
* Two literal segments must be byte-equal. If one side runs out of segments
|
|
43
|
+
* before the other and the next pending segment on either side isn't `**`,
|
|
44
|
+
* there is no overlap.
|
|
45
|
+
*/
|
|
46
|
+
export function globsOverlap(a: string, b: string): boolean {
|
|
47
|
+
const segsA = a.split('/')
|
|
48
|
+
const segsB = b.split('/')
|
|
49
|
+
let i = 0
|
|
50
|
+
let j = 0
|
|
51
|
+
while (i < segsA.length || j < segsB.length) {
|
|
52
|
+
const sa = i < segsA.length ? segsA[i] : undefined
|
|
53
|
+
const sb = j < segsB.length ? segsB[j] : undefined
|
|
54
|
+
if (sa === '**' || sb === '**') return true
|
|
55
|
+
if (sa === undefined || sb === undefined) return false
|
|
56
|
+
if (sa === '*' || sb === '*') {
|
|
57
|
+
i++
|
|
58
|
+
j++
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (sa !== sb) return false
|
|
62
|
+
i++
|
|
63
|
+
j++
|
|
64
|
+
}
|
|
65
|
+
return true
|
|
66
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { deriveStatusFromModel } from './state-machine-model'
|
|
2
|
+
import type { DerivedTranche, DerivedStatus, DerivedTask, ForgeFacts, Tranche, Task, UnknownEdge } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compute each task's derived status, the resolved depends-on / conflicts-with
|
|
6
|
+
* graph, and dispatch-eligibility per `tranche-model.md` §3 (status table)
|
|
7
|
+
* and §8 (dispatch gates).
|
|
8
|
+
*
|
|
9
|
+
* The function is pure: `tranche` + `forgeFacts` in, `DerivedTranche` out.
|
|
10
|
+
* Edges referencing task ids not present in this tranche's table are
|
|
11
|
+
* reported via `unknownEdges` rather than throwing — see §4 traps in the brief
|
|
12
|
+
* (real files reference dropped/prose-only ids in narrative).
|
|
13
|
+
*/
|
|
14
|
+
export function deriveTranche(tranche: Tranche, forgeFacts: Map<string, ForgeFacts>): DerivedTranche {
|
|
15
|
+
const byId = new Map<string, Task>()
|
|
16
|
+
for (const task of tranche.tasks) byId.set(task.id, task)
|
|
17
|
+
|
|
18
|
+
// First pass: derive each task's own status.
|
|
19
|
+
const statuses = new Map<string, DerivedStatus>()
|
|
20
|
+
for (const task of tranche.tasks) {
|
|
21
|
+
statuses.set(task.id, deriveStatus(forgeFacts.get(task.id)))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Second pass: dispatch eligibility, which references siblings' derived
|
|
25
|
+
// statuses (so it must come after the first pass).
|
|
26
|
+
const unknownEdges: UnknownEdge[] = []
|
|
27
|
+
const derived: DerivedTask[] = []
|
|
28
|
+
for (const task of tranche.tasks) {
|
|
29
|
+
const status = statuses.get(task.id) ?? 'todo'
|
|
30
|
+
const dependsOnNotMerged: string[] = []
|
|
31
|
+
for (const dep of task.dependsOn) {
|
|
32
|
+
if (!byId.has(dep)) {
|
|
33
|
+
unknownEdges.push({ from: task.id, to: dep, kind: 'depends-on' })
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
if (statuses.get(dep) !== 'merged') dependsOnNotMerged.push(dep)
|
|
37
|
+
}
|
|
38
|
+
const conflictsWithOpenOrInFlight: string[] = []
|
|
39
|
+
for (const sib of task.conflictsWith) {
|
|
40
|
+
if (!byId.has(sib)) {
|
|
41
|
+
unknownEdges.push({ from: task.id, to: sib, kind: 'conflicts-with' })
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
const sibStatus = statuses.get(sib)
|
|
45
|
+
if (sibStatus === 'in-flight' || sibStatus === 'in-review' || sibStatus === 'changes-requested') {
|
|
46
|
+
conflictsWithOpenOrInFlight.push(sib)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
derived.push({
|
|
50
|
+
task,
|
|
51
|
+
status,
|
|
52
|
+
dispatchable: dependsOnNotMerged.length === 0 && conflictsWithOpenOrInFlight.length === 0,
|
|
53
|
+
blockers: { dependsOnNotMerged, conflictsWithOpenOrInFlight }
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { tranche, tasks: derived, unknownEdges }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Map a single task's forge facts to its derived status. Mirrors the §3 table
|
|
62
|
+
*: `blocked` wins over everything else, then `merged`, then
|
|
63
|
+
* `changes-requested`, then `in-review`, then `in-flight`, then `todo` for the
|
|
64
|
+
* open-issue cases (assigned or not — both are `todo` inside a tranche per
|
|
65
|
+
*), then the honest terminal cases for a closed-without-merge Issue.
|
|
66
|
+
*
|
|
67
|
+
* Reopened-after-merge exception: a reopened Issue can carry a stale
|
|
68
|
+
* `prState: 'merged'` fact from a closing PR that predates the reopen —
|
|
69
|
+
* `issueState` and `prState` are independently sourced, and GitHub does not
|
|
70
|
+
* retract a PR's merge history just because the Issue reopened. Without this
|
|
71
|
+
* check, such a task would permanently display `merged` even though the work
|
|
72
|
+
* it names was never actually done. Checked before the `prState === 'merged'`
|
|
73
|
+
* branch below so a currently-open Issue always wins over a historical merge.
|
|
74
|
+
*
|
|
75
|
+
* Honest terminal derivation: a closed Issue with no merged PR must
|
|
76
|
+
* never resolve to `todo` (which would imply not-started — a lie). It reads
|
|
77
|
+
* GitHub's native `stateReason`: closed `NOT_PLANNED` → `dropped` (legitimately
|
|
78
|
+
* abandoned); anything else (closed `COMPLETED` or no reason recorded) →
|
|
79
|
+
* `incoherent` (genuinely done but unprovable, or a broken close that the
|
|
80
|
+
* `Closes #N` law was built to surface).
|
|
81
|
+
*
|
|
82
|
+
* A missing `forgeFacts` entry → `todo`. Tranche tasks are committed work;
|
|
83
|
+
* `backlog` is a project-level concept only and is never emitted here.
|
|
84
|
+
*
|
|
85
|
+
* The rules themselves are no longer written here. They live as an ordered,
|
|
86
|
+
* pure-data list in `state-machine-model.ts` (the `actions.ts`
|
|
87
|
+
* discipline): one list, executed by this function and rendered by the docs,
|
|
88
|
+
* so the two cannot drift. Each rule carries the prose and the reason its
|
|
89
|
+
* position matters — change the order there, and this function's behavior
|
|
90
|
+
* changes with it.
|
|
91
|
+
*/
|
|
92
|
+
function deriveStatus(facts: ForgeFacts | undefined): DerivedStatus {
|
|
93
|
+
return deriveStatusFromModel(facts)
|
|
94
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* diagram-model.ts — the pure derivation at the heart of one library
|
|
3
|
+
* turns doctrine (`enforcement.md` rings/gates/checks + role/contract
|
|
4
|
+
* frontmatter + the canonical `ACTIONS` set) plus declarative config plus
|
|
5
|
+
* live status into a renderer-agnostic `DiagramModel`. Every diagram consumer
|
|
6
|
+
* (Studio, the Vinaya portal, a CLI visualizer) reads THIS model; none
|
|
7
|
+
* re-implements the governance logic. Zero I/O, zero `fs`/`node:`
|
|
8
|
+
* imports — doctrine arrives already-read through `DoctrineContent`;
|
|
9
|
+
* the file reads live behind `DoctrineSource` in `@attalabs/vinaya-sources`.
|
|
10
|
+
*
|
|
11
|
+
* What is NOT here: geometry. Radius, angle, colour, coordinates are the
|
|
12
|
+
* renderer's job (a ring index and a render-state are structural facts; a
|
|
13
|
+
* pixel is not). Render-state is DERIVED, never authored: `disabled` when
|
|
14
|
+
* config disables it, else `active`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import matter from 'gray-matter'
|
|
18
|
+
import { ACTIONS, CROSSING_KEYWORDS } from './actions'
|
|
19
|
+
import type { DoctrineContent } from './doctrine-source'
|
|
20
|
+
import { findHeadingLine, findTable } from './markdown-table'
|
|
21
|
+
import { parseEnforcementRegistry } from './registry-parse'
|
|
22
|
+
import type { Tranche } from './types'
|
|
23
|
+
|
|
24
|
+
export type DiagramNodeKind = 'ring' | 'gate' | 'check' | 'action' | 'role' | 'contract'
|
|
25
|
+
export type RenderState = 'active' | 'disabled'
|
|
26
|
+
|
|
27
|
+
export type DiagramNode = {
|
|
28
|
+
id: string
|
|
29
|
+
kind: DiagramNodeKind
|
|
30
|
+
label: string
|
|
31
|
+
ringIndex?: 0 | 1 | 2
|
|
32
|
+
renderState: RenderState
|
|
33
|
+
sourceLine?: number
|
|
34
|
+
summary?: string
|
|
35
|
+
/** What this node actually does, in one plain sentence — every kind carries
|
|
36
|
+
* one, each from its own source's `description`: the enforcement table's
|
|
37
|
+
* Description column for `gate`/`check`, `description:` frontmatter for
|
|
38
|
+
* `role`/`contract`, the `ACTIONS` entry's field for `action`. One register
|
|
39
|
+
* for every kind, because a reader cannot see what kind of node they
|
|
40
|
+
* clicked. Never `GateRow.spec` — that column is written to enforce, not to
|
|
41
|
+
* read (38% of rows cite a decision id, an issue number or a file path;
|
|
42
|
+
* longest is 2650 chars), and rendering it here is the bug this field
|
|
43
|
+
* exists to prevent. `summary` asks the question, this answers it, and
|
|
44
|
+
* "Read more" carries the spec. Optional because a source may omit it,
|
|
45
|
+
* never because a kind structurally lacks one. */
|
|
46
|
+
detail?: string
|
|
47
|
+
category?: 'ci' | 'hook' | 'event'
|
|
48
|
+
actorType?: 'agent' | 'human' | 'either'
|
|
49
|
+
/** Whether this action reaches GitHub — `action` nodes only, straight from
|
|
50
|
+
* its `ACTIONS` entry (the canonical set). On the node because a
|
|
51
|
+
* renderer cannot get it any other way: `ACTIONS` is a value export, and
|
|
52
|
+
* importing it into a client component drags `@attalabs/aeg-forge-state`'s
|
|
53
|
+
* `node:child_process` into the browser bundle. The distinction is
|
|
54
|
+
* doctrine-load-bearing — ring-0 gates guard exactly the crossings, and
|
|
55
|
+
* G3 exists to prove there is no unguarded one — so it must survive to the
|
|
56
|
+
* render rather than being implied by which ring a node was filed under. */
|
|
57
|
+
crosses?: 'into-github' | 'none'
|
|
58
|
+
/** The node's name **for a reader**, when its `label` is an identifier rather
|
|
59
|
+
* than words. `role`/`contract` nodes label themselves with their doctrine id
|
|
60
|
+
* (`brief-author`, `archivist-tranche-archivist`) because that id is the
|
|
61
|
+
* node's identity — routes, doc paths and edge endpoints all key off it, so
|
|
62
|
+
* it cannot be prettified in place. This carries the doc's own `title:`
|
|
63
|
+
* frontmatter instead, and every renderer showing a name to a person prefers
|
|
64
|
+
* it, falling back to `label` when absent.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately NOT `sidebar_title`: that field is the docs nav's own caption,
|
|
67
|
+
* and sourcing a doctrine node's name from a nav hint would let a caption
|
|
68
|
+
* edit silently rename the node. `title:` is the document's own name, which
|
|
69
|
+
* is what a display name should be. */
|
|
70
|
+
displayLabel?: string
|
|
71
|
+
/** Where this node sits in the process, from its doc's `order:` frontmatter
|
|
72
|
+
* — `role`/`contract` nodes only. Doctrine files are read in filename order,
|
|
73
|
+
* which is alphabetical and therefore meaningless to a reader; the process
|
|
74
|
+
* runs plan → brief → build → review → verify → merge → archive, and that is
|
|
75
|
+
* the order every surface listing these nodes should present them in. Kept on
|
|
76
|
+
* the node rather than re-derived per renderer so the docs nav and the map
|
|
77
|
+
* cannot disagree about the sequence. Absent means unordered — a renderer
|
|
78
|
+
* sorting by it must treat that as "last", never as zero. */
|
|
79
|
+
order?: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type DiagramEdge = {
|
|
83
|
+
id: string
|
|
84
|
+
kind: 'guards' | 'performs' | 'produces' | 'consumes'
|
|
85
|
+
from: string
|
|
86
|
+
to: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type DiagramFinding = { configKey?: string; reason: string }
|
|
90
|
+
|
|
91
|
+
export type DiagramConfig = {
|
|
92
|
+
rings?: { ring1_forgeWriteInterception?: boolean; ring2_asyncAudits?: boolean }
|
|
93
|
+
gates?: Record<string, boolean>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type DiagramModel = {
|
|
97
|
+
nodes: DiagramNode[]
|
|
98
|
+
edges: DiagramEdge[]
|
|
99
|
+
findings: DiagramFinding[]
|
|
100
|
+
tranche: Tranche | null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Deterministic slug from a table row's first cell: lowercase, drop
|
|
104
|
+
* backticks/bold/markdown-link chrome, collapse non-alphanumerics to `-`. */
|
|
105
|
+
function slugify(cell: string): string {
|
|
106
|
+
return cell
|
|
107
|
+
.toLowerCase()
|
|
108
|
+
.replace(/`/g, '')
|
|
109
|
+
.replace(/\*\*/g, '')
|
|
110
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
111
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
112
|
+
.replace(/^-+|-+$/g, '')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The three ring summary labels, keyed by ring index, from `enforcement.md`'s
|
|
116
|
+
* `## The model:` summary table (the same table `loadRings` reads). Throws on a
|
|
117
|
+
* malformed doctrine — same contract as `parseEnforcementRegistry`.
|
|
118
|
+
*
|
|
119
|
+
* Keyed by row POSITION (row 0 = ring 0, row 1 = ring 1, row 2 = ring 2),
|
|
120
|
+
* not by a "0 —"/"1 —"/"2 —" text prefix on the cell — the labels
|
|
121
|
+
* themselves carry no numeric prefix (round-3 fix: dropped per Issue #508),
|
|
122
|
+
* so a prefix match would no longer find anything. The 3-row length check
|
|
123
|
+
* above is what actually guarantees the ring-0/1/2 order; position is a
|
|
124
|
+
* reliable key once that's enforced. */
|
|
125
|
+
function ringLabels(enforcement: string): Record<0 | 1 | 2, string> {
|
|
126
|
+
const lines = enforcement.split('\n')
|
|
127
|
+
const summaryHeadingLine = findHeadingLine(lines, /^##\s*The model:/)
|
|
128
|
+
if (!summaryHeadingLine) throw new Error('enforcement.md: could not find "## The model:" heading')
|
|
129
|
+
const summaryTable = findTable(lines, summaryHeadingLine, false)
|
|
130
|
+
if (summaryTable?.rows.length !== 3) {
|
|
131
|
+
throw new Error('enforcement.md: summary table does not have exactly 3 rows (Ring 0/1/2)')
|
|
132
|
+
}
|
|
133
|
+
const byRow = summaryTable.rows.map((row) => (row.cells[0] ?? '').replace(/\*\*/g, '').trim())
|
|
134
|
+
return { 0: byRow[0] ?? '', 1: byRow[1] ?? '', 2: byRow[2] ?? '' }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
type RoleFrontmatter = {
|
|
138
|
+
roleId: string
|
|
139
|
+
title?: string
|
|
140
|
+
order?: number
|
|
141
|
+
description?: string
|
|
142
|
+
summary?: string
|
|
143
|
+
actorType?: 'agent' | 'human' | 'either'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extractRole(content: string): RoleFrontmatter | null {
|
|
147
|
+
const { data } = matter(content)
|
|
148
|
+
if (typeof data.role_id !== 'string') return null
|
|
149
|
+
return {
|
|
150
|
+
roleId: data.role_id,
|
|
151
|
+
title: typeof data.title === 'string' ? data.title : undefined,
|
|
152
|
+
order: typeof data.order === 'number' ? data.order : undefined,
|
|
153
|
+
description: typeof data.description === 'string' ? data.description : undefined,
|
|
154
|
+
summary: typeof data.summary === 'string' ? data.summary : undefined,
|
|
155
|
+
actorType: data.actor === 'agent' || data.actor === 'human' || data.actor === 'either' ? data.actor : undefined
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type ContractFrontmatter = {
|
|
160
|
+
contractId: string
|
|
161
|
+
title?: string
|
|
162
|
+
order?: number
|
|
163
|
+
description?: string
|
|
164
|
+
producer?: string
|
|
165
|
+
consumer?: string
|
|
166
|
+
summary?: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function extractContract(content: string): ContractFrontmatter | null {
|
|
170
|
+
const { data } = matter(content)
|
|
171
|
+
if (typeof data.contract_id !== 'string') return null
|
|
172
|
+
return {
|
|
173
|
+
contractId: data.contract_id,
|
|
174
|
+
title: typeof data.title === 'string' ? data.title : undefined,
|
|
175
|
+
order: typeof data.order === 'number' ? data.order : undefined,
|
|
176
|
+
description: typeof data.description === 'string' ? data.description : undefined,
|
|
177
|
+
producer: typeof data.producer === 'string' ? data.producer : undefined,
|
|
178
|
+
consumer: typeof data.consumer === 'string' ? data.consumer : undefined,
|
|
179
|
+
summary: typeof data.summary === 'string' ? data.summary : undefined
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The pure derivation. `doctrine` is already-read raw markdown; `config` is the
|
|
185
|
+
* declarative enable/disable input (null = nothing disabled); `tranche` is
|
|
186
|
+
* StateSource live context, passed through verbatim and never used to compute
|
|
187
|
+
* render-state.
|
|
188
|
+
*/
|
|
189
|
+
export function deriveDiagramModel(
|
|
190
|
+
doctrine: DoctrineContent,
|
|
191
|
+
config: DiagramConfig | null,
|
|
192
|
+
tranche: Tranche | null
|
|
193
|
+
): DiagramModel {
|
|
194
|
+
const nodes: DiagramNode[] = []
|
|
195
|
+
const edges: DiagramEdge[] = []
|
|
196
|
+
const findings: DiagramFinding[] = []
|
|
197
|
+
|
|
198
|
+
const labels = ringLabels(doctrine.enforcement)
|
|
199
|
+
const ring1Disabled = config?.rings?.ring1_forgeWriteInterception === false
|
|
200
|
+
const ring2Disabled = config?.rings?.ring2_asyncAudits === false
|
|
201
|
+
|
|
202
|
+
// --- Ring nodes (Ring 0 has no ring-level switch) ---
|
|
203
|
+
nodes.push({ id: 'ring:0', kind: 'ring', label: labels[0], ringIndex: 0, renderState: 'active' })
|
|
204
|
+
nodes.push({
|
|
205
|
+
id: 'ring:1',
|
|
206
|
+
kind: 'ring',
|
|
207
|
+
label: labels[1],
|
|
208
|
+
ringIndex: 1,
|
|
209
|
+
renderState: ring1Disabled ? 'disabled' : 'active'
|
|
210
|
+
})
|
|
211
|
+
nodes.push({
|
|
212
|
+
id: 'ring:2',
|
|
213
|
+
kind: 'ring',
|
|
214
|
+
label: labels[2],
|
|
215
|
+
ringIndex: 2,
|
|
216
|
+
renderState: ring2Disabled ? 'disabled' : 'active'
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// --- Gate (Ring 0) and check (Ring 1/2) nodes from the detail tables ---
|
|
220
|
+
const gateRows = parseEnforcementRegistry(doctrine.enforcement)
|
|
221
|
+
const usedSlugs = new Set<string>()
|
|
222
|
+
const gateCheckSlugs = new Set<string>()
|
|
223
|
+
// Track ring-0 gate nodes (label + id) for the `guards` keyword match.
|
|
224
|
+
const ring0Gates: Array<{ id: string; label: string }> = []
|
|
225
|
+
|
|
226
|
+
for (const row of gateRows) {
|
|
227
|
+
const ringIndex: 0 | 1 | 2 = row.ring === 'ring0' ? 0 : row.ring === 'ring1' ? 1 : 2
|
|
228
|
+
const kind: DiagramNodeKind = ringIndex === 0 ? 'gate' : 'check'
|
|
229
|
+
const base = slugify(row.action)
|
|
230
|
+
const slug = usedSlugs.has(base) ? `${base}-${row.line}` : base
|
|
231
|
+
usedSlugs.add(slug)
|
|
232
|
+
gateCheckSlugs.add(slug)
|
|
233
|
+
const id = `${kind}:${slug}`
|
|
234
|
+
|
|
235
|
+
let renderState: RenderState
|
|
236
|
+
if (config?.gates?.[slug] === false) {
|
|
237
|
+
renderState = 'disabled'
|
|
238
|
+
} else if (ringIndex === 1 && ring1Disabled) {
|
|
239
|
+
renderState = 'disabled'
|
|
240
|
+
} else if (ringIndex === 2 && ring2Disabled) {
|
|
241
|
+
renderState = 'disabled'
|
|
242
|
+
} else {
|
|
243
|
+
renderState = 'active'
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
nodes.push({
|
|
247
|
+
id,
|
|
248
|
+
kind,
|
|
249
|
+
label: row.action,
|
|
250
|
+
ringIndex,
|
|
251
|
+
renderState,
|
|
252
|
+
sourceLine: row.line,
|
|
253
|
+
summary: row.summary,
|
|
254
|
+
detail: row.description,
|
|
255
|
+
category: row.category
|
|
256
|
+
})
|
|
257
|
+
if (ringIndex === 0) ring0Gates.push({ id, label: row.action })
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// --- Action nodes (always active in v1) ---
|
|
261
|
+
for (const action of ACTIONS) {
|
|
262
|
+
nodes.push({
|
|
263
|
+
id: `action:${action.id}`,
|
|
264
|
+
kind: 'action',
|
|
265
|
+
label: action.label,
|
|
266
|
+
renderState: 'active',
|
|
267
|
+
summary: action.summary,
|
|
268
|
+
detail: action.description,
|
|
269
|
+
crosses: action.crosses
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- Role nodes from roles/*.md frontmatter (skip files without role_id) ---
|
|
274
|
+
const roleIds = new Set<string>()
|
|
275
|
+
for (const file of doctrine.roles) {
|
|
276
|
+
const role = extractRole(file.content)
|
|
277
|
+
if (role === null) continue
|
|
278
|
+
if (roleIds.has(role.roleId)) continue
|
|
279
|
+
roleIds.add(role.roleId)
|
|
280
|
+
nodes.push({
|
|
281
|
+
id: `role:${role.roleId}`,
|
|
282
|
+
kind: 'role',
|
|
283
|
+
label: role.roleId,
|
|
284
|
+
displayLabel: role.title,
|
|
285
|
+
order: role.order,
|
|
286
|
+
renderState: 'active',
|
|
287
|
+
summary: role.summary,
|
|
288
|
+
detail: role.description,
|
|
289
|
+
actorType: role.actorType
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- Contract nodes from contracts/*.md frontmatter (skip without id) ---
|
|
294
|
+
const contracts: ContractFrontmatter[] = []
|
|
295
|
+
for (const file of doctrine.contracts) {
|
|
296
|
+
const contract = extractContract(file.content)
|
|
297
|
+
if (contract === null) continue
|
|
298
|
+
contracts.push(contract)
|
|
299
|
+
nodes.push({
|
|
300
|
+
id: `contract:${contract.contractId}`,
|
|
301
|
+
kind: 'contract',
|
|
302
|
+
label: contract.contractId,
|
|
303
|
+
displayLabel: contract.title,
|
|
304
|
+
order: contract.order,
|
|
305
|
+
renderState: 'active',
|
|
306
|
+
summary: contract.summary,
|
|
307
|
+
detail: contract.description
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- Edges ---
|
|
312
|
+
// guards: ring-0 gate → into-github action, matched by CROSSING_KEYWORDS
|
|
313
|
+
// (case-insensitive substring on the gate row label; one edge per match).
|
|
314
|
+
for (const action of ACTIONS) {
|
|
315
|
+
if (action.crosses !== 'into-github') continue
|
|
316
|
+
const keyword = (CROSSING_KEYWORDS[action.id] ?? '').toLowerCase()
|
|
317
|
+
if (keyword === '') continue
|
|
318
|
+
for (const gate of ring0Gates) {
|
|
319
|
+
if (!gate.label.toLowerCase().includes(keyword)) continue
|
|
320
|
+
edges.push({
|
|
321
|
+
id: `guards:${gate.id}:action:${action.id}`,
|
|
322
|
+
kind: 'guards',
|
|
323
|
+
from: gate.id,
|
|
324
|
+
to: `action:${action.id}`
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// performs: role → action for every ACTIONS.performedBy entry (the canonical
|
|
330
|
+
// single source — the role's own `performs` frontmatter is intentionally not
|
|
331
|
+
// re-read, so the two lists can never drift).
|
|
332
|
+
for (const action of ACTIONS) {
|
|
333
|
+
for (const roleId of action.performedBy) {
|
|
334
|
+
edges.push({
|
|
335
|
+
id: `performs:role:${roleId}:action:${action.id}`,
|
|
336
|
+
kind: 'performs',
|
|
337
|
+
from: `role:${roleId}`,
|
|
338
|
+
to: `action:${action.id}`
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// produces / consumes: role → contract from each contract's producer/consumer.
|
|
344
|
+
for (const contract of contracts) {
|
|
345
|
+
if (contract.producer !== undefined) {
|
|
346
|
+
edges.push({
|
|
347
|
+
id: `produces:role:${contract.producer}:contract:${contract.contractId}`,
|
|
348
|
+
kind: 'produces',
|
|
349
|
+
from: `role:${contract.producer}`,
|
|
350
|
+
to: `contract:${contract.contractId}`
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
if (contract.consumer !== undefined) {
|
|
354
|
+
edges.push({
|
|
355
|
+
id: `consumes:role:${contract.consumer}:contract:${contract.contractId}`,
|
|
356
|
+
kind: 'consumes',
|
|
357
|
+
from: `role:${contract.consumer}`,
|
|
358
|
+
to: `contract:${contract.contractId}`
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// --- Findings: config keys that reference no real gate/check slug. Config
|
|
364
|
+
// references, never invents — surface the drift, don't silently drop it. ---
|
|
365
|
+
for (const key of Object.keys(config?.gates ?? {})) {
|
|
366
|
+
if (!gateCheckSlugs.has(key)) {
|
|
367
|
+
findings.push({ configKey: key, reason: `config gates["${key}"] references no gate or check in doctrine` })
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return { nodes, edges, findings, tranche }
|
|
372
|
+
}
|