@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,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Leftover-worktree/branch classification (aeg-governance-hardening task 11,
|
|
3
|
+
* #324). Pure — no `fs`, no `git` shell-outs. The CLI shim
|
|
4
|
+
* (`bin/verify-dispatch.ts`) gathers the three facts (does the remote branch
|
|
5
|
+
* exist, does a local worktree exist, how many commits is the branch ahead
|
|
6
|
+
* of main) and passes them in.
|
|
7
|
+
*
|
|
8
|
+
* Exists to answer, deterministically, "is it safe to run Step 0's
|
|
9
|
+
* `git worktree add -b <branch> origin/main` for this task?" — Step 0 itself
|
|
10
|
+
* never creates a commit, so ANY commit already ahead of main on this branch
|
|
11
|
+
* is real prior work, never an artifact of re-running Step 0. Silently
|
|
12
|
+
* recreating a branch that already has commits ahead of main would discard
|
|
13
|
+
* that work — the failure this module exists to prevent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type LeftoverInput = {
|
|
17
|
+
/** A `task/<tranche>/<n>` branch already exists on the forge remote. */
|
|
18
|
+
branchExistsRemote: boolean
|
|
19
|
+
/** A local worktree for this task already exists on disk. */
|
|
20
|
+
worktreeExistsLocal: boolean
|
|
21
|
+
/** Commits on the task branch that are not yet on `origin/main`. */
|
|
22
|
+
commitsAheadOfMain: number
|
|
23
|
+
/**
|
|
24
|
+
* An OPEN pull request already exists for this task's branch, or `null`/
|
|
25
|
+
* omitted when unknown or none exists. Optional and additive — a caller
|
|
26
|
+
* that has no PR-number fact (e.g. `verify-dispatch.ts`'s existing call
|
|
27
|
+
* site, which only ever needed the three fields above) is unaffected.
|
|
28
|
+
*/
|
|
29
|
+
openPrNumber?: number | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type LeftoverVerdict = 'clean' | 'resume' | 'stop'
|
|
33
|
+
|
|
34
|
+
export type LeftoverResult = { verdict: LeftoverVerdict; reason: string }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `clean` — no branch, no worktree, zero commits ahead: safe to run Step 0 fresh.
|
|
38
|
+
* `resume` — a branch or worktree already exists but carries zero commits
|
|
39
|
+
* ahead of main (e.g. a prior Step 0 ran but no work was
|
|
40
|
+
* committed yet): reuse the existing worktree rather than
|
|
41
|
+
* recreating it, but there is nothing to lose either way.
|
|
42
|
+
* `stop` — commits already ahead of main exist: real work is already on
|
|
43
|
+
* this branch. Never silently discard it — the Developer must
|
|
44
|
+
* resume in the existing worktree, not re-run Step 0.
|
|
45
|
+
*/
|
|
46
|
+
export function classifyLeftover(input: LeftoverInput): LeftoverResult {
|
|
47
|
+
const { branchExistsRemote, worktreeExistsLocal, commitsAheadOfMain, openPrNumber } = input
|
|
48
|
+
const prSuffix = openPrNumber ? ` PR #${openPrNumber} is already open for this task.` : ''
|
|
49
|
+
|
|
50
|
+
if (commitsAheadOfMain > 0) {
|
|
51
|
+
return {
|
|
52
|
+
verdict: 'stop',
|
|
53
|
+
reason: `${commitsAheadOfMain} commit(s) already ahead of origin/main on this task branch — real work exists.${prSuffix} Do not re-run Step 0 (which would branch fresh from origin/main and orphan that work); resume in the existing worktree instead.`
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (branchExistsRemote || worktreeExistsLocal) {
|
|
58
|
+
return {
|
|
59
|
+
verdict: 'resume',
|
|
60
|
+
reason: `A ${branchExistsRemote ? 'remote branch' : ''}${branchExistsRemote && worktreeExistsLocal ? ' and a ' : ''}${worktreeExistsLocal ? 'local worktree' : ''} already exist for this task, with zero commits ahead of main — reuse it rather than recreating (\`git worktree add\` on an existing branch/dir will fail anyway).`
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
verdict: 'clean',
|
|
66
|
+
reason: 'No branch, no worktree, no commits ahead of main — safe to run Step 0 fresh.'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* local-anchor-coverage.ts — a generic, regex-parameterized "dangling
|
|
3
|
+
* anchor" checker.
|
|
4
|
+
*
|
|
5
|
+
* The pattern this generalizes: `reader-resolvable-prose.ts`'s Class 1
|
|
6
|
+
* (`checkUnresolvableReferences`) flags a reference a reader cannot resolve
|
|
7
|
+
* at all — the pattern (a forge number, a tranche slug) IS the finding, full
|
|
8
|
+
* stop. This module handles the one-step-further case Issue #730 names: a
|
|
9
|
+
* reference that is FINE as long as something else nearby resolves it — an
|
|
10
|
+
* ordinal task mention ("Task 2") is fine beside a citation shaped
|
|
11
|
+
* `(task N, #issue)`, and only a finding when that citation is missing from
|
|
12
|
+
* its scope. Same "pattern is an input, not a constant" discipline as
|
|
13
|
+
* `reader-resolvable-prose.ts`, carried one step further: the CITATION
|
|
14
|
+
* pattern and its co-occurrence SCOPE are inputs too. This file has no
|
|
15
|
+
* `/Task \d+/` literal, no citation-shape regex, no product name — that
|
|
16
|
+
* knowledge belongs to whoever configures an instance (see
|
|
17
|
+
* `scripts/vinaya-checks/task-anchor.ts` for attalabs' own).
|
|
18
|
+
*
|
|
19
|
+
* Zero I/O: mirrors `reader-resolvable-prose.ts`'s in-memory-content shape
|
|
20
|
+
* (`files: { path, content }[]`, adapter-read) rather than
|
|
21
|
+
* `vocabulary-citation.ts`'s `grepFn`/`matchFn` injection. Reason: co-occurrence
|
|
22
|
+
* scoping (§ below) needs the whole file's line structure to group lines into
|
|
23
|
+
* blocks — a per-line grep-hit model would force the adapter to pre-chunk
|
|
24
|
+
* paragraphs itself before calling in, which is exactly the structural work
|
|
25
|
+
* this primitive exists to own.
|
|
26
|
+
*
|
|
27
|
+
* `pattern`/`mustCoOccurWith` are real `RegExp` objects, not POSIX-ERE
|
|
28
|
+
* strings — unlike `vocabulary-citation.ts`, nothing here shells out to
|
|
29
|
+
* `grep -E`, so the `\d`/`\w`/`\s`-avoidance caution that applies there (GNU
|
|
30
|
+
* grep reads those as literal characters) does not apply to this file; native
|
|
31
|
+
* `RegExp` supports them. A consumer that itself uses `grep` for file
|
|
32
|
+
* discovery (as `task-anchor.ts` does not — it reads files directly) would
|
|
33
|
+
* still need that caution on its own end, independent of this primitive.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export type AnchorSourceFile = { path: string; content: string }
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `'line'` — the citation must appear on the same line as the anchor.
|
|
40
|
+
* `'block'` — the citation must appear anywhere in the anchor's enclosing
|
|
41
|
+
* block (see `splitIntoBlocks` below): a markdown paragraph, or a list item
|
|
42
|
+
* together with its nested sub-items and wrapped continuation lines. A
|
|
43
|
+
* blank line, or a new top-level (non-indented) bullet/heading, starts a new
|
|
44
|
+
* block — so two adjacent list items are two different blocks, never one.
|
|
45
|
+
*/
|
|
46
|
+
export type AnchorCoverageScope = 'line' | 'block'
|
|
47
|
+
|
|
48
|
+
export type LocalAnchorCoverageOptions = {
|
|
49
|
+
pattern: RegExp
|
|
50
|
+
mustCoOccurWith: RegExp
|
|
51
|
+
scope: AnchorCoverageScope
|
|
52
|
+
/** Non-vacuity self-test (optional): `pattern` must match this string. */
|
|
53
|
+
sample?: string
|
|
54
|
+
/** Non-vacuity self-test (optional): `mustCoOccurWith` must match this string. */
|
|
55
|
+
coOccurSample?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type AnchorCoverageFinding = {
|
|
59
|
+
file: string
|
|
60
|
+
line: number
|
|
61
|
+
match: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type LocalAnchorCoverageResult = {
|
|
65
|
+
findings: AnchorCoverageFinding[]
|
|
66
|
+
/**
|
|
67
|
+
* Names of whichever supplied sample(s) failed to self-match —
|
|
68
|
+
* `'pattern'` and/or `'mustCoOccurWith'`. A config bug reported loud
|
|
69
|
+
* instead of silently returning clean, same discipline as
|
|
70
|
+
* `vocabulary-citation.ts`'s `vacuousPatterns`.
|
|
71
|
+
*/
|
|
72
|
+
vacuous: ('pattern' | 'mustCoOccurWith')[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Strips `g`/`y` flags so a caller-supplied global/sticky regex can't leak `lastIndex` state into a one-shot `.test()`. */
|
|
76
|
+
function testFresh(re: RegExp, text: string): boolean {
|
|
77
|
+
const flags = re.flags.replace(/[gy]/g, '')
|
|
78
|
+
return new RegExp(re.source, flags).test(text)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Ensures the regex used to iterate every match carries the `g` flag, without mutating the caller's object. */
|
|
82
|
+
function toGlobalIterator(re: RegExp): RegExp {
|
|
83
|
+
const flags = re.flags.includes('g') ? re.flags : `${re.flags}g`
|
|
84
|
+
return new RegExp(re.source, flags)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Total match count of `re` in `text` — the co-occurrence "supply" a scope offers. */
|
|
88
|
+
function countMatches(re: RegExp, text: string): number {
|
|
89
|
+
const iter = toGlobalIterator(re)
|
|
90
|
+
let count = 0
|
|
91
|
+
iter.lastIndex = 0
|
|
92
|
+
let m = iter.exec(text)
|
|
93
|
+
while (m !== null) {
|
|
94
|
+
count++
|
|
95
|
+
if (m.index === iter.lastIndex) iter.lastIndex++
|
|
96
|
+
m = iter.exec(text)
|
|
97
|
+
}
|
|
98
|
+
return count
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function computeLineStarts(content: string): number[] {
|
|
102
|
+
const starts = [0]
|
|
103
|
+
for (let i = 0; i < content.length; i++) {
|
|
104
|
+
if (content.charCodeAt(i) === 10) starts.push(i + 1)
|
|
105
|
+
}
|
|
106
|
+
return starts
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 1-based line number containing character offset `index`, via binary search over `lineStarts`. */
|
|
110
|
+
function lineNumberAt(lineStarts: number[], index: number): number {
|
|
111
|
+
let lo = 0
|
|
112
|
+
let hi = lineStarts.length - 1
|
|
113
|
+
while (lo < hi) {
|
|
114
|
+
const mid = (lo + hi + 1) >> 1
|
|
115
|
+
if ((lineStarts[mid] as number) <= index) lo = mid
|
|
116
|
+
else hi = mid - 1
|
|
117
|
+
}
|
|
118
|
+
return lo + 1
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const BLANK_LINE = /^\s*$/
|
|
122
|
+
/** A top-level (non-indented) list marker — `-`/`*`/`+` or `N.` — starts a new block; an indented nested item does not. */
|
|
123
|
+
const TOP_LEVEL_BULLET = /^(?:[-*+]|[0-9]+\.)\s/
|
|
124
|
+
const HEADING_LINE = /^#{1,6}\s/
|
|
125
|
+
|
|
126
|
+
type Block = { startLine: number; endLine: number; text: string }
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Groups a file's lines into scope blocks: a blank line ends the current
|
|
130
|
+
* block; a new top-level bullet or heading also ends it (and starts its
|
|
131
|
+
* own); every other line — including an indented/nested bullet, or a
|
|
132
|
+
* wrapped continuation line — extends the current block. This is what makes
|
|
133
|
+
* "same paragraph/bullet" well-defined rather than a guess: two sibling
|
|
134
|
+
* list items on adjacent lines with no blank line between them are still
|
|
135
|
+
* two separate blocks (each starts its own on hitting the next top-level
|
|
136
|
+
* marker), while a nested sub-bullet stays part of its parent's block (an
|
|
137
|
+
* elaboration of the same anchor, not a separate one).
|
|
138
|
+
*/
|
|
139
|
+
function splitIntoBlocks(content: string): Block[] {
|
|
140
|
+
const lines = content.split('\n')
|
|
141
|
+
const blocks: Block[] = []
|
|
142
|
+
let current: string[] = []
|
|
143
|
+
let currentStartIdx = 0
|
|
144
|
+
|
|
145
|
+
const flush = (): void => {
|
|
146
|
+
if (current.length === 0) return
|
|
147
|
+
blocks.push({
|
|
148
|
+
startLine: currentStartIdx + 1,
|
|
149
|
+
endLine: currentStartIdx + current.length,
|
|
150
|
+
text: current.join('\n')
|
|
151
|
+
})
|
|
152
|
+
current = []
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
156
|
+
const line = lines[i] as string
|
|
157
|
+
if (BLANK_LINE.test(line)) {
|
|
158
|
+
flush()
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
const isHeading = HEADING_LINE.test(line)
|
|
162
|
+
const startsNewBlock = TOP_LEVEL_BULLET.test(line) || isHeading
|
|
163
|
+
if (startsNewBlock && current.length > 0) flush()
|
|
164
|
+
if (current.length === 0) currentStartIdx = i
|
|
165
|
+
current.push(line)
|
|
166
|
+
// A heading is always a single-line block — it never absorbs the prose
|
|
167
|
+
// that follows it (that prose's own citation does not clear an anchor
|
|
168
|
+
// inside the heading's own text).
|
|
169
|
+
if (isHeading) flush()
|
|
170
|
+
}
|
|
171
|
+
flush()
|
|
172
|
+
return blocks
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Flags every match of `pattern` that has no `mustCoOccurWith` match within
|
|
177
|
+
* its scope. Pure — takes file contents, returns findings; performs no I/O
|
|
178
|
+
* and knows nothing about what `pattern`/`mustCoOccurWith` mean.
|
|
179
|
+
*
|
|
180
|
+
* **Co-occurrence is counted, not merely detected.** A scope with two
|
|
181
|
+
* `pattern` matches and only one `mustCoOccurWith` match must flag one of
|
|
182
|
+
* them — checking "does a citation exist anywhere in this scope" lets a
|
|
183
|
+
* SINGLE real citation silently clear every other, unrelated anchor sharing
|
|
184
|
+
* the same block (found live in review: `"Task 2 did X, and Task 5 (task 5,
|
|
185
|
+
* #999) wrapped it up."` reported zero findings under a presence-only test,
|
|
186
|
+
* because "Task 5"'s citation cleared the unrelated "Task 2" anchor too).
|
|
187
|
+
* Each scope's citation matches are a fixed SUPPLY; each pattern match in
|
|
188
|
+
* that scope, in document order, consumes one — the first `N` matches (`N` =
|
|
189
|
+
* the scope's citation count) are covered, everything past that is a
|
|
190
|
+
* finding. This does not verify WHICH citation belongs to WHICH anchor
|
|
191
|
+
* (this primitive has no product-specific notion of "the same number") —
|
|
192
|
+
* only that supply meets demand, which is exactly what a purely structural,
|
|
193
|
+
* product-agnostic primitive can promise.
|
|
194
|
+
*/
|
|
195
|
+
export function checkLocalAnchorCoverage(
|
|
196
|
+
files: readonly AnchorSourceFile[],
|
|
197
|
+
options: LocalAnchorCoverageOptions
|
|
198
|
+
): LocalAnchorCoverageResult {
|
|
199
|
+
const vacuous: ('pattern' | 'mustCoOccurWith')[] = []
|
|
200
|
+
if (options.sample !== undefined && !testFresh(options.pattern, options.sample)) vacuous.push('pattern')
|
|
201
|
+
if (options.coOccurSample !== undefined && !testFresh(options.mustCoOccurWith, options.coOccurSample)) {
|
|
202
|
+
vacuous.push('mustCoOccurWith')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const findings: AnchorCoverageFinding[] = []
|
|
206
|
+
const iterPattern = toGlobalIterator(options.pattern)
|
|
207
|
+
|
|
208
|
+
for (const file of files) {
|
|
209
|
+
const lineStarts = computeLineStarts(file.content)
|
|
210
|
+
const lines = file.content.split('\n')
|
|
211
|
+
const blocks = options.scope === 'block' ? splitIntoBlocks(file.content) : null
|
|
212
|
+
|
|
213
|
+
// Scope key: the block object itself (`'block'` scope) or the line
|
|
214
|
+
// number (`'line'` scope) — matches sharing a key share one citation
|
|
215
|
+
// supply, consumed in document order.
|
|
216
|
+
const remainingSupply = new Map<Block | number, number>()
|
|
217
|
+
const supplyFor = (key: Block | number, scopeText: string): number => {
|
|
218
|
+
const cached = remainingSupply.get(key)
|
|
219
|
+
if (cached !== undefined) return cached
|
|
220
|
+
const supply = countMatches(options.mustCoOccurWith, scopeText)
|
|
221
|
+
remainingSupply.set(key, supply)
|
|
222
|
+
return supply
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
iterPattern.lastIndex = 0
|
|
226
|
+
let match: RegExpExecArray | null = iterPattern.exec(file.content)
|
|
227
|
+
while (match !== null) {
|
|
228
|
+
const line = lineNumberAt(lineStarts, match.index)
|
|
229
|
+
const block = blocks ? (blocks.find((b) => line >= b.startLine && line <= b.endLine) ?? null) : null
|
|
230
|
+
const scopeText = block ? block.text : (lines[line - 1] ?? '')
|
|
231
|
+
const key: Block | number = block ?? line
|
|
232
|
+
const supply = supplyFor(key, scopeText)
|
|
233
|
+
if (supply > 0) {
|
|
234
|
+
remainingSupply.set(key, supply - 1)
|
|
235
|
+
} else {
|
|
236
|
+
findings.push({ file: file.path, line, match: match[0] })
|
|
237
|
+
}
|
|
238
|
+
// Guard against a zero-width match looping forever.
|
|
239
|
+
if (match.index === iterPattern.lastIndex) iterPattern.lastIndex++
|
|
240
|
+
match = iterPattern.exec(file.content)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { findings, vacuous }
|
|
245
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ring-0 main-branch refusal (task 9, Issue #58). Mechanizes the
|
|
3
|
+
* worktree-plus-PR rule at ring 0 for adopters: refuses a commit or push
|
|
4
|
+
* whose current branch IS the repo's default branch — today enforced only
|
|
5
|
+
* by attalabs' hand-written husky shell (which `init` never generates) and
|
|
6
|
+
* detected post-merge by `audit --only=direct-push`. Pure — no `git`/`fs`
|
|
7
|
+
* I/O; the caller derives both branch facts and passes them in, same
|
|
8
|
+
* discipline as `no-disk-state.ts`.
|
|
9
|
+
*
|
|
10
|
+
* The discriminator that makes this safe: refusal keys on the SYMBOLIC
|
|
11
|
+
* current branch equaling the default branch. A detached HEAD (every CI
|
|
12
|
+
* checkout, or a deliberate `git checkout --detach`) has no symbolic branch
|
|
13
|
+
* name at all — `currentSymbolicBranch` is `null` — and always passes,
|
|
14
|
+
* never refused. Any branch other than the default passes too. Only a
|
|
15
|
+
* real, named local branch that literal-matches the default branch name
|
|
16
|
+
* refuses. A plain `vinaya check --all` run while parked on the default
|
|
17
|
+
* branch locally WILL refuse — that is the intended behavior (work belongs
|
|
18
|
+
* in worktrees), not a bug.
|
|
19
|
+
*
|
|
20
|
+
* `defaultBranch: null` means the caller could not derive the default
|
|
21
|
+
* branch (no resolvable `origin/HEAD`, no forge answer) — this predicate
|
|
22
|
+
* fails OPEN with a `warning` finding naming why, never a false refusal: a
|
|
23
|
+
* check whose job is refusing risky actions must not itself risk refusing
|
|
24
|
+
* a legitimate one it cannot actually evaluate.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type MainBranchRefusalReason = 'on-default-branch' | 'default-branch-undetermined'
|
|
28
|
+
|
|
29
|
+
export type MainBranchRefusalFinding = {
|
|
30
|
+
reason: MainBranchRefusalReason
|
|
31
|
+
severity: 'error' | 'warning'
|
|
32
|
+
currentBranch: string
|
|
33
|
+
defaultBranch: string | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function checkMainBranchRefusal(facts: {
|
|
37
|
+
currentSymbolicBranch: string | null
|
|
38
|
+
defaultBranch: string | null
|
|
39
|
+
}): MainBranchRefusalFinding | null {
|
|
40
|
+
const { currentSymbolicBranch, defaultBranch } = facts
|
|
41
|
+
|
|
42
|
+
// Detached HEAD — no symbolic branch to compare. Never refuse.
|
|
43
|
+
if (currentSymbolicBranch === null) return null
|
|
44
|
+
|
|
45
|
+
if (defaultBranch === null) {
|
|
46
|
+
return {
|
|
47
|
+
reason: 'default-branch-undetermined',
|
|
48
|
+
severity: 'warning',
|
|
49
|
+
currentBranch: currentSymbolicBranch,
|
|
50
|
+
defaultBranch: null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (currentSymbolicBranch === defaultBranch) {
|
|
55
|
+
return {
|
|
56
|
+
reason: 'on-default-branch',
|
|
57
|
+
severity: 'error',
|
|
58
|
+
currentBranch: currentSymbolicBranch,
|
|
59
|
+
defaultBranch
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc-owners manifest validity checks (M1/M2/M3) plus the `# no-doc:` allow-list
|
|
3
|
+
* parser. Pure — file-existence is injected by the caller.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DOC_OWNERS_PATH, globToRegex, isUrlPointer, parseDocOwners, pointerToPath } from './doc-owners'
|
|
7
|
+
|
|
8
|
+
export type NoDocRule = { glob: string; reason: string }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse `# no-doc: <glob> — <reason>` allow-list lines from doc-owners content.
|
|
12
|
+
* These lines exempt a surface from the completeness scoreboard.
|
|
13
|
+
*/
|
|
14
|
+
export function parseNoDocRules(content: string): NoDocRule[] {
|
|
15
|
+
const rules: NoDocRule[] = []
|
|
16
|
+
for (const line of content.split('\n')) {
|
|
17
|
+
const m = line.match(/^#\s+no-doc:\s+(\S+)(?:\s+[—–]\s+|\s+-\s+)(.+)$/)
|
|
18
|
+
if (m) rules.push({ glob: (m[1] ?? '').trim(), reason: (m[2] ?? '').trim() })
|
|
19
|
+
}
|
|
20
|
+
return rules
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validate the doc-owners manifest file (all bindings, not just fired ones).
|
|
25
|
+
* M1 (hard-fail): in-repo pointer does not exist on disk.
|
|
26
|
+
* M2 (advisory): glob fails to produce a valid regex (extremely unlikely with our simple grammar).
|
|
27
|
+
* M3 (hard-fail): same glob appears more than once.
|
|
28
|
+
*/
|
|
29
|
+
export function checkManifestValidity(
|
|
30
|
+
content: string | null,
|
|
31
|
+
fileExists: (p: string) => boolean
|
|
32
|
+
): { m1Errors: string[]; m2Notes: string[]; m3Errors: string[]; noDocRules: NoDocRule[] } {
|
|
33
|
+
if (content === null) return { m1Errors: [], m2Notes: [], m3Errors: [], noDocRules: [] }
|
|
34
|
+
|
|
35
|
+
const { bindings, errors: parseErrors } = parseDocOwners(content)
|
|
36
|
+
const m1Errors: string[] = [...parseErrors]
|
|
37
|
+
const m2Notes: string[] = []
|
|
38
|
+
const m3Errors: string[] = []
|
|
39
|
+
const noDocRules = parseNoDocRules(content)
|
|
40
|
+
|
|
41
|
+
// M2: glob syntax (advisory — our grammar is permissive so failures are very rare)
|
|
42
|
+
for (const b of bindings) {
|
|
43
|
+
if (!b.glob) {
|
|
44
|
+
m2Notes.push(`M2 manifest-bad-glob: ${DOC_OWNERS_PATH}:${b.lineNum} — empty glob.`)
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
globToRegex(b.glob)
|
|
49
|
+
} catch (e) {
|
|
50
|
+
m2Notes.push(`M2 manifest-bad-glob: ${DOC_OWNERS_PATH}:${b.lineNum} — glob "${b.glob}" failed: ${e}`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// M3: duplicate globs
|
|
55
|
+
const globLines = new Map<string, number[]>()
|
|
56
|
+
for (const b of bindings) {
|
|
57
|
+
const list = globLines.get(b.glob) ?? []
|
|
58
|
+
list.push(b.lineNum)
|
|
59
|
+
globLines.set(b.glob, list)
|
|
60
|
+
}
|
|
61
|
+
for (const [glob, lines] of globLines) {
|
|
62
|
+
if (lines.length > 1) {
|
|
63
|
+
m3Errors.push(
|
|
64
|
+
`M3 manifest-duplicate-glob: ${DOC_OWNERS_PATH} — glob "${glob}" appears ${lines.length} times (lines ${lines.join(', ')}).`
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// M1: every in-repo pointer exists on disk (checks ALL bindings, not just fired ones)
|
|
70
|
+
for (const b of bindings) {
|
|
71
|
+
if (isUrlPointer(b.pointer)) continue
|
|
72
|
+
const pointerPath = pointerToPath(b.pointer)
|
|
73
|
+
if (!fileExists(pointerPath)) {
|
|
74
|
+
m1Errors.push(
|
|
75
|
+
`M1 manifest-dangling: ${DOC_OWNERS_PATH}:${b.lineNum} — pointer ${b.pointer} does not exist on disk.`
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { m1Errors, m2Notes, m3Errors, noDocRules }
|
|
81
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, no-I/O extraction of GitHub-flavored-markdown tables from raw text.
|
|
3
|
+
* The single, aeg-core-owned parser every doctrine consumer reads tables
|
|
4
|
+
* through — the Vinaya `/aeg` renderer, `registry-parse.ts`, and the
|
|
5
|
+
* DiagramModel derivation all sit on this one implementation, so no consumer
|
|
6
|
+
* can silently drift from the real file the moment a row's wording changes
|
|
7
|
+
* (one parser, N consumers).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type TableRow = {
|
|
11
|
+
/** Raw cell text per column, in header order. */
|
|
12
|
+
cells: string[]
|
|
13
|
+
/** 1-indexed line number of this row in the source file. */
|
|
14
|
+
line: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ParsedTable = {
|
|
18
|
+
headers: string[]
|
|
19
|
+
rows: TableRow[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function splitRow(line: string): string[] {
|
|
23
|
+
const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '')
|
|
24
|
+
return trimmed.split('|').map((cell) => cell.trim())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const TABLE_ROW_PATTERN = /^\s*\|.*\|\s*$/
|
|
28
|
+
const SEPARATOR_ROW_PATTERN = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Finds the FIRST markdown table appearing at or after `fromLine` (1-indexed,
|
|
32
|
+
* inclusive) in `lines`. Returns null if none is found before the next `## `
|
|
33
|
+
* heading (or end of file) when `stopAtHeading` is true.
|
|
34
|
+
*/
|
|
35
|
+
export function findTable(lines: string[], fromLine: number, stopAtHeading = true): ParsedTable | null {
|
|
36
|
+
let i = fromLine - 1
|
|
37
|
+
while (i < lines.length) {
|
|
38
|
+
const line = lines[i] ?? ''
|
|
39
|
+
if (stopAtHeading && i > fromLine - 1 && /^##\s/.test(line)) return null
|
|
40
|
+
if (TABLE_ROW_PATTERN.test(line)) {
|
|
41
|
+
const headerLine = line
|
|
42
|
+
const sepLine = lines[i + 1] ?? ''
|
|
43
|
+
if (!SEPARATOR_ROW_PATTERN.test(sepLine)) {
|
|
44
|
+
i++
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
const headers = splitRow(headerLine)
|
|
48
|
+
const rows: TableRow[] = []
|
|
49
|
+
let j = i + 2
|
|
50
|
+
while (j < lines.length && TABLE_ROW_PATTERN.test(lines[j] ?? '')) {
|
|
51
|
+
rows.push({ cells: splitRow(lines[j] ?? ''), line: j + 1 })
|
|
52
|
+
j++
|
|
53
|
+
}
|
|
54
|
+
return { headers, rows }
|
|
55
|
+
}
|
|
56
|
+
i++
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Finds the 1-indexed line of the first heading matching `pattern`, or null. */
|
|
62
|
+
export function findHeadingLine(lines: string[], pattern: RegExp): number | null {
|
|
63
|
+
for (let i = 0; i < lines.length; i++) {
|
|
64
|
+
if (pattern.test(lines[i] ?? '')) return i + 1
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Convenience: row cells as a keyed object using `headers` as keys. */
|
|
70
|
+
export function rowToRecord(headers: string[], row: TableRow): Record<string, string> {
|
|
71
|
+
const record: Record<string, string> = {}
|
|
72
|
+
headers.forEach((h, idx) => {
|
|
73
|
+
record[h] = row.cells[idx] ?? ''
|
|
74
|
+
})
|
|
75
|
+
return record
|
|
76
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hardened I/O for `MeteringCapabilityDeps` (CWE-59, `#313`). Every real
|
|
3
|
+
* caller of `resolveMeteringCapability` fills `exists`/`readFile` with the
|
|
4
|
+
* factory here rather than hand-rolling `existsSync`/`readFileSync`, so the
|
|
5
|
+
* guard lives once at the shared seam instead of being re-derived (or
|
|
6
|
+
* missed) at each call site.
|
|
7
|
+
*
|
|
8
|
+
* `claude-code-transcript.ts` stays pure (no `fs`, no `process.env`) — this
|
|
9
|
+
* sibling module is `aeg-core`'s one deliberate exception, exported
|
|
10
|
+
* alongside it rather than living in `bin/`: `apps/cli` only ever sees this
|
|
11
|
+
* package's `src/index.ts` surface, and `bin/` is not part of it (see that
|
|
12
|
+
* file's own header on the `bin/` vs `src/` split).
|
|
13
|
+
*
|
|
14
|
+
* The pointer path this guards (`$TMPDIR/claude-transcript-<key>.txt`) is
|
|
15
|
+
* fully predictable and, on the usual CI/Linux default with `TMPDIR` unset,
|
|
16
|
+
* sits in a directory other local users can typically write to. The write
|
|
17
|
+
* side of this exact path was hardened already
|
|
18
|
+
* (`apps/cli/src/lib/claude-stop-hook-emitter.ts`, CWE-59: `wx` +
|
|
19
|
+
* `renameSync`, refusing to write through a symlink). This is the read
|
|
20
|
+
* side's turn, against the same threat model.
|
|
21
|
+
*
|
|
22
|
+
* Mechanism: open with `O_NOFOLLOW | O_NONBLOCK`, then `fstat` the
|
|
23
|
+
* resulting descriptor — never a separate `lstat`-then-`open` pair, which
|
|
24
|
+
* still races between the two calls (a symlink or FIFO planted in the gap
|
|
25
|
+
* defeats the earlier check). `O_NOFOLLOW` makes the kernel refuse an open
|
|
26
|
+
* through a symlink outright (`ELOOP`) rather than trusting a stat taken a
|
|
27
|
+
* moment earlier. `O_NONBLOCK` is what actually closes the FIFO hang:
|
|
28
|
+
* opening a FIFO for reading in the default blocking mode waits for a
|
|
29
|
+
* writer that may never come — the reviewer's finding, 20+ seconds, killed
|
|
30
|
+
* by their own alarm rather than by the process. With `O_NONBLOCK` the open
|
|
31
|
+
* returns immediately regardless of whether a writer exists, and `fstat`'s
|
|
32
|
+
* `isFile()` then refuses the descriptor before any read is attempted, so
|
|
33
|
+
* the read call this guards never has a FIFO to block on in the first
|
|
34
|
+
* place. Neither flag changes behavior for a genuine regular file.
|
|
35
|
+
* Confirmed against a live planted symlink and a live FIFO, on both Node
|
|
36
|
+
* and Bun, before writing this comment.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { closeSync, constants as fsConstants, fstatSync, openSync, readFileSync } from 'node:fs'
|
|
40
|
+
import type { MeteringCapabilityDeps } from './claude-code-transcript'
|
|
41
|
+
|
|
42
|
+
const GUARD_OPEN_FLAGS = fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The one property both `exists` and `readFile` require of an already-open
|
|
46
|
+
* descriptor before its content is trusted: a regular file (never a FIFO,
|
|
47
|
+
* device, directory, or — moot once `O_NOFOLLOW` has run — a symlink),
|
|
48
|
+
* owned by this process's own user on a platform that reports one at all.
|
|
49
|
+
*
|
|
50
|
+
* Split out from the `openSync`/`fstatSync` pair so the foreign-owner
|
|
51
|
+
* refusal — not constructible in CI without a second real local user — stays
|
|
52
|
+
* unit-testable by handing this function a faked stat directly, rather than
|
|
53
|
+
* needing an actual cross-user fixture.
|
|
54
|
+
*/
|
|
55
|
+
export function isTrustedMeteringStat(stat: { isFile(): boolean; uid: number }, ownUid: number | undefined): boolean {
|
|
56
|
+
if (!stat.isFile()) return false
|
|
57
|
+
if (typeof ownUid === 'number' && stat.uid !== ownUid) return false
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ownUid(): number | undefined {
|
|
62
|
+
return typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function guardedOpen(path: string): number | undefined {
|
|
66
|
+
try {
|
|
67
|
+
return openSync(path, GUARD_OPEN_FLAGS)
|
|
68
|
+
} catch {
|
|
69
|
+
// Covers a missing path (`ENOENT`), a symlink (`ELOOP`, `O_NOFOLLOW`),
|
|
70
|
+
// and anything else the platform refuses to open under these flags —
|
|
71
|
+
// all degrade to "not usable", never a throw. An attacker must not be
|
|
72
|
+
// able to turn a probe into a crash either.
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hardenedExists(path: string): boolean {
|
|
78
|
+
const fd = guardedOpen(path)
|
|
79
|
+
if (fd === undefined) return false
|
|
80
|
+
try {
|
|
81
|
+
return isTrustedMeteringStat(fstatSync(fd), ownUid())
|
|
82
|
+
} finally {
|
|
83
|
+
closeSync(fd)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hardenedReadFile(path: string): string {
|
|
88
|
+
const fd = guardedOpen(path)
|
|
89
|
+
if (fd === undefined) {
|
|
90
|
+
throw new Error(`refusing to read ${path}: not openable as a plain, non-symlinked file`)
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
if (!isTrustedMeteringStat(fstatSync(fd), ownUid())) {
|
|
94
|
+
throw new Error(`refusing to read ${path}: not a regular file owned by this user`)
|
|
95
|
+
}
|
|
96
|
+
// Reads the already-open, already-checked descriptor directly — never
|
|
97
|
+
// reopens by path, which would reintroduce the exact TOCTOU gap this
|
|
98
|
+
// module exists to close.
|
|
99
|
+
return readFileSync(fd, 'utf8')
|
|
100
|
+
} finally {
|
|
101
|
+
closeSync(fd)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The hardened `MeteringCapabilityDeps` every real caller of
|
|
107
|
+
* `resolveMeteringCapability` should build from, in place of hand-rolled
|
|
108
|
+
* `existsSync`/`readFileSync`. `env`/`cwd` carry no I/O hazard of their own;
|
|
109
|
+
* every current call site reads them the same way, so this reads them
|
|
110
|
+
* directly rather than taking them as parameters no caller would vary.
|
|
111
|
+
*/
|
|
112
|
+
export function hardenedMeteringDeps(): MeteringCapabilityDeps {
|
|
113
|
+
return {
|
|
114
|
+
env: process.env,
|
|
115
|
+
cwd: process.cwd(),
|
|
116
|
+
exists: hardenedExists,
|
|
117
|
+
readFile: hardenedReadFile
|
|
118
|
+
}
|
|
119
|
+
}
|