@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,183 @@
|
|
|
1
|
+
import { readMarkdownTable } from './parse-registry'
|
|
2
|
+
import type { Tranche, Lifecycle, Task } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse `aeg-root/tranches/<name>.md` into a typed `Tranche`.
|
|
6
|
+
*
|
|
7
|
+
* Captures:
|
|
8
|
+
* - the tranche's name (from `# Tranche: <name> — <timeframe>`; the
|
|
9
|
+
* superseded `# Iteration:` spelling is still read, see `parseName`)
|
|
10
|
+
* - the lifecycle marker (`Lifecycle: active|complete`, defaulting to
|
|
11
|
+
* `'active'` when absent — pre-§11 files have no marker)
|
|
12
|
+
* - the first goal paragraph
|
|
13
|
+
* - the `## Tasks (topology)` table rows
|
|
14
|
+
* - per-task rationale blocks (raw markdown, captured verbatim)
|
|
15
|
+
* - an optional `## Backlog` section as bullets
|
|
16
|
+
*
|
|
17
|
+
* Real tranche files include narrative references to dropped task ids in
|
|
18
|
+
* prose (e.g. a "Task 3a — removed" note). The parser ignores those — only
|
|
19
|
+
* rows present in the topology table become tasks.
|
|
20
|
+
*/
|
|
21
|
+
export function parseTranche(md: string): Tranche {
|
|
22
|
+
return {
|
|
23
|
+
name: parseName(md),
|
|
24
|
+
lifecycle: parseLifecycle(md),
|
|
25
|
+
goal: parseGoal(md),
|
|
26
|
+
tasks: parseTasks(md),
|
|
27
|
+
backlog: parseBacklog(md)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseName(md: string): string {
|
|
32
|
+
// The H1 is `# Tranche: <slug> — <timeframe>`. The slug may contain
|
|
33
|
+
// hyphens (e.g. `some-tranche-name`, `another-v1`), so capture the first
|
|
34
|
+
// non-whitespace run — the space before the em-dash is the delimiter.
|
|
35
|
+
//
|
|
36
|
+
// `Iteration:` is accepted as the superseded spelling, permanently. This is
|
|
37
|
+
// a READER of files this repo does not necessarily own: content pulled out
|
|
38
|
+
// of git history predates the rename and cannot be rewritten, and an
|
|
39
|
+
// adopter's own topology files are theirs, not ours to migrate. Refusing
|
|
40
|
+
// the old marker would not fail loudly — `parseName` returns `''`, which
|
|
41
|
+
// reads downstream as a nameless tranche. The writer side is unaffected:
|
|
42
|
+
// every file this repo emits or archives carries `# Tranche:`.
|
|
43
|
+
const m = md.match(/^#\s+(?:Tranche|Iteration):\s+(\S+)/m)
|
|
44
|
+
return m?.[1] ? m[1].trim() : ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseLifecycle(md: string): Lifecycle {
|
|
48
|
+
// `Lifecycle: active` or `Lifecycle: complete`. The label may be wrapped in
|
|
49
|
+
// bold (`**Lifecycle:** active`); the closing `**` sits after the colon.
|
|
50
|
+
const m = md.match(/^\s*(?:\*\*)?Lifecycle\s*:(?:\*\*)?\s*(active|complete)\b/im)
|
|
51
|
+
return m?.[1] ? (m[1].toLowerCase() as Lifecycle) : 'active'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseGoal(md: string): string {
|
|
55
|
+
// Match `Goal (…): <text>` to the end of the paragraph.
|
|
56
|
+
// The live files use various subtitles ("execution, not roadmap-why",
|
|
57
|
+
// "execution, not product-why"); strip everything up to the colon and
|
|
58
|
+
// capture the first paragraph that follows.
|
|
59
|
+
const lines = md.split(/\r?\n/)
|
|
60
|
+
const buf: string[] = []
|
|
61
|
+
let inGoal = false
|
|
62
|
+
for (const raw of lines) {
|
|
63
|
+
const line = stripBoldWrappers(raw)
|
|
64
|
+
if (!inGoal) {
|
|
65
|
+
const m = line.match(/^Goal\b[^:]*:\s*(.*)$/i)
|
|
66
|
+
if (m) {
|
|
67
|
+
inGoal = true
|
|
68
|
+
const rest = (m[1] ?? '').trim()
|
|
69
|
+
if (rest) buf.push(rest)
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
if (line.trim() === '') break
|
|
73
|
+
// Stop at the next markdown heading or HR.
|
|
74
|
+
if (/^#{1,6}\s/.test(line) || /^---+\s*$/.test(line)) break
|
|
75
|
+
buf.push(line.trim())
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return buf.join(' ').trim()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stripBoldWrappers(line: string): string {
|
|
82
|
+
// Remove bold markers around `Goal …:` or `Lifecycle:` style lines so the
|
|
83
|
+
// greedy regexes above work on either `**Goal:**` or plain `Goal:`.
|
|
84
|
+
return line.replace(/\*\*/g, '')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseTasks(md: string): Task[] {
|
|
88
|
+
const rows = readMarkdownTable(md, /^##\s+Tasks\b/i)
|
|
89
|
+
const rationales = extractRationales(md)
|
|
90
|
+
const tasks: Task[] = []
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
// Expected 6 columns: # | Task | Issue | Project(s) | Depends-on | Conflicts-with.
|
|
93
|
+
if (row.length < 6) continue
|
|
94
|
+
const id = (row[0] ?? '').trim()
|
|
95
|
+
if (!id) continue
|
|
96
|
+
tasks.push({
|
|
97
|
+
id,
|
|
98
|
+
title: (row[1] ?? '').trim(),
|
|
99
|
+
issue: parseIssueCell(row[2] ?? ''),
|
|
100
|
+
projects: parseCsvCell(row[3] ?? ''),
|
|
101
|
+
dependsOn: parseEdgeCell(row[4] ?? ''),
|
|
102
|
+
conflictsWith: parseEdgeCell(row[5] ?? ''),
|
|
103
|
+
rationaleMarkdown: rationales.get(id) ?? ''
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
return tasks
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseIssueCell(cell: string): number | null {
|
|
110
|
+
const trimmed = stripBackticks(cell).trim()
|
|
111
|
+
if (!trimmed) return null
|
|
112
|
+
if (isEmDashOrDash(trimmed)) return null
|
|
113
|
+
const m = trimmed.match(/#?(\d+)/)
|
|
114
|
+
return m ? Number(m[1]) : null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseCsvCell(cell: string): string[] {
|
|
118
|
+
const cleaned = stripBackticks(cell).trim()
|
|
119
|
+
if (!cleaned || isEmDashOrDash(cleaned)) return []
|
|
120
|
+
return cleaned
|
|
121
|
+
.split(',')
|
|
122
|
+
.map((s) => s.trim())
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseEdgeCell(cell: string): string[] {
|
|
127
|
+
return parseCsvCell(cell)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isEmDashOrDash(s: string): boolean {
|
|
131
|
+
const t = s.trim()
|
|
132
|
+
return t === '—' || t === '-' || t === '–'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function stripBackticks(s: string): string {
|
|
136
|
+
return s.replace(/`/g, '')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Capture each `### Task <id> — …` block as raw markdown — from the heading
|
|
141
|
+
* through the line before the next `### ` / `## ` / `# ` / end-of-file.
|
|
142
|
+
* The id may be `1`, `7a`, `7b`, etc. Per-task rationale lives inline in the
|
|
143
|
+
* tranche file (see §4 template); the planner's rationale also lives in the
|
|
144
|
+
* Issue body, but the inline copy is what `parseTranche` exposes.
|
|
145
|
+
*/
|
|
146
|
+
function extractRationales(md: string): Map<string, string> {
|
|
147
|
+
const out = new Map<string, string>()
|
|
148
|
+
const lines = md.split(/\r?\n/)
|
|
149
|
+
let i = 0
|
|
150
|
+
while (i < lines.length) {
|
|
151
|
+
const m = (lines[i] ?? '').match(/^###\s+Task\s+(\S+)\s+—/)
|
|
152
|
+
if (!m?.[1]) {
|
|
153
|
+
i++
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
const id = m[1]
|
|
157
|
+
const start = i
|
|
158
|
+
i++
|
|
159
|
+
while (i < lines.length && !/^#{1,3}\s/.test(lines[i] ?? '')) i++
|
|
160
|
+
out.set(id, lines.slice(start, i).join('\n').trimEnd())
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseBacklog(md: string): string[] {
|
|
166
|
+
const lines = md.split(/\r?\n/)
|
|
167
|
+
let i = 0
|
|
168
|
+
for (; i < lines.length; i++) {
|
|
169
|
+
if (/^##\s+Backlog\b/i.test(lines[i] ?? '')) {
|
|
170
|
+
i++
|
|
171
|
+
break
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (i >= lines.length) return []
|
|
175
|
+
const bullets: string[] = []
|
|
176
|
+
for (; i < lines.length; i++) {
|
|
177
|
+
const line = lines[i] ?? ''
|
|
178
|
+
if (/^#{1,3}\s/.test(line)) break
|
|
179
|
+
const m = line.match(/^\s*[-*+]\s+(.*)$/)
|
|
180
|
+
if (m?.[1]) bullets.push(m[1].trim())
|
|
181
|
+
}
|
|
182
|
+
return bullets
|
|
183
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR tier derivation and the docs-gate override check. Pure — takes the diff,
|
|
3
|
+
* PR body, and env-derived strings as explicit parameters. The original
|
|
4
|
+
* `scripts/verify-docs.ts` read `process.env.PR_BODY` / `OVERRIDE_DOCS` /
|
|
5
|
+
* `PR_LABELS` directly inside these functions; that hidden I/O is removed
|
|
6
|
+
* here — the caller (the CLI shim) reads env once and passes the values in.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { hasLabel, label } from '@attalabs/aeg-forge-state'
|
|
10
|
+
import { anchoredRegion } from './anchored-region'
|
|
11
|
+
import { isDocFile, isSpecFile } from './file-classify'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Derive a tier from the changed-file list when no `Tier:` field is in the PR body.
|
|
15
|
+
*
|
|
16
|
+
* Rules (in priority order):
|
|
17
|
+
* 1. Spec or doc file in diff → Tier 1
|
|
18
|
+
* 2. Otherwise (code/config…) → Tier 0
|
|
19
|
+
*
|
|
20
|
+
* A frozen archive carries no tier signal: nothing reads it, so touching one
|
|
21
|
+
* says nothing about a change's impact.
|
|
22
|
+
*/
|
|
23
|
+
export function deriveTierFromDiff(changed: string[]): 0 | 1 {
|
|
24
|
+
if (changed.some((p) => isSpecFile(p) || isDocFile(p))) return 1
|
|
25
|
+
return 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read the `Tier:` field from the PR body.
|
|
30
|
+
*
|
|
31
|
+
* Tolerates the three markdown shapes the field appears in:
|
|
32
|
+
* - plain: `Tier: 3`
|
|
33
|
+
* - bold colon: `**Tier:** 3` (the `**` wraps `Tier:` including the colon)
|
|
34
|
+
* - bold label: `**Tier**: 3` (the `**` wraps only `Tier`)
|
|
35
|
+
*
|
|
36
|
+
* The field may appear inline in a metadata line (e.g.
|
|
37
|
+
* `Tranche: x · Task: 1 · **Tier:** 3 · Project: y`), so it is NOT anchored
|
|
38
|
+
* to line-start. Returns null when no Tier field is present at all — the caller
|
|
39
|
+
* decides what a missing tier means (PR mode treats it as an explicit error,
|
|
40
|
+
* NOT a silent default).
|
|
41
|
+
*
|
|
42
|
+
* When the body carries an `AEG:TIER` anchor pair (`anchored-region.ts`,
|
|
43
|
+
* task 30), the same regex runs exclusively inside that pair — a `Tier:`
|
|
44
|
+
* mention anywhere else (a pasted reference brief, a quoted example) is
|
|
45
|
+
* ignored. Bodies without the pair parse exactly as before.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* The exact `Tier:` field grammar, exported so a consumer that needs the
|
|
49
|
+
* MATCH itself (not just the parsed number) — `body-bare-digits`, which
|
|
50
|
+
* needs to know precisely which substring is the field's real value so it
|
|
51
|
+
* can exempt exactly that and nothing appended after it — reuses this one
|
|
52
|
+
* definition rather than a second regex that could silently drift from it.
|
|
53
|
+
* An optional bold-open, the word Tier, an optional bold-close, a colon, an
|
|
54
|
+
* optional bold-close (covers `**Tier:**`), optional space, then the digit.
|
|
55
|
+
*/
|
|
56
|
+
export const TIER_FIELD = /(\*\*)?\s*Tier\s*(\*\*)?\s*:\s*(\*\*)?\s*([013])\b/i
|
|
57
|
+
|
|
58
|
+
export function readTierFromPrBody(prBody: string): 0 | 1 | 3 | null {
|
|
59
|
+
const searchIn = anchoredRegion(prBody, 'TIER') ?? prBody
|
|
60
|
+
const m = searchIn.match(TIER_FIELD)
|
|
61
|
+
if (!m) return null
|
|
62
|
+
const t = Number(m[4])
|
|
63
|
+
return t === 0 || t === 1 || t === 3 ? (t as 0 | 1 | 3) : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The body token that activates the override — the label name in brackets, so
|
|
68
|
+
* the two spellings cannot drift. Built from the code-owned vocabulary rather
|
|
69
|
+
* than written as a literal (#614): `override:docs` was the one §14 system
|
|
70
|
+
* label the namespace migration missed, precisely because it lived here as a
|
|
71
|
+
* bare string that no label-family grep looked for.
|
|
72
|
+
*/
|
|
73
|
+
const OVERRIDE_BODY_TOKEN = `[${label('override-docs')}]`
|
|
74
|
+
|
|
75
|
+
export function overrideActive(opts: { overrideDocsEnv?: string; prLabels?: string; prBody?: string }): boolean {
|
|
76
|
+
if (opts.overrideDocsEnv === '1') return true
|
|
77
|
+
const labels = (opts.prLabels || '').split(',').map((s) => s.trim())
|
|
78
|
+
if (hasLabel('override-docs', labels)) return true
|
|
79
|
+
if ((opts.prBody || '').includes(OVERRIDE_BODY_TOKEN)) return true
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Premise-pinning grammar (aeg-governance-hardening task 11, #324). Pure —
|
|
3
|
+
* no `fs`; file content is injected via `fileReader` so the checker stays
|
|
4
|
+
* testable and CWD-independent.
|
|
5
|
+
*
|
|
6
|
+
* A brief/PR body's `Premise:` block pins concrete, checkable facts about the
|
|
7
|
+
* code surface at authoring time — e.g. "this function still exists", "this
|
|
8
|
+
* constant is still absent". `verify-dispatch --premise <body-file>`
|
|
9
|
+
* re-asserts every pin immediately before Step 0: a premise that no longer
|
|
10
|
+
* holds means the surface moved since the brief was written, and the
|
|
11
|
+
* Developer should stop and re-dig rather than execute against a stale
|
|
12
|
+
* mental model (the exact failure of aeg-governance-hardening's live-fire
|
|
13
|
+
* #3 — two briefs described a target architecture a later, uncited migration
|
|
14
|
+
* had already superseded).
|
|
15
|
+
*
|
|
16
|
+
* Deliberately minimal — exactly three assertion kinds. This is a pin
|
|
17
|
+
* format, not a DSL; resist adding more.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
import { anchoredRegion } from './anchored-region'
|
|
22
|
+
|
|
23
|
+
export type PremiseAssertion =
|
|
24
|
+
| { kind: 'contains'; path: string; value: string }
|
|
25
|
+
| { kind: 'absent'; path: string; value: string }
|
|
26
|
+
| { kind: 'sha256'; path: string; value: string }
|
|
27
|
+
|
|
28
|
+
const ASSERTION_KINDS = new Set(['contains', 'absent', 'sha256'])
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* True for the header line in either serialization the rest of the brief
|
|
32
|
+
* grammar uses (`brief-validation.ts`, `issue-validation.ts`): `**Premise:**`
|
|
33
|
+
* bold-inline or `### Premise` heading. Strips `*`/`#` markup first rather
|
|
34
|
+
* than trying to enumerate every marker placement — `**Premise:**` closes
|
|
35
|
+
* its bold markers *after* the colon, `**Premise**:` closes them *before*.
|
|
36
|
+
*/
|
|
37
|
+
function isPremiseHeader(line: string): boolean {
|
|
38
|
+
const stripped = line.replace(/[*#]/g, '').trim()
|
|
39
|
+
return /^premise\s*:?$/i.test(stripped)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Matches one bullet line: `- <path> <kind>: <value>`. */
|
|
43
|
+
const PREMISE_LINE = /^[-*]\s*(\S+)\s+(contains|absent|sha256)\s*:\s*(.+)$/i
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse every `- <path> <kind>: <value>` bullet under the `Premise:` header.
|
|
47
|
+
* Malformed bullets (wrong kind, missing colon) are silently skipped — this
|
|
48
|
+
* mirrors `parsePremiseBlock`'s sibling parsers' presence-only philosophy;
|
|
49
|
+
* the caller decides whether zero assertions is itself a failure
|
|
50
|
+
* (`checkPremiseCoverage`). Stops the block at the first blank line or the
|
|
51
|
+
* first non-bullet line after the header.
|
|
52
|
+
*
|
|
53
|
+
* When the body carries an `AEG:PREMISE` anchor pair (`anchored-region.ts`,
|
|
54
|
+
* task 30), the block is parsed exclusively inside that pair — the pair must
|
|
55
|
+
* contain the `**Premise:**` header line and its bullets; a premise-shaped
|
|
56
|
+
* block anywhere else in the body is ignored. Bodies without the pair parse
|
|
57
|
+
* exactly as before.
|
|
58
|
+
*/
|
|
59
|
+
export function parsePremiseBlock(prBody: string): PremiseAssertion[] {
|
|
60
|
+
const searchIn = anchoredRegion(prBody, 'PREMISE') ?? prBody
|
|
61
|
+
const lines = searchIn.split(/\r?\n/)
|
|
62
|
+
const assertions: PremiseAssertion[] = []
|
|
63
|
+
let inBlock = false
|
|
64
|
+
|
|
65
|
+
for (const raw of lines) {
|
|
66
|
+
const line = raw.trim()
|
|
67
|
+
if (!inBlock) {
|
|
68
|
+
if (isPremiseHeader(line)) inBlock = true
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (line === '') break
|
|
72
|
+
const m = line.match(PREMISE_LINE)
|
|
73
|
+
if (!m) break
|
|
74
|
+
const [, path, kindRaw, value] = m
|
|
75
|
+
const kind = (kindRaw as string).toLowerCase()
|
|
76
|
+
if (!ASSERTION_KINDS.has(kind)) continue
|
|
77
|
+
assertions.push({ kind, path: path as string, value: (value as string).trim() } as PremiseAssertion)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return assertions
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type PremiseCheckResult = { pass: boolean; failures: string[] }
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Re-assert every parsed premise against the current file content, injected
|
|
87
|
+
* via `fileReader` (returns `null` when the path does not exist). One
|
|
88
|
+
* failure line per broken pin, naming the exact path/kind/value so the
|
|
89
|
+
* Developer can see precisely what moved.
|
|
90
|
+
*/
|
|
91
|
+
export function checkPremises(
|
|
92
|
+
assertions: PremiseAssertion[],
|
|
93
|
+
fileReader: (path: string) => string | null
|
|
94
|
+
): PremiseCheckResult {
|
|
95
|
+
const failures: string[] = []
|
|
96
|
+
|
|
97
|
+
for (const a of assertions) {
|
|
98
|
+
const content = fileReader(a.path)
|
|
99
|
+
if (content === null) {
|
|
100
|
+
failures.push(
|
|
101
|
+
`premise-check: ${a.path} does not exist on disk (premise asserted \`${a.kind}: ${a.value}\`) — the surface moved since this brief was authored; re-dig before proceeding.`
|
|
102
|
+
)
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (a.kind === 'contains' && !content.includes(a.value)) {
|
|
107
|
+
failures.push(
|
|
108
|
+
`premise-check: ${a.path} no longer contains "${a.value}" — the premise this brief pinned has moved; re-dig before proceeding.`
|
|
109
|
+
)
|
|
110
|
+
} else if (a.kind === 'absent' && content.includes(a.value)) {
|
|
111
|
+
failures.push(
|
|
112
|
+
`premise-check: ${a.path} now contains "${a.value}", but the brief pinned it absent — the premise has moved; re-dig before proceeding.`
|
|
113
|
+
)
|
|
114
|
+
} else if (a.kind === 'sha256') {
|
|
115
|
+
const actual = createHash('sha256').update(content).digest('hex')
|
|
116
|
+
if (actual !== a.value.toLowerCase()) {
|
|
117
|
+
failures.push(
|
|
118
|
+
`premise-check: ${a.path} sha256 mismatch (brief pinned ${a.value}, file is now ${actual}) — the file changed since this brief was authored; re-dig before proceeding.`
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { pass: failures.length === 0, failures }
|
|
125
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quoted-command staleness (Issue #128). Pure — no `fs`, no `git`/`gh`.
|
|
3
|
+
*
|
|
4
|
+
* **The defect this closes.** A doc quotes a command, config line, or file
|
|
5
|
+
* content verbatim, in backticks, as a statement of present fact — "what
|
|
6
|
+
* runs today: `X`". When a diff changes the thing being quoted, the
|
|
7
|
+
* quotation goes stale and nothing notices: the doc changed nothing, the
|
|
8
|
+
* code is correct, every existing gate passes. Measured, live: a CI
|
|
9
|
+
* invocation was pinned to a version in one PR; `aeg-root/enforcement.md`
|
|
10
|
+
* quoted the pre-pin form verbatim as "what runs today"; all 17 registered
|
|
11
|
+
* checks passed, `doctor` reported healthy, security passed. A code-review
|
|
12
|
+
* agent found it after roughly half an hour of independent re-derivation.
|
|
13
|
+
* The fix was one line — the detection took a review round.
|
|
14
|
+
*
|
|
15
|
+
* **Marker-based, not inferred — the Principal's explicit decision
|
|
16
|
+
* (2026-08-30).** The Issue leaves the claim-vs-instruction distinction
|
|
17
|
+
* open and calls it "the real work"; it is now settled: a doc opts a span
|
|
18
|
+
* in with a citation marker naming the file it quotes. An adopter-facing
|
|
19
|
+
* `npx @attalabs/vinaya init` in a README is instruction, correctly
|
|
20
|
+
* unpinned — inferring over command-looking spans would flag it, which is
|
|
21
|
+
* the exact false-positive that gets a gate disabled. This module
|
|
22
|
+
* implements NO inference and NO heuristic fallback for an unmarked span;
|
|
23
|
+
* only annotated quotes are ever evaluated. Coverage grows only as docs
|
|
24
|
+
* adopt the marker — that cost is understood and accepted.
|
|
25
|
+
*
|
|
26
|
+
* **The marker grammar.** Two-line (or inline) HTML-comment pair, the same
|
|
27
|
+
* invisible-on-render, code-blind idiom `anchored-region.ts` uses for the
|
|
28
|
+
* six PR/Issue-body gate-read fields — reused for its masking discipline
|
|
29
|
+
* (`maskCode`, imported below) rather than re-implemented, though this is a
|
|
30
|
+
* DIFFERENT field kind from `anchored-region.ts`'s closed `AnchorField`
|
|
31
|
+
* union: `anchoredRegionBounds` parses one of six fixed field names inside a
|
|
32
|
+
* forge body; this marker instead carries a free-form cited-file path
|
|
33
|
+
* inline in its own START tag, inside an arbitrary doc file, so it cannot
|
|
34
|
+
* reuse that function directly without widening a closed, PR-body-specific
|
|
35
|
+
* type for a doc-authoring concern outside this task's surface. The shape:
|
|
36
|
+
*
|
|
37
|
+
* <!-- AEG:QUOTES-FILE:START:<repo-relative-path> -->
|
|
38
|
+
* `<quoted text>`
|
|
39
|
+
* <!-- AEG:QUOTES-FILE:END -->
|
|
40
|
+
*
|
|
41
|
+
* Both markers may sit on their own lines or inline within a prose
|
|
42
|
+
* sentence (the real annotation this task ships wraps an inline backtick
|
|
43
|
+
* span mid-paragraph). Recognition rules mirror `anchored-region.ts`
|
|
44
|
+
* exactly: markers inside a fenced/indented code block or inline code span
|
|
45
|
+
* do not count (an authoring example showing the marker syntax itself,
|
|
46
|
+
* fenced, is never mistaken for a real one); a `START` with no following
|
|
47
|
+
* `END` is not an anchor at all; multiple pairs in one file are scanned
|
|
48
|
+
* left to right, each `START` paired with the next `END` after it.
|
|
49
|
+
*
|
|
50
|
+
* **The quoted text.** The content between the markers, trimmed, with one
|
|
51
|
+
* layer of wrapping stripped: a single-backtick inline span, a fenced block
|
|
52
|
+
* (backtick or tilde), or neither (bare text) — whichever the author used.
|
|
53
|
+
* That literal string is the predicate's subject: it either occurs verbatim
|
|
54
|
+
* in the cited file's content, or it does not. No normalization beyond
|
|
55
|
+
* that — a doc that means to tolerate whitespace/formatting drift is not
|
|
56
|
+
* this check's problem to solve.
|
|
57
|
+
*
|
|
58
|
+
* **The predicate.** Decidable, per the Issue: a quoted command either
|
|
59
|
+
* appears in the file it claims to quote, or it does not. `findCitedQuotes`
|
|
60
|
+
* discovers every marked span in the governed-doc corpus (the same
|
|
61
|
+
* `classifyProseFile` scoping `reader-resolvable-prose.ts` sweeps —
|
|
62
|
+
* `ships`/`reader-facing`, never `internal` — reused rather than a second
|
|
63
|
+
* notion of "governed doc"); `evaluateCitedQuotes` then checks each against
|
|
64
|
+
* the cited file's actual content, which the caller supplies (the bin reads
|
|
65
|
+
* it — this module never touches disk). A finding names both sides: what
|
|
66
|
+
* the doc claims (the quoted text) and which file it cited that no longer
|
|
67
|
+
* (or never did) contain it verbatim.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import { maskCode } from '@attalabs/aeg-forge-state/strip-code'
|
|
71
|
+
import { classifyProseFile, type ProseFileClass } from './reader-resolvable-prose'
|
|
72
|
+
|
|
73
|
+
// `maskCode` (index-preserving) is imported directly from the `./strip-code`
|
|
74
|
+
// subpath — the same narrow front door `anchored-region.ts` uses, per that
|
|
75
|
+
// package's own barrel comment ("reachable through the `./strip-code`
|
|
76
|
+
// subpath for the two callers that need them"). Not through
|
|
77
|
+
// `anchored-region.ts`'s re-export: that file's own `anchoredRegionBounds`
|
|
78
|
+
// is scoped to its closed six-field `AnchorField` grammar, which this
|
|
79
|
+
// marker (a free-form cited-file path, not one of those six names) cannot
|
|
80
|
+
// use without widening a PR-body-specific type for a doc-authoring concern
|
|
81
|
+
// outside this task's surface. Reusing `maskCode` itself — rather than
|
|
82
|
+
// writing a second masker — is the actual "do not write a second anchor
|
|
83
|
+
// parser" discipline this module honors.
|
|
84
|
+
|
|
85
|
+
export type QuotedCommandSourceFile = { path: string; content: string }
|
|
86
|
+
|
|
87
|
+
/** One marked span: the doc that carries it, where, what it claims, and which file it cites. */
|
|
88
|
+
export type CitedQuote = {
|
|
89
|
+
file: string
|
|
90
|
+
line: number
|
|
91
|
+
quotedText: string
|
|
92
|
+
citedFile: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type QuotedCommandFinding = {
|
|
96
|
+
file: string
|
|
97
|
+
line: number
|
|
98
|
+
citedFile: string
|
|
99
|
+
quotedText: string
|
|
100
|
+
message: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `aeg-root/**` by default — same default `reader-resolvable-prose.ts` uses, so a caller that doesn't override either stays consistent. */
|
|
104
|
+
const QUOTED_COMMAND_SHIPS_PREFIX = 'aeg-root/'
|
|
105
|
+
|
|
106
|
+
/** The two classes this check sweeps for markers — `internal` never is, matching `reader-resolvable-prose.ts`'s own (identically-valued, differently-named to avoid `symbol-collisions.test.ts`) `SWEPT_CLASSES`. */
|
|
107
|
+
const QUOTED_COMMAND_SWEPT_CLASSES: ReadonlySet<ProseFileClass> = new Set(['ships', 'reader-facing'])
|
|
108
|
+
|
|
109
|
+
const START_PATTERN = /<!--\s*AEG:QUOTES-FILE:START:(\S+?)\s*-->/g
|
|
110
|
+
const END_PATTERN = /<!--\s*AEG:QUOTES-FILE:END\s*-->/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A marker's `citedFile` must be a plain repo-root-relative path — never
|
|
114
|
+
* absolute, never carrying a `..` traversal segment. Security finding
|
|
115
|
+
* (this check's own PR, round 2): an unvalidated `citedFile` turns this
|
|
116
|
+
* check into a file-content oracle any doc author can drive — a crafted
|
|
117
|
+
* marker naming `../../../../etc/hosts` (or any path outside the repo the
|
|
118
|
+
* check process can reach) gets its content read and compared against
|
|
119
|
+
* attacker-chosen `quotedText`, and the three distinguishable outcomes
|
|
120
|
+
* (silent pass on an exact match, a "no longer contains" finding on a
|
|
121
|
+
* miss, a "could not be read" finding when the target is absent) form a
|
|
122
|
+
* working binary-search oracle over that file's real content — reproduced
|
|
123
|
+
* live, three ways, including a working read of `/etc/hosts`. A path
|
|
124
|
+
* failing this check is not a valid citation at all: the marker is treated
|
|
125
|
+
* exactly like an unterminated START/END pair (silently not an anchor),
|
|
126
|
+
* never reaching the file-read stage, so there is no signal difference
|
|
127
|
+
* between "malformed marker" and "no marker" for an attacker to probe.
|
|
128
|
+
*/
|
|
129
|
+
export function isValidCitedFilePath(path: string): boolean {
|
|
130
|
+
if (path.length === 0) return false
|
|
131
|
+
if (path.startsWith('/') || path.startsWith('\\')) return false
|
|
132
|
+
if (/^[A-Za-z]:[\\/]/.test(path)) return false
|
|
133
|
+
return !path.split(/[\\/]+/).includes('..')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function quotedCommandLineAt(content: string, index: number): number {
|
|
137
|
+
let line = 1
|
|
138
|
+
for (let i = 0; i < index; i++) {
|
|
139
|
+
if (content.charCodeAt(i) === 10) line++
|
|
140
|
+
}
|
|
141
|
+
return line
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Strips exactly one layer of wrapping from the marker's inner text: a
|
|
146
|
+
* single-backtick inline span, a fenced block (backtick or tilde, with or
|
|
147
|
+
* without an info string), or neither. Returns the trimmed bare text in
|
|
148
|
+
* every case — this is deliberately not CommonMark-general, only the two
|
|
149
|
+
* shapes an author actually writes a quoted command in.
|
|
150
|
+
*/
|
|
151
|
+
function extractQuotedText(raw: string): string {
|
|
152
|
+
const trimmed = raw.trim()
|
|
153
|
+
const fenced = /^(?:`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n?(?:`{3,}|~{3,})$/.exec(trimmed)
|
|
154
|
+
if (fenced) return (fenced[1] ?? '').trim()
|
|
155
|
+
const inline = /^`([^`]+)`$/.exec(trimmed)
|
|
156
|
+
if (inline) return (inline[1] ?? '').trim()
|
|
157
|
+
return trimmed
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Scans one file's content for `AEG:QUOTES-FILE` marker pairs. Searches
|
|
162
|
+
* position-only over `stripCode`'s masked-but-length-altering output is
|
|
163
|
+
* unsafe (indices would no longer map back) — so this uses `maskCode`
|
|
164
|
+
* (index-preserving, same primitive `anchored-region.ts` uses) to find
|
|
165
|
+
* marker positions code-blind, then slices the real inner text from the
|
|
166
|
+
* original `content` at those same indices, exactly as `anchoredRegionBounds`
|
|
167
|
+
* does for its own six fields.
|
|
168
|
+
*/
|
|
169
|
+
function findQuotesInFile(path: string, content: string): CitedQuote[] {
|
|
170
|
+
const masked = maskCode(content)
|
|
171
|
+
const quotes: CitedQuote[] = []
|
|
172
|
+
|
|
173
|
+
START_PATTERN.lastIndex = 0
|
|
174
|
+
let start: RegExpExecArray | null = START_PATTERN.exec(masked)
|
|
175
|
+
while (start !== null) {
|
|
176
|
+
const citedFile = start[1] as string
|
|
177
|
+
const innerStart = start.index + start[0].length
|
|
178
|
+
const end = END_PATTERN.exec(masked.slice(innerStart))
|
|
179
|
+
if (end === null) {
|
|
180
|
+
// A START with no following END is not an anchor at all (mirrors
|
|
181
|
+
// anchored-region.ts's identical rule) — resume scanning right after
|
|
182
|
+
// this unterminated START rather than treating the rest of the file
|
|
183
|
+
// as consumed by it.
|
|
184
|
+
START_PATTERN.lastIndex = innerStart
|
|
185
|
+
start = START_PATTERN.exec(masked)
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
const innerEnd = innerStart + end.index
|
|
189
|
+
if (isValidCitedFilePath(citedFile)) {
|
|
190
|
+
const quotedText = extractQuotedText(content.slice(innerStart, innerEnd))
|
|
191
|
+
if (quotedText.length > 0) {
|
|
192
|
+
quotes.push({ file: path, line: quotedCommandLineAt(content, innerStart), quotedText, citedFile })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
START_PATTERN.lastIndex = innerEnd + end[0].length
|
|
196
|
+
start = START_PATTERN.exec(masked)
|
|
197
|
+
}
|
|
198
|
+
return quotes
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Every marked span across the governed-doc corpus — `files` scoped to the
|
|
203
|
+
* same `ships`/`reader-facing` classes `reader-resolvable-prose.ts` sweeps
|
|
204
|
+
* (never `internal`), via the identical `classifyProseFile` call, not a
|
|
205
|
+
* second notion of "governed doc". Zero I/O: `files` is read by the caller.
|
|
206
|
+
*/
|
|
207
|
+
export function findCitedQuotes(
|
|
208
|
+
files: readonly QuotedCommandSourceFile[],
|
|
209
|
+
readerFacingPrefix: string,
|
|
210
|
+
readerFacingSuffix: string,
|
|
211
|
+
shipsPrefix: string = QUOTED_COMMAND_SHIPS_PREFIX
|
|
212
|
+
): CitedQuote[] {
|
|
213
|
+
const quotes: CitedQuote[] = []
|
|
214
|
+
for (const file of files) {
|
|
215
|
+
const cls = classifyProseFile(file.path, readerFacingPrefix, readerFacingSuffix, shipsPrefix)
|
|
216
|
+
if (!cls || !QUOTED_COMMAND_SWEPT_CLASSES.has(cls)) continue
|
|
217
|
+
quotes.push(...findQuotesInFile(file.path, file.content))
|
|
218
|
+
}
|
|
219
|
+
return quotes
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The predicate itself: does each cited quote still appear verbatim in the
|
|
224
|
+
* file it claims to quote? `citedFileContents` is supplied by the caller —
|
|
225
|
+
* this function does no I/O and does not care whether a cited path lies
|
|
226
|
+
* inside or outside the governed-doc corpus (a workflow YAML a doc quotes
|
|
227
|
+
* is neither `ships` nor `reader-facing`, and must still be checkable). A
|
|
228
|
+
* cited path absent from `citedFileContents` is itself a finding (the file
|
|
229
|
+
* could not be read/found), not a silent pass — collapsing "unreadable"
|
|
230
|
+
* into "verified" would be the same fail-open class other checks in this
|
|
231
|
+
* package document as a real, reproduced incident.
|
|
232
|
+
*/
|
|
233
|
+
export function evaluateCitedQuotes(
|
|
234
|
+
citedQuotes: readonly CitedQuote[],
|
|
235
|
+
citedFileContents: ReadonlyMap<string, string>
|
|
236
|
+
): QuotedCommandFinding[] {
|
|
237
|
+
const findings: QuotedCommandFinding[] = []
|
|
238
|
+
for (const quote of citedQuotes) {
|
|
239
|
+
const content = citedFileContents.get(quote.citedFile)
|
|
240
|
+
if (content === undefined) {
|
|
241
|
+
findings.push({
|
|
242
|
+
file: quote.file,
|
|
243
|
+
line: quote.line,
|
|
244
|
+
citedFile: quote.citedFile,
|
|
245
|
+
quotedText: quote.quotedText,
|
|
246
|
+
message: `${quote.file}:${quote.line} quotes "${quote.quotedText}" as citing \`${quote.citedFile}\`, but that file could not be read`
|
|
247
|
+
})
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
if (!content.includes(quote.quotedText)) {
|
|
251
|
+
findings.push({
|
|
252
|
+
file: quote.file,
|
|
253
|
+
line: quote.line,
|
|
254
|
+
citedFile: quote.citedFile,
|
|
255
|
+
quotedText: quote.quotedText,
|
|
256
|
+
message: `${quote.file}:${quote.line} quotes "${quote.quotedText}" as citing \`${quote.citedFile}\`, but \`${quote.citedFile}\` no longer contains that text verbatim`
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return findings
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Runs both phases in one call: discover markers, then evaluate them against the supplied cited-file contents. */
|
|
264
|
+
export function checkQuotedCommandStaleness(
|
|
265
|
+
files: readonly QuotedCommandSourceFile[],
|
|
266
|
+
citedFileContents: ReadonlyMap<string, string>,
|
|
267
|
+
readerFacingPrefix: string,
|
|
268
|
+
readerFacingSuffix: string,
|
|
269
|
+
shipsPrefix: string = QUOTED_COMMAND_SHIPS_PREFIX
|
|
270
|
+
): QuotedCommandFinding[] {
|
|
271
|
+
return evaluateCitedQuotes(
|
|
272
|
+
findCitedQuotes(files, readerFacingPrefix, readerFacingSuffix, shipsPrefix),
|
|
273
|
+
citedFileContents
|
|
274
|
+
)
|
|
275
|
+
}
|