@attalabs/vinaya 0.22.0 → 0.24.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 +14 -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 +559 -140
- package/dist/checks/bin/check-branch-topology.js +630 -145
- package/dist/checks/bin/check-brief-shape.js +600 -143
- package/dist/checks/bin/check-changeset-coverage.js +942 -162
- package/dist/checks/bin/check-closes-n.js +630 -145
- package/dist/checks/bin/check-coherence.js +641 -148
- package/dist/checks/bin/check-dead-branch-push.js +559 -140
- package/dist/checks/bin/check-dispatch-readiness.js +639 -146
- package/dist/checks/bin/check-doc-coverage-push.js +696 -219
- package/dist/checks/bin/check-doc-coverage.js +696 -219
- package/dist/checks/bin/check-doctrine-no-procedures.js +4800 -0
- package/dist/checks/bin/check-doctrine-portability.js +691 -147
- package/dist/checks/bin/check-evidence-fresh.js +1048 -222
- package/dist/checks/bin/check-exec-bits.js +4937 -0
- package/dist/checks/bin/check-first-push-dispatch.js +630 -145
- package/dist/checks/bin/check-issue-assignment.js +630 -145
- package/dist/checks/bin/check-main-branch-refusal.js +565 -141
- package/dist/checks/bin/check-no-disk-state.js +559 -140
- package/dist/checks/bin/check-pr-report-density.js +4467 -0
- package/dist/checks/bin/check-quoted-command.js +656 -164
- package/dist/checks/bin/check-reader-resolvable-prose.js +652 -163
- package/dist/checks/bin/check-registry-gates.js +602 -145
- package/dist/checks/bin/check-retired-vocabulary.js +652 -163
- package/dist/checks/bin/check-review-gate.js +677 -142
- package/dist/checks/bin/check-single-plan-pr.js +559 -140
- package/dist/checks/bin/check-test-plan.js +560 -141
- package/dist/checks/bin/check-token-collection-wired.js +559 -140
- package/dist/checks/bin/check-token-report.js +559 -140
- package/dist/checks/bin/check-workspace-escape.js +992 -146
- package/dist/index.js +8884 -5627
- 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 +118 -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 +198 -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-render.ts +462 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/brief-validation.ts +912 -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 +806 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/consumer-enumeration.ts +75 -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 +268 -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-no-procedures.ts +94 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/doctrine-portability.ts +293 -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 +171 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/index.ts +323 -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 +177 -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-report-density.ts +116 -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 +160 -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 +378 -0
- package/studio-standalone/_node_modules/@attalabs/aeg-core/src/review-status.ts +200 -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 +117 -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 +199 -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]__1pndh3_._.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/[root-of-the-server]__1wc4-ip._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_03x_w6q._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0gvm3og._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_0lwxg63._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1fqw88f._.js +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/_1n0cnq-._.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/apps_vinaya-studio_web_src_app_studio_projects_[name]_tranches_[slug]_05ufo8i._.js +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/chunks/ssr/node_modules_1vo08dj._.js +2 -2
- package/studio-standalone/apps/vinaya-studio/web/.next/server/middleware-build-manifest.js +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/server/pages/500.html +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.js +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/server/server-reference-manifest.json +3 -3
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{37hnluhzj9bei.js → 0zebsmmk1bvnb.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{267o-tsrnuwnv.js → 1uogpj5w2n5ju.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/.next/static/chunks/{1hufpuody6vjv.js → 3m1kgax7j2vgs.js} +1 -1
- package/studio-standalone/apps/vinaya-studio/web/package.json +2 -2
- /package/studio-standalone/_node_modules/@attalabs/vinaya/studio-standalone/_node_modules/@attalabs/{aeg-core → vinaya/studio-standalone/_node_modules/@attalabs/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 → QY2GhiI47-765D9DHGy1I}/_buildManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_clientMiddlewareManifest.js +0 -0
- /package/studio-standalone/apps/vinaya-studio/web/.next/static/{Fmyc42qzLwFcWWHLLNy-G → QY2GhiI47-765D9DHGy1I}/_ssgManifest.js +0 -0
|
@@ -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 seven-field `AnchorField` grammar, which this
|
|
79
|
+
// marker (a free-form cited-file path, not one of those seven 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 seven 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
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader-resolvable prose — classes 1 and 2 of the three-class analysis in
|
|
3
|
+
* Issue #694: the two mechanizable classes of "words a reader cannot
|
|
4
|
+
* resolve." Class 3 (register/slop — padding adjectives, first person,
|
|
5
|
+
* narrating the work episode) is NOT here; it stays with the review role,
|
|
6
|
+
* per the measurement below.
|
|
7
|
+
*
|
|
8
|
+
* **The measurement that disqualifies a phrase blacklist.** A phrase
|
|
9
|
+
* blacklist tested against the real corpus (all of `aeg-root` plus 1,157
|
|
10
|
+
* TypeScript files) scored 14 hits, 14 false positives, 0 true positives —
|
|
11
|
+
* "a review was *requested*"; "could *this change* leak a secret"; "*I have*
|
|
12
|
+
* X, Y, Z" inside a quoted example. The fix is not tuning the word list; it
|
|
13
|
+
* is asking a different question:
|
|
14
|
+
*
|
|
15
|
+
* - Class 1 (references): not "does this phrase appear" but "does this
|
|
16
|
+
* doctrine cite a forge number or tranche slug the reader has no forge to
|
|
17
|
+
* resolve." Reuses the same two pattern shapes proven in
|
|
18
|
+
* `retired-vocabulary.test.ts` (`FORGE_NUMBER_PATTERN`,
|
|
19
|
+
* `TRANCHE_SLUG_VN_PATTERN`, and the legacy-slug list derived — not
|
|
20
|
+
* guessed — from `aeg-root/tranches/completed/*.md` filenames), scoped to
|
|
21
|
+
* the reader-facing surfaces instead of banned repo-wide (the same digit
|
|
22
|
+
* shape is this product's own live `Closes #N`/PR-number grammar
|
|
23
|
+
* elsewhere, so a repo-wide ban would flag the mechanism, not the
|
|
24
|
+
* citation habit).
|
|
25
|
+
* - Class 2 (vocabulary): not "is this word forbidden" but "does this
|
|
26
|
+
* reader-facing file use a coined term while neither defining it inline
|
|
27
|
+
* nor linking a definition." The term list is derived from
|
|
28
|
+
* `aeg-root/glossary.md`'s own entry headings — never hard-coded — so it
|
|
29
|
+
* cannot drift the first time an entry is added.
|
|
30
|
+
*
|
|
31
|
+
* Zero I/O: every input (file paths + contents, the glossary's term list, the
|
|
32
|
+
* legacy-slug list) is read by the adapter and passed in. POSIX-ERE-safe
|
|
33
|
+
* patterns only — no `\d`/`\w`/`\s`/lookaheads, and no `\b` relied on after a
|
|
34
|
+
* non-word character (the exact regression class `retired-vocabulary.test.ts`
|
|
35
|
+
* already paid for) — even though these patterns run through native
|
|
36
|
+
* `RegExp`, not `grep -E`, keeping them portable to either.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export type ProseFileClass = 'ships' | 'reader-facing' | 'internal'
|
|
40
|
+
|
|
41
|
+
export type ProseSourceFile = { path: string; content: string }
|
|
42
|
+
|
|
43
|
+
export type ProseFinding = {
|
|
44
|
+
file: string
|
|
45
|
+
line: number
|
|
46
|
+
message: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `aeg-root/**` by default — what this repo's own package carries and
|
|
51
|
+
* `/docs` publishes. Every caller below takes `shipsPrefix` as its LAST,
|
|
52
|
+
* defaulted parameter so an adopter whose installed doctrine tree lives
|
|
53
|
+
* somewhere else (task 7, Issue #56 — de-hardcoding `doctrineRoot`) can
|
|
54
|
+
* override it without breaking any existing positional call.
|
|
55
|
+
*/
|
|
56
|
+
const SHIPS_PREFIX = 'aeg-root/'
|
|
57
|
+
|
|
58
|
+
/** `<shipsPrefix>tranches/completed/` — history, never rewritten. Same exemption `retired-vocabulary.test.ts` already carries for the identical reason: an archived tranche legitimately cites the forge numbers and slugs it closed. */
|
|
59
|
+
function shipsArchivePrefix(shipsPrefix: string): string {
|
|
60
|
+
return `${shipsPrefix}tranches/completed/`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A per-product specs file (`apps/<product>/specs/**`) — this reader has this forge; references are legitimate here. */
|
|
64
|
+
function isSpecFile(path: string): boolean {
|
|
65
|
+
return path.startsWith('apps/') && path.includes('/specs/') && path.endsWith('.md')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Any `CLAUDE.md`, at any depth — same reasoning as specs: this reader has this forge. */
|
|
69
|
+
function isClaudeMdFile(path: string): boolean {
|
|
70
|
+
return path === 'CLAUDE.md' || path.endsWith('/CLAUDE.md')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The file-class map, encoded as data (not a rule buried in control flow).
|
|
75
|
+
* Returns `null` for anything outside all three classes — out of this
|
|
76
|
+
* check's scope entirely, not merely exempt.
|
|
77
|
+
*
|
|
78
|
+
* `readerFacingPrefix`/`readerFacingSuffix` scope the "reader-facing" class
|
|
79
|
+
* to a consumer's own reader-facing surface — this package stays generic and
|
|
80
|
+
* has no attalabs-specific path baked in. The caller-supplied shape mirrors
|
|
81
|
+
* the vinaya adopter's product site: a page's own body is where
|
|
82
|
+
* reader-visible prose lives (JSX text, step copy); sibling component
|
|
83
|
+
* directories include non-textual/decorative surfaces (e.g. canvas
|
|
84
|
+
* illustrations using fictional PR numbers as flavor text) that are not
|
|
85
|
+
* prose in the sense this check means, hence a suffix-scoped match rather
|
|
86
|
+
* than a bare prefix.
|
|
87
|
+
*/
|
|
88
|
+
export function classifyProseFile(
|
|
89
|
+
path: string,
|
|
90
|
+
readerFacingPrefix: string,
|
|
91
|
+
readerFacingSuffix: string,
|
|
92
|
+
shipsPrefix: string = SHIPS_PREFIX
|
|
93
|
+
): ProseFileClass | null {
|
|
94
|
+
if (path.startsWith(shipsPrefix)) {
|
|
95
|
+
return path.startsWith(shipsArchivePrefix(shipsPrefix)) ? 'internal' : 'ships'
|
|
96
|
+
}
|
|
97
|
+
if (path.startsWith(readerFacingPrefix) && path.endsWith(readerFacingSuffix)) {
|
|
98
|
+
return 'reader-facing'
|
|
99
|
+
}
|
|
100
|
+
if (isSpecFile(path) || isClaudeMdFile(path)) return 'internal'
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The two classes this check actually sweeps — `internal` never is. */
|
|
105
|
+
const SWEPT_CLASSES: ReadonlySet<ProseFileClass> = new Set(['ships', 'reader-facing'])
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Strips the parts of a file that are never reader prose, per file kind — a
|
|
109
|
+
* fenced/inline code example in a markdown doc, or a source comment in a
|
|
110
|
+
* `.tsx` page (a page's own developer-facing comments, e.g. "same reasoning
|
|
111
|
+
* as `/roadmap`", are not what a site visitor reads). Line count is
|
|
112
|
+
* preserved (replacements keep their newlines) so reported line numbers stay
|
|
113
|
+
* accurate against the original file.
|
|
114
|
+
*
|
|
115
|
+
* The `//` line-comment cut requires the marker not be immediately preceded
|
|
116
|
+
* by `:` — without that, `https://vinaya.dev` on a reader-facing line reads
|
|
117
|
+
* as a comment starting at its own `//`, silently blanking every word of
|
|
118
|
+
* real prose that follows it on the line. No boundary check is needed on
|
|
119
|
+
* the far side: unlike `//`, the character before it is never itself part
|
|
120
|
+
* of the marker, so a plain capturing group (not a lookbehind) is enough.
|
|
121
|
+
*/
|
|
122
|
+
export function stripNonProse(path: string, content: string): string {
|
|
123
|
+
if (path.endsWith('.md')) {
|
|
124
|
+
return content
|
|
125
|
+
.replace(/```.*?```/gs, (m) => m.replace(/[^\n]/g, ''))
|
|
126
|
+
.replace(/`[^`\n]*`/g, (m) => m.replace(/[^\n]/g, ''))
|
|
127
|
+
}
|
|
128
|
+
if (path.endsWith('.tsx') || path.endsWith('.ts')) {
|
|
129
|
+
return content
|
|
130
|
+
.replace(/\/\*.*?\*\//gs, (m) => m.replace(/[^\n]/g, ''))
|
|
131
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, ''))
|
|
132
|
+
}
|
|
133
|
+
return content
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function lineAt(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
|
+
/** `#294`-shaped — a PR/Issue number cited bare, resolvable only inside this repo's own tracker. */
|
|
145
|
+
export const FORGE_NUMBER_PATTERN = /#[0-9]{2,4}/g
|
|
146
|
+
|
|
147
|
+
/** `aeg-forge-state-v1`-shaped — an internal tranche slug ending `-vN`. */
|
|
148
|
+
export const TRANCHE_SLUG_VN_PATTERN = /[a-z][a-z-]+-v[0-9]/g
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Builds the legacy-slug pattern from the real archived-tranche filenames
|
|
152
|
+
* the adapter reads (mirrors `retired-vocabulary.test.ts`'s `legacySlugs()` —
|
|
153
|
+
* derived from `aeg-root/tranches/completed/*.md`, never a shape guess).
|
|
154
|
+
* Returns `null` when there is nothing left for it to catch (every archived
|
|
155
|
+
* slug now ends `-vN`), matching that file's "no pattern with zero positive
|
|
156
|
+
* samples" rule.
|
|
157
|
+
*
|
|
158
|
+
* Boundary-checked like `containsWholeWord` below (character class, not
|
|
159
|
+
* `\b`) so a real slug cannot match as a bare substring inside a longer,
|
|
160
|
+
* unrelated token. `retired-vocabulary.test.ts`'s own copy of this pattern
|
|
161
|
+
* has no such boundary; this one adds it rather than copying the gap.
|
|
162
|
+
* Group 1 is the leading boundary char (or start-of-string), group 2 is the
|
|
163
|
+
* slug itself, group 3 the trailing boundary char (or end-of-string) — the
|
|
164
|
+
* caller reports group 2, not the whole match, so the boundary chars never
|
|
165
|
+
* leak into a finding's message.
|
|
166
|
+
*/
|
|
167
|
+
export function legacySlugPattern(legacySlugs: readonly string[]): RegExp | null {
|
|
168
|
+
if (legacySlugs.length === 0) return null
|
|
169
|
+
const alternation = legacySlugs.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
|
170
|
+
return new RegExp(`(^|[^a-zA-Z0-9\\n-])(${alternation})([^a-zA-Z0-9\\n-]|$)`, 'g')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Class 1 — unresolvable references. Runs the reference patterns over every
|
|
175
|
+
* swept file (`ships`/`reader-facing`), skipping code-fenced/commented-out
|
|
176
|
+
* non-prose first.
|
|
177
|
+
*/
|
|
178
|
+
export function checkUnresolvableReferences(
|
|
179
|
+
files: readonly ProseSourceFile[],
|
|
180
|
+
readerFacingPrefix: string,
|
|
181
|
+
readerFacingSuffix: string,
|
|
182
|
+
legacySlugs: readonly string[] = [],
|
|
183
|
+
shipsPrefix: string = SHIPS_PREFIX
|
|
184
|
+
): ProseFinding[] {
|
|
185
|
+
const findings: ProseFinding[] = []
|
|
186
|
+
const legacyPattern = legacySlugPattern(legacySlugs)
|
|
187
|
+
// `group` names the capture holding the actual cited text — the plain
|
|
188
|
+
// patterns have none (report the whole match), the boundary-checked
|
|
189
|
+
// legacy-slug pattern reports its group 2 so the boundary chars around it
|
|
190
|
+
// never leak into the finding's message.
|
|
191
|
+
const patterns: { pattern: RegExp; what: string; group?: number }[] = [
|
|
192
|
+
{ pattern: FORGE_NUMBER_PATTERN, what: 'a forge number' },
|
|
193
|
+
{ pattern: TRANCHE_SLUG_VN_PATTERN, what: 'an internal tranche slug' },
|
|
194
|
+
...(legacyPattern ? [{ pattern: legacyPattern, what: 'an internal tranche slug', group: 2 }] : [])
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
for (const file of files) {
|
|
198
|
+
const cls = classifyProseFile(file.path, readerFacingPrefix, readerFacingSuffix, shipsPrefix)
|
|
199
|
+
if (!cls || !SWEPT_CLASSES.has(cls)) continue
|
|
200
|
+
const scrubbed = stripNonProse(file.path, file.content)
|
|
201
|
+
for (const { pattern, what, group } of patterns) {
|
|
202
|
+
pattern.lastIndex = 0
|
|
203
|
+
let match: RegExpExecArray | null = pattern.exec(scrubbed)
|
|
204
|
+
while (match !== null) {
|
|
205
|
+
const cited = group !== undefined ? (match[group] ?? match[0]) : match[0]
|
|
206
|
+
findings.push({
|
|
207
|
+
file: file.path,
|
|
208
|
+
line: lineAt(scrubbed, match.index),
|
|
209
|
+
message: `references ${what} ("${cited}") a reader outside this repo's tracker cannot resolve`
|
|
210
|
+
})
|
|
211
|
+
match = pattern.exec(scrubbed)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return findings
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Escapes a glossary term for use inside a hand-built, boundary-checked pattern. */
|
|
219
|
+
function escapeTerm(term: string): string {
|
|
220
|
+
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Whole-word (or whole-phrase), case-insensitive occurrence of `term` in
|
|
225
|
+
* `text` — boundaries checked by character class rather than `\b`, per the
|
|
226
|
+
* "do not rely on `\b` after a non-word character" lesson. Matches the exact
|
|
227
|
+
* glossary heading only (no inflections): "briefly" must never match
|
|
228
|
+
* "brief" the way a prefix match would — that exact shape (`let's` matching
|
|
229
|
+
* `bullet's`) is what the disqualifying measurement caught.
|
|
230
|
+
*/
|
|
231
|
+
function containsWholeWord(text: string, term: string): boolean {
|
|
232
|
+
const escaped = escapeTerm(term)
|
|
233
|
+
const re = new RegExp(`(^|[^a-zA-Z])${escaped}([^a-zA-Z]|$)`, 'i')
|
|
234
|
+
return re.test(text)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function findWholeWordLine(content: string, term: string): number | null {
|
|
238
|
+
const lines = content.split('\n')
|
|
239
|
+
let lineNo = 0
|
|
240
|
+
for (const line of lines) {
|
|
241
|
+
lineNo++
|
|
242
|
+
if (containsWholeWord(line, term)) return lineNo
|
|
243
|
+
}
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A file "defines" a term inline when it carries the same
|
|
249
|
+
* `Term — definition` shape the glossary itself uses (an em-dash right after
|
|
250
|
+
* the term), or links the glossary page/file directly.
|
|
251
|
+
*/
|
|
252
|
+
function definesOrLinksGlossary(content: string, term: string): boolean {
|
|
253
|
+
const escaped = escapeTerm(term)
|
|
254
|
+
const definesInline = new RegExp(`(^|[^a-zA-Z])${escaped}([^a-zA-Z]|$)[^\\n]{0,3}—`, 'i').test(content)
|
|
255
|
+
if (definesInline) return true
|
|
256
|
+
return /docs\/glossary|glossary\.md/i.test(content)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Class 2 — undefined coined vocabulary. Not "is this word forbidden" — it
|
|
261
|
+
* is "does this reader-facing file use a coined term while neither defining
|
|
262
|
+
* it inline nor linking a definition." `glossaryTerms` is read by the
|
|
263
|
+
* adapter from `aeg-root/glossary.md`'s own entry headings — never
|
|
264
|
+
* hard-coded here, so the check cannot silently rot the first time an entry
|
|
265
|
+
* is added.
|
|
266
|
+
*/
|
|
267
|
+
export function checkUndefinedVocabulary(
|
|
268
|
+
files: readonly ProseSourceFile[],
|
|
269
|
+
glossaryTerms: readonly string[],
|
|
270
|
+
readerFacingPrefix: string,
|
|
271
|
+
readerFacingSuffix: string,
|
|
272
|
+
shipsPrefix: string = SHIPS_PREFIX
|
|
273
|
+
): ProseFinding[] {
|
|
274
|
+
const findings: ProseFinding[] = []
|
|
275
|
+
|
|
276
|
+
for (const file of files) {
|
|
277
|
+
const cls = classifyProseFile(file.path, readerFacingPrefix, readerFacingSuffix, shipsPrefix)
|
|
278
|
+
if (!cls || !SWEPT_CLASSES.has(cls)) continue
|
|
279
|
+
const scrubbed = stripNonProse(file.path, file.content)
|
|
280
|
+
|
|
281
|
+
for (const term of glossaryTerms) {
|
|
282
|
+
const line = findWholeWordLine(scrubbed, term)
|
|
283
|
+
if (line === null) continue
|
|
284
|
+
if (definesOrLinksGlossary(scrubbed, term)) continue
|
|
285
|
+
findings.push({
|
|
286
|
+
file: file.path,
|
|
287
|
+
line,
|
|
288
|
+
message: `uses coined term "${term}" without defining it inline or linking the glossary`
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return findings
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Runs both mechanizable classes over the given files in one pass. */
|
|
296
|
+
export function checkReaderResolvableProse(
|
|
297
|
+
files: readonly ProseSourceFile[],
|
|
298
|
+
glossaryTerms: readonly string[],
|
|
299
|
+
readerFacingPrefix: string,
|
|
300
|
+
readerFacingSuffix: string,
|
|
301
|
+
legacySlugs: readonly string[] = [],
|
|
302
|
+
shipsPrefix: string = SHIPS_PREFIX
|
|
303
|
+
): ProseFinding[] {
|
|
304
|
+
return [
|
|
305
|
+
...checkUnresolvableReferences(files, readerFacingPrefix, readerFacingSuffix, legacySlugs, shipsPrefix),
|
|
306
|
+
...checkUndefinedVocabulary(files, glossaryTerms, readerFacingPrefix, readerFacingSuffix, shipsPrefix)
|
|
307
|
+
]
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Parses `aeg-root/glossary.md`'s `**Term** — definition` entry headings into a bare term list. */
|
|
311
|
+
export function parseGlossaryTerms(glossaryContent: string): string[] {
|
|
312
|
+
const terms: string[] = []
|
|
313
|
+
const re = /^\*\*(.+?)\*\* —/gm
|
|
314
|
+
let match: RegExpExecArray | null = re.exec(glossaryContent)
|
|
315
|
+
while (match !== null) {
|
|
316
|
+
if (match[1] !== undefined) terms.push(match[1])
|
|
317
|
+
match = re.exec(glossaryContent)
|
|
318
|
+
}
|
|
319
|
+
return terms
|
|
320
|
+
}
|