From 94f6030661e5927eaaa3b9658e0249f26671dd81 Mon Sep 17 00:00:00 2001 From: Francois Lanusse Date: Tue, 30 Jun 2026 14:33:21 +0200 Subject: [PATCH 1/7] feat: redesign ASTRA referencing around a unified path grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-kind directives/roles and the dotted anchor grammar with a single slash-path grammar that mirrors astra.yaml, addressed by one {astra} role (inline) and one {astra} directive (block) — the MyST way — plus colon-namespaced variants and a native #astra: cross-reference scheme. - new src/path.ts: parse/resolve the unified grammar (collections, scope steps, option/evidence children, registries, leading-/ and ../) - {astra} directive renders elements, children, whole collections (registries), and bare sub-analyses; options label/caption/compact/ show/hide/universe/class - {astra} role is store-driven; {astra:num} emits a native numref crossReference; {astra:cite}/{astra:cite:t} emit MyST citations from DOI evidence; {astra:value} reads a table cell, metric, or decision selection - #astra: links resolve to crossReferences / cross-page links and ![](#astra:...) figure embeds, page-relative with ../ support - account for ASTRA dropping the narrative section (no card summaries, no #narrative anchors) - rewrite README authoring docs; add design_proposal.md - rewrite test suites + add tests/path.test.ts (105 tests pass) Signed-off-by: Francois Lanusse Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 149 +++-- design_proposal.md | 527 ++++++++++++++++ src/index.ts | 1036 ++++++++++++++++++------------- src/path.ts | 186 ++++++ src/transform/prose.ts | 499 +++++---------- src/transform/resolved-store.ts | 4 +- tests/path.test.ts | 174 ++++++ tests/plugin-core.test.ts | 461 +++++++------- tests/prose.test.ts | 381 ++++-------- 9 files changed, 2081 insertions(+), 1336 deletions(-) create mode 100644 design_proposal.md create mode 100644 src/path.ts create mode 100644 tests/path.test.ts diff --git a/README.md b/README.md index eb9dda1..2d45f24 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,11 @@ with the analysis. ```markdown The combined LRG3+ELG1 bin reaches -$D_V/r_d =$ {astra:value}`bao_distance_table tracer=lrg3_elg1 col=DV_over_rd pm` -at $z_\mathrm{eff} =$ {astra:value}`bao_distance_table tracer=lrg3_elg1 col=z_eff`, -consistent with the {astra:finding}`bao_detected_post_recon` detection. +$D_V/r_d =$ {astra:value}`outputs/bao_distance_table col=DV_over_rd tracer=lrg3_elg1 ±` +at $z_\mathrm{eff} =$ {astra:value}`outputs/bao_distance_table col=z_eff tracer=lrg3_elg1`, +consistent with the {astra}`findings/bao_detected_post_recon` detection. -:::{astra:output} bao_fit_plot +:::{astra} outputs/bao_fit_plot ::: ``` @@ -117,62 +117,119 @@ That's it — no custom server and no build step of your own. MySTRA reads ## Authoring -The directive and role vocabulary below *is* your compositional surface — what -you place is what appears. +You reference **any part** of an ASTRA analysis with one idea: a **path** that +mirrors `astra.yaml`. One name — `astra` — drives both surfaces, the MyST way +(just as `{math}` is both a role and a directive): wrap a path in the `{astra}` +*role* to mention it inline, or use the `{astra}` *directive* to embed it as a +block. -### Block directives — import a component by id +### Paths — addressing any element -```markdown -:::{astra:decision} covariance_source -::: # dropdown: the choice + tabbed options -:::{astra:output} bao_fit_plot -::: # the figure (or table), with provenance -:::{astra:finding} bao_detected_post_recon -::: # claim + notes + scope + evidence (:compact: trims to claim only) -:::{astra:prior-insight} recon_sharpens_bao_peak -::: # the prior insight as an admonition -:::{astra:inputs} -::: # the inputs registry table (root scope) -:::{astra:outputs} clustering -::: # outputs table for the `clustering` sub-analysis -:::{astra:subanalysis} reconstruction -::: # a nav card linking to the sub-analysis page +A path is a slash-separated route through the analysis tree. Read it like a file +path; the first meaningful segment is a top-level `astra.yaml` collection. + +``` +outputs/hubble_diagram an output (figure / table / metric / …) +decisions/algorithm a decision +decisions/algorithm/options/gp a child — one option of a decision +findings/sig/evidence/fig1 a child — one evidence record of a finding +prior_insights/recon_sharpens_bao a prior insight +inputs/raw_catalog an input +reconstruction/outputs/xi an output in the `reconstruction` sub-analysis +reconstruction the sub-analysis itself +outputs a whole collection (a registry) ``` -### Inline roles — cite a component in a sentence +Collections are the `astra.yaml` keys: `inputs`, `outputs`, `decisions`, +`findings`, `prior_insights` (hyphen alias `prior-insights`), `analyses`, +`universes`. A sub-analysis id may be written directly (the `analyses/` prefix is +implied) and nests to any depth (`clustering/correlation/outputs/xi`). In roles +and directives a path resolves from the **root analysis** (a leading `/` is +optional); the `#astra:` link scheme (below) resolves relative to the **page**, +and additionally supports `../` to climb scopes. + +### Inline references — the `{astra}` role + +```markdown +We adopt the {astra}`decisions/algorithm` and report {astra}`outputs/hubble_diagram`, +which confirms {astra}`findings/signal_detected`. + +{astra}`our preferred method ` # custom display text +``` Each renders as a neutral text label (a rich theme adds a kind glyph and a hover -preview card): +preview card). A few specialised variants follow MyST's colon convention +(`{cite:p}` / `{cite:t}`): + +```markdown +{astra:num}`outputs/hubble_diagram` # "Figure 3" (like {numref}; supports %s) +{astra:num}`see Fig. %s ` +{astra:cite}`prior_insights/recon_sharpens_bao` # "(Chen et al., 2024)" — parenthetical +{astra:cite:t}`prior_insights/recon_sharpens_bao` # "Chen et al. (2024)" — textual +``` + +### Block embeds — the `{astra}` directive ```markdown -{astra:decision}`covariance_source` -{astra:output}`hubble_diagram_plot` -{astra:finding}`subpercent_alpha_iso_precision` -{astra:prior-insight}`recon_sharpens_bao_peak|the recovered peak` # |display override -{astra:analysis}`reconstruction` +:::{astra} decisions/algorithm +::: # the decision + its tabbed options +:::{astra} outputs/hubble_diagram +::: # the figure (or table / metric), with provenance +:::{astra} findings/signal_detected +::: # claim + notes + scope + evidence +:::{astra} prior_insights/recon_sharpens_bao +::: # the prior insight as an admonition +:::{astra} reconstruction +::: # a nav card linking to the sub-analysis page +:::{astra} outputs +::: # a whole collection → the outputs registry +:::{astra} reconstruction/inputs +::: # the inputs registry for a sub-analysis ``` +Options follow MyST's `:key: value` form: + +| Option | Meaning | +|---|---| +| `:label:` | Cross-reference label for the rendered block (manage the anchor yourself). | +| `:caption:` | Caption text (figure / table outputs). | +| `:compact:` | Findings: claim + notes + scope only (no evidence figures). | +| `:show:` / `:hide:` | Findings: parts to include / exclude (`claim, notes, scope, evidence`). | +| `:universe:` | Render the element as resolved under a specific universe id. | +| `:class:` | Extra CSS class(es) on the rendered block. | + ### Live values — never hard-type a measured number -Pull a cell straight from a materialised result product at build time: +Pull a number straight from the resolved analysis at build time: ```markdown -{astra:value}`bao_distance_table tracer=lrg3_elg1 col=DV_over_rd pm` → 19.88 ± 0.17 -{astra:value}`bao_alpha_values tracer=elg1 recon=Pre col=alpha1_std` → 0.0696 +{astra:value}`outputs/bao_distance_table col=DV_over_rd tracer=lrg3_elg1 ±` → 19.88 ± 0.17 +{astra:value}`outputs/bao_alpha_values col=alpha1 tracer=elg1 recon=Pre sig=3` → 0.0696 +{astra:value}`decisions/algorithm` → the selected option ``` -Grammar: ` col= [= …] [pm] [err=] [sig=N]`. -It reads the output's CSV/JSON, filters rows by each `key=val`, and renders the -selected cell — append `pm` (or `err=`) to show `± std`, `sig=N` to set -significant figures. +Grammar: ` [col=] [= …] [±|pm] [err=] [sig=N]`. For a +table output it reads the CSV/JSON, filters rows by each `key=val`, and renders +the selected cell — append `±` (or `pm`/`err=`) to show `± std`, `sig=N` to +set significant figures. A metric output renders its scalar; a `decisions/` +path renders the option selected under the active universe. + +### Native cross-references and embeds + +Every element is also a MyST cross-reference target under the `astra:` scheme, so +plain MyST links work — resolved relative to the current page, with `../` and a +leading `/`: -### Cross-references and scoping +```markdown +[](#astra:outputs/hubble_diagram) # auto-filled link text +[the diagram](#astra:outputs/hubble_diagram) # custom text +![](#astra:outputs/hubble_diagram) # embed a figure output -- **Anchors**: `[text](#decisions.x)`, `#outputs.y`, `#analyses.sub.…` resolve - to cross-references, alongside plain MyST `[](#output-bao_fit_plot)`. -- **Scoping**: a component path is `` for the root analysis or `.` - for a sub-analysis (e.g. `reconstruction.algorithm`), and can nest - (`a.b.id`). Each sub-analysis is typically its own page. +:::{figure} #astra:outputs/hubble_diagram +:label: fig-hubble +A caption written here, in the report. +::: +``` Everything else — prose, math, figures you author yourself, the table of contents, multi-page structure — is ordinary MyST. @@ -221,8 +278,9 @@ comes for free once a project bibliography is wired. Every placed block carries a stable `astra-` class (`astra-decision`, `astra-output`/`--figure`, `astra-finding`, -`astra-prior-insight`, `astra-inputs`/`astra-outputs`, `astra-subanalysis`) on -the node bearing its `-` identifier; inline tokens are neutral +`astra-prior-insight`, `astra-input`/`astra-inputs`/`astra-outputs`, +`astra-option`, `astra-universe`, `astra-subanalysis`) on the node bearing its +`-` identifier; inline tokens are neutral (`span.astra-ref--`). For rich rendering the plugin also bakes a **resolved store** onto a hidden `div.astra-store` carrier's `data` (per page): the fully resolved outputs (project-relative paths, parsed table/metric values, recipes, @@ -242,10 +300,11 @@ exported `ResolvedStore` / `Serialized*` types. ``` src/ ├── index.ts The MyST plugin + package entry (default export = the plugin) +├── path.ts The unified reference path grammar (parse + resolve) ├── loader.ts Load a project for one universe (via the SDK) + resolve result files └── transform/ Per-component renderers used by the plugin ├── ast-helpers.ts Pure AST node constructors - ├── prose.ts Parse component Markdown + resolve ASTRA anchors + ├── prose.ts Parse component Markdown + resolve #astra: cross-references ├── parse-table-data.ts CSV/JSON table parser ├── resolve-output.ts Resolves `from:` output/alias chains ├── provenance.ts Traces an output's decision/input provenance frames diff --git a/design_proposal.md b/design_proposal.md new file mode 100644 index 0000000..ca94992 --- /dev/null +++ b/design_proposal.md @@ -0,0 +1,527 @@ +# Referencing ASTRA components in MySTRA — Design Proposal + +> Status: proposal / draft documentation +> Audience: report authors (the humans who write the Markdown) + +This document describes a redesigned, ground-up system for referencing **any part +of an ASTRA analysis** from inside a MySTRA report. It is written as +user-facing documentation: if this proposal is adopted, this is roughly the page +an author would read to learn the syntax. + +The design follows [MyST's own conventions for roles and directives](https://mystmd.org/guide/syntax-overview) +as closely as possible, so that everything you already know about MyST +cross-references, citations, and embedding carries over directly. + +--- + +## 1. The one idea you need + +Every report is a view onto an **ASTRA analysis tree**. That tree is exactly the +structure of your `astra.yaml`: + +```yaml +# astra.yaml (sketch) +inputs: { raw_catalog: {…} } +outputs: { hubble_diagram: {…} } +decisions: { algorithm: { options: { gp: {…}, spline: {…} } } } +findings: { signal_detected: { evidence: { fig1: {…} } } } +prior_insights:{ recon_sharpens_bao: {…} } +analyses: # sub-analyses, nested to any depth + reconstruction: + decisions: { method: {…} } + outputs: { xi: {…} } +``` + +To reference **anything**, you write the **path to it in that tree**: + +``` +decisions/algorithm the "algorithm" decision (this page's scope) +decisions/algorithm/options/gp one option of that decision +outputs/hubble_diagram an output (figure / table / metric / …) +findings/signal_detected/evidence/fig1 one piece of evidence behind a finding +reconstruction/outputs/xi an output inside the "reconstruction" sub-analysis +``` + +There are then only **two things you do with a path**: + +| You want to… | Use… | MyST kind | +|------------------------------------------------|-----------------------|-----------| +| **mention / cite** it in a sentence (inline) | the `{astra}` *role* | inline | +| **embed / present** it as a block | the `{astra}` *directive* | block | + +That's the whole model. One path grammar, one name (`astra`), used inline as a +role and as a block as a directive — the same way MyST reuses `{math}` both as a +role and a directive. Everything else on this page is detail. + +### Design principles (why it looks like this) + +1. **One addressing scheme for everything.** A finding's third piece of + evidence, a single option of a decision, a sub-analysis three levels deep, a + cell in a results table — all are reachable with the *same* path grammar. If + it exists in `astra.yaml`, you can point at it. +2. **The path *is* the `astra.yaml` structure.** No second mental model to learn. + Collection names (`outputs`, `decisions`, `findings`, …) are the YAML keys; + nesting uses `/` like a file path; `..` and a leading `/` mean what they mean + in every shell on earth. +3. **Roles for inline, directives for blocks** — the fundamental MyST split. You + never have to remember which custom name does which; it's `{astra}` either way. +4. **Native MyST first.** Every element is also a normal MyST *cross-reference + target*, so plain `[text](#…)`, the `@` shorthand, hover previews, + `![](#…)` figure-embeds, and `{figure}` wrappers all work without learning + anything ASTRA-specific. +5. **Variants follow MyST's colon convention.** Just as MyST has `{cite:p}` and + `{cite:t}`, the small number of specialised behaviours are colon-suffixed: + `{astra:num}`, `{astra:value}`, `{astra:cite}`. Nothing ad-hoc. + +--- + +## 2. Paths: addressing any component + +A path is a slash-separated route through the analysis tree. Read it left to +right exactly like a file path. + +### 2.1 Collections and ids + +The first meaningful segment is a **collection** (a top-level ASTRA key), and the +next is the **id** of the element inside it: + +``` +inputs/ +outputs/ +decisions/ +findings/ +prior_insights/ (hyphen alias: prior-insights/) +universes/ +analyses/ a sub-analysis +``` + +Examples: + +``` +outputs/hubble_diagram +decisions/algorithm +findings/signal_detected +prior_insights/recon_sharpens_bao +``` + +### 2.2 Children (going inside an element) + +Some elements contain addressable children. Keep walking the path: + +``` +decisions/algorithm/options/gp an Option of a Decision +findings/signal_detected/evidence/fig1 an Evidence record of a Finding +prior_insights/recon_sharpens_bao/evidence/chen2024 +``` + +### 2.3 Scopes (sub-analyses) and the `analyses/` shorthand + +Sub-analyses live under `analyses/`. Because sub-analyses are the *only* nestable +container, the `analyses/` segment is **optional** — a bare sub-analysis id at the +front of a path is understood as a scope step: + +``` +analyses/reconstruction/outputs/xi full form +reconstruction/outputs/xi shorthand — identical meaning +clustering/correlation/outputs/xi two scopes deep +reconstruction the sub-analysis itself +``` + +### 2.4 Relative, absolute, and parent paths + +Paths resolve **relative to the scope of the current page** by default (a page +that renders the `reconstruction` sub-analysis has `reconstruction` as its +scope). Use the familiar file-path markers to move around: + +``` +outputs/xi relative to this page's scope +/outputs/xi absolute — from the root analysis +../outputs/xi the parent scope +../../decisions/method two scopes up +``` + +This single rule is what makes the system *powerful enough to reference any part +of any analysis or sub-analysis from anywhere*: every element has both a stable +absolute address (`/…`) and convenient relative ones. + +### 2.5 Grammar (reference) + +``` +path ::= ["/"] step* target +step ::= (".." | sub-analysis-id) "/" ; ".." climbs, name descends +target ::= collection "/" id child* + | sub-analysis-id ; the sub-analysis itself + | collection ; the whole collection (a registry) +collection ::= "inputs" | "outputs" | "decisions" | "findings" + | "prior_insights" | "analyses" | "universes" +child ::= "/" ("options" | "evidence") "/" id +``` + +A path that stops at a **collection** (e.g. `outputs`, `reconstruction/inputs`) +addresses the whole registry — useful for the directive forms in §4.4. + +--- + +## 3. Referencing inline — the `{astra}` role + +Wrap a path in the `{astra}` role to drop a smart, linked mention into prose. It +behaves like MyST's `{ref}`: by default it renders the element's label as a +hyperlink, with a hover preview. + +```markdown +We adopt the {astra}`decisions/algorithm` and report the +{astra}`outputs/hubble_diagram`, which confirms {astra}`findings/signal_detected`. +``` + +renders roughly as: + +> We adopt the [GP reconstruction] and report the [Hubble diagram], which +> confirms [a >5σ signal]. + +### 3.1 Custom display text + +Use MyST's standard `text ` override (identical to `{ref}`): + +```markdown +{astra}`our preferred method ` +``` + +### 3.2 The native link / `@` forms + +Because every element is a real cross-reference target, you never *have* to use +the role. These are equivalent and fully MyST-native: + +```markdown +{astra}`outputs/hubble_diagram` the role (most explicit, always works) +[](#astra:outputs/hubble_diagram) markdown link, auto-filled text +[the diagram](#astra:outputs/hubble_diagram) markdown link, custom text +@astra:outputs/hubble_diagram @-shorthand (where your MyST build supports it) +``` + +Targets are namespaced under the `astra:` scheme so they never collide with your +own labels or bibliography keys. Leaving the link text empty auto-fills the label +or number, exactly as MyST does for figures and sections. + +### 3.3 Role variants (the colon family) + +Following MyST's `{cite:p}` / `{cite:t}` convention, a few specialised behaviours +are colon-suffixed. Each takes the same path grammar. + +| Role | Purpose | Example | Renders | +|------|---------|---------|---------| +| `{astra}` | smart linked reference (default per kind) | `` {astra}`outputs/hubble_diagram` `` | "Hubble diagram" (link) | +| `{astra:num}` | numbered reference (like `{numref}`) | `` {astra:num}`outputs/hubble_diagram` `` | "Figure 3" | +| `{astra:value}` | extract a live value (see §6) | `` {astra:value}`outputs/h0` `` | "67.4" | +| `{astra:cite}` | bibliographic citation, parenthetical | `` {astra:cite}`prior_insights/recon_sharpens_bao` `` | "(Chen et al., 2024)" | +| `{astra:cite:t}` | bibliographic citation, textual | `` {astra:cite:t}`prior_insights/recon_sharpens_bao` `` | "Chen et al. (2024)" | + +`{astra:num}` supports the `%s` number placeholder and the `{label}` placeholder +in custom text, just like `{numref}`: + +```markdown +{astra:num}`see Fig. %s ` → "see Fig. 3" +{astra:num}`the {label} (Fig. %s) ` +``` + +--- + +## 4. Embedding as a block — the `{astra}` directive + +Use `{astra}` as a directive (block form) to **render** the addressed component +in place. The path is the directive argument. Use colon fences `:::` (the body is +Markdown) per MyST's recommendation. + +```markdown +:::{astra} decisions/algorithm +::: +``` + +By default each kind gets a sensible presentation (see §5). The directive is +recursive over the path grammar: point it at a single element, a child, or a +whole collection, and it renders the appropriate thing. + +### 4.1 Single element + +```markdown +:::{astra} outputs/hubble_diagram +::: + +:::{astra} findings/signal_detected +::: + +:::{astra} reconstruction +::: # a sub-analysis → a navigation/summary card +``` + +### 4.2 Options + +```markdown +:::{astra} decisions/algorithm +::: # all options, rendered as tabs by default + +:::{astra} decisions/algorithm/options/gp +::: # just one option +``` + +### 4.3 Children of findings (evidence) + +```markdown +:::{astra} findings/signal_detected +:hide: evidence +::: # claim + notes only + +:::{astra} findings/signal_detected/evidence/fig1 +::: # a single evidence figure +``` + +### 4.4 Collections / registries + +Point the directive at a collection to render its registry table: + +```markdown +:::{astra} inputs +::: # the inputs registry for this scope + +:::{astra} reconstruction/outputs +::: # the outputs registry for a sub-analysis +``` + +### 4.5 Directive options + +All options follow MyST's standard `:key: value` form. + +| Option | Meaning | +|--------|---------| +| `:label:` (alias `:name:`) | Give this rendered block a label so *it* can be cross-referenced (standard MyST). | +| `:caption:` | Caption text for figure/table/card renders. | +| `:as:` | Presentation override. Outputs: `figure \| table \| metric \| value`. Decisions: `tabs \| list \| table`. Collections: `table \| list \| cards`. | +| `:show:` / `:hide:` | Comma-list of parts to include/exclude: `claim, rationale, notes, evidence, options, recipe, provenance`. | +| `:compact:` | Boolean. Dense rendering (label + essentials, no heavy media). | +| `:universe:` | Render the element as it resolves under a named universe, overriding the page's active one. | +| `:class:` | Extra CSS classes (standard MyST). | + +Example: + +```markdown +:::{astra} outputs/hubble_diagram +:as: table +:caption: Distance–redshift measurements used in the fit. +:label: tbl-hubble +::: +``` + +### 4.6 Native embedding interop + +Outputs are figures/tables, so MyST's native transclusion works directly — handy +when you want to wrap an output in a standard `{figure}` or `{table}`: + +```markdown +![](#astra:outputs/hubble_diagram) # embed the rendered output + +:::{figure} #astra:outputs/hubble_diagram +:label: fig-hubble +A caption written here, in the report. +::: +``` + +--- + +## 5. How each kind renders + +The path's collection determines both the default *auto-label* (what an empty +inline reference fills in) and the default *block presentation*. + +| Kind | Inline default (`{astra}`) | Block default (`:::{astra}`) | +|------|----------------------------|------------------------------| +| `inputs/` | input label | a row/card describing the source | +| `outputs/` | output label; `{astra:num}` → "Figure/Table N" | the figure / table / metric, with caption + provenance | +| `decisions/` | decision label | its options (tabs by default) + rationale | +| `decisions//options/` | option label | one option (description, support) | +| `findings/` | finding label/claim | claim + scope + notes + evidence blocks | +| `findings//evidence/` | the evidence (e.g. "Fig. N" or citation) | the single evidence item | +| `prior_insights/` | insight label; auto-appends its citation | a `seealso` admonition (claim + citation) | +| `analyses/` | sub-analysis name | a navigation/summary card linking to its page | +| `universes/` | universe label | the universe's decision selections | + +Auto-label resolution always falls back gracefully: explicit `label` → +humanised id. Inline references to outputs participate in MyST numbering, so +`{astra:num}` yields stable "Figure 3" / "Table 2" style text. + +--- + +## 6. Pulling live values — `{astra:value}` + +`{astra:value}` inlines a *number* taken straight from the resolved analysis, so +the prose never drifts from the results. It is the one role with a richer body +grammar, because selecting a scalar sometimes needs a row + column. + +``` +{astra:value}` [col=] [= …] [±] [err=] [sig=]` +``` + +| Target | Example | Renders | +|--------|---------|---------| +| **Metric output** (already scalar) | `` {astra:value}`outputs/h0` `` | `67.4` | +| **Table cell** (pick column + filter rows) | `` {astra:value}`outputs/bao_table col=DV_over_rd tracer=lrg3` `` | `19.88` | +| **…with uncertainty** | `` {astra:value}`outputs/bao_table col=DV_over_rd tracer=lrg3 ±` `` | `19.88 ± 0.17` | +| **…explicit error column / precision** | `` {astra:value}`outputs/bao_table col=alpha tracer=elg1 err=alpha_err sig=3` `` | `0.0696` | +| **Decision** (→ selected option under the active universe) | `` {astra:value}`decisions/algorithm` `` | `GP reconstruction` | + +Rules: +- `col=` selects the value column (required for table outputs). +- bare `key=value` pairs filter rows (case-insensitive); the match must be unique. +- `±` appends the matching `_std` / `_err` column if present; + `err=` names it explicitly. +- `sig=` sets significant figures (default 4); `dp=` sets decimal places. + +Because the selection rides on the same path, the *same value role* reads a +metric, a table cell, **or** which option a decision resolved to under the active +universe — one role, every scalar in the analysis. + +--- + +## 7. Citations and bibliography + +Findings and prior insights carry **evidence**, some of which are DOIs. Those +DOIs flow into MyST's normal citation/bibliography pipeline, so you get author–year +citations and an auto-generated reference list with no extra work. + +```markdown +This matches earlier work {astra:cite}`prior_insights/recon_sharpens_bao`. +{astra:cite:t}`prior_insights/recon_sharpens_bao` first reported the effect. +``` + +renders: + +> This matches earlier work (Chen et al., 2024). +> Chen et al. (2024) first reported the effect. + +- `{astra:cite}` → parenthetical, mirroring `{cite:p}`. +- `{astra:cite:t}` → textual/narrative, mirroring `{cite:t}`. +- A plain `{astra}` reference to a prior insight links to its rendered card; the + `{astra:cite}` variants are for when you want a formatted bibliographic citation + in the sentence. +- Multiple DOIs on one insight are grouped, like MyST's `;`-separated citations. + +You can also cite a finding's evidence directly: +`` {astra:cite}`findings/signal_detected/evidence/chen2024` ``. + +--- + +## 8. Cross-referencing what you embed + +Anything you embed can be labelled and then referenced like any MyST object, +which closes the loop: + +```markdown +:::{astra} outputs/hubble_diagram +:label: fig-hubble +::: + +As [](#fig-hubble) shows, the fit is excellent. +``` + +Two ways to reference, both valid: +- **By ASTRA path** — `{astra}`outputs/hubble_diagram`` or + `[](#astra:outputs/hubble_diagram)`. Works even with no manual label, anywhere + in the project. +- **By your own label** — the `:label:` you gave the embed, referenced with plain + MyST. Best when you want a specific caption/number tied to a specific placement. + +--- + +## 9. Worked example + +```markdown +--- +title: Hubble Diagram Analysis +--- + +## Method + +We measure the expansion history under several methodological choices. The +central one is {astra}`decisions/algorithm`; we adopt +{astra}`decisions/algorithm/options/gp`, motivated by +{astra:cite:t}`prior_insights/recon_sharpens_bao`. + +:::{astra} decisions/algorithm +::: + +## Results + +The headline result is the {astra:num}`outputs/hubble_diagram`: + +:::{astra} outputs/hubble_diagram +:label: fig-hubble +::: + +From the BAO table we recover +{astra:value}`outputs/bao_table col=DV_over_rd tracer=lrg3 ±` for the LRG3 +tracer, supporting {astra}`findings/signal_detected`: + +:::{astra} findings/signal_detected +::: + +A parallel treatment appears in the {astra}`reconstruction` sub-analysis; compare +its {astra}`reconstruction/outputs/xi` with the result above. + +## Data products + +:::{astra} outputs +::: +``` + +--- + +## 10. Cheat sheet + +```text +PATHS (mirror your astra.yaml; '/', '..', leading '/' as in file paths) + outputs/hubble_diagram element in this scope + decisions/algorithm/options/gp a child (option) + findings/sig/evidence/fig1 a child (evidence) + reconstruction/outputs/xi sub-analysis (analyses/ implied) + /decisions/method ../outputs/xi absolute / parent + outputs reconstruction/inputs a whole collection (registry) + +INLINE (roles) + {astra}`PATH` smart linked reference + {astra}`text ` custom display text + {astra:num}`PATH` "Figure 3" (supports %s, {label}) + {astra:value}`PATH col=C key=v ± sig=3` live value from results + {astra:cite}`PATH` {astra:cite:t}`PATH` citation (paren / textual) + [](#astra:PATH) @astra:PATH native link / shorthand forms + +BLOCK (directive) + :::{astra} PATH + :label: :caption: :as: :show:/:hide: :compact: :universe: :class: + ::: + ![](#astra:PATH) native figure/table embed + :::{figure} #astra:PATH … ::: wrap an output in a figure +``` + +--- + +## Appendix — relationship to the current system + +This is a clean-slate design, but for reviewers, here is what changes and why. + +- **One grammar instead of two.** Today there are custom kind-named roles + (`{astra:output}`x``) *and* a separate dotted anchor grammar + (`[](#outputs.x)`). These are merged into a single slash path that both the + role and the native MyST link consume. +- **One role + one directive instead of ~5 + ~7.** The kind is read from the + path's collection segment, so `{astra}` and `:::{astra}` cover every element. + Specialised behaviour is limited to the small MyST-style colon family + (`:num`, `:value`, `:cite`, `:cite:t`). +- **The path mirrors `astra.yaml`** (plural collection keys, `/` nesting) rather + than a bespoke dotted scheme, and adds file-path semantics (`..`, leading `/`) + for moving between scopes — so referencing across sub-analyses needs no new + rules. +- **Children and collections are first-class.** Options, evidence, table cells, + and whole registries are addressable with the same grammar, so there is no + element the system cannot point at. +- **Native MyST throughout.** Every element registers as a cross-reference target + under the `astra:` scheme, so `[](#…)`, `@…`, hover previews, `![](#…)` + embeds, `{figure}`/`{table}` wrappers, and DOI bibliography generation all work + with no ASTRA-specific knowledge. diff --git a/src/index.ts b/src/index.ts index fbf60f1..4326a6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,39 +5,36 @@ * `project.plugins`); named exports at the bottom expose the loader + resolved * store for programmatic use. * - * The author writes a normal MyST Markdown report and pulls in ASTRA components - * by id; this plugin reads `astra.yaml` at build time and emits standard MyST - * AST, running on the stock `myst` CLI and themes: + * Authors reference any part of an ASTRA analysis through a single, unified + * **path grammar** that mirrors `astra.yaml` (see `./path.ts`). One name — + * `astra` — drives both surfaces, the MyST way (`{math}` is likewise a role and + * a directive): * - * Block "import" (directives): - * :::{astra:decision} covariance_source - * ::: - * :::{astra:output} bao_fit_plot - * ::: - * :::{astra:finding} bao_detected_post_recon - * ::: - * :::{astra:prior-insight} recon_sharpens_bao_peak - * ::: - * :::{astra:inputs} - * ::: # full inputs registry table (root scope) - * :::{astra:outputs} clustering - * ::: # outputs table for the clustering sub-analysis - * :::{astra:subanalysis} reconstruction - * ::: # nav card to the sub-analysis page + * Inline reference (role): + * {astra}`outputs/hubble_diagram` link + hover card + * {astra}`our method ` custom display text + * {astra:num}`outputs/hubble_diagram` numbered ("Figure 3") + * {astra:value}`outputs/bao_table col=DV tracer=lrg3 ±` live number + * {astra:cite}`prior_insights/recon` parenthetical citation + * {astra:cite:t}`prior_insights/recon` textual citation * - * Inline "cite" (roles): - * {astra:decision}`covariance_source` - * {astra:output}`hubble_diagram_plot` - * {astra:finding}`subpercent_alpha_iso_precision` - * {astra:prior-insight}`recon_sharpens_bao_peak` + * Block embed (directive): + * :::{astra} decisions/algorithm ::: the decision + its options + * :::{astra} outputs/hubble_diagram ::: the figure / table / metric + * :::{astra} findings/signal ::: claim + scope + evidence + * :::{astra} reconstruction ::: a sub-analysis nav card + * :::{astra} outputs ::: the outputs registry * - * Scoping: a component path is `` (root analysis) or `.` - * (sub-analysis), e.g. `reconstruction.algorithm`. Sub-analysis paths can - * nest (`a.b.id`). Table directives take a bare scope path (`reconstruction`) - * or nothing (root). + * Native cross-reference scheme (resolved relative to the page scope): + * [text](#astra:outputs/hubble_diagram) crossReference / page link + * ![](#astra:outputs/hubble_diagram) embed a figure output * - * The plugin reads the ASTRA project once (cached) and renders each component - * via the per-component helpers in `./transform/`. + * Paths in roles and directives resolve from the **root analysis** (a leading + * `/` is optional); the `#astra:` link scheme resolves relative to the **page + * scope** and supports `../`. See README.md for the authoring guide. + * + * The plugin reads the ASTRA project once (cached) and renders each element via + * the per-kind helpers in `./transform/`. * * The project root defaults to `process.cwd()` (run `myst start` from the * project dir). Override with `ASTRA_PROJECT_ROOT`; pick a universe with @@ -52,12 +49,8 @@ import { universeFilePath, type ArtifactResolver, } from './loader.js'; -import type { Analysis, Input, Insight, Output, Universe } from '@astra-spec/sdk'; -import { - makeProseParser, - resolveNarrativeAnchors, - firstParagraphText, -} from './transform/prose.js'; +import type { Analysis, Decision, Input, Insight, Output, Universe } from '@astra-spec/sdk'; +import { makeProseParser, resolveNarrativeAnchors } from './transform/prose.js'; import type { AnalysisScope, PriorInsightScope, @@ -68,12 +61,15 @@ import { admonitionTitle, card, cite, + citeGroup, + crossReference, emphasis, heading, hiddenDiv, makeTabItem, paragraph, refNode, + strong, text, walkNodes, } from './transform/ast-helpers.js'; @@ -85,6 +81,15 @@ import { parseTableData } from './transform/parse-table-data.js'; import { resolveOutputs } from './transform/resolve-output.js'; import { buildResolvedStore } from './transform/resolved-store.js'; import { pageFrames, type ProvFrame } from './transform/provenance.js'; +import { + parseAstraPath, + pathIdentifier, + splitDisplay, + dottedKey, + KIND_BY_COLLECTION, + type AstraPath, + type Collection, +} from './path.js'; // ── Project loading + cache ───────────────────────────────────────────── @@ -127,8 +132,6 @@ function sourceMtimeMs(root: string, universe?: string): number { // ignore — a missing dependency leaves `newest` as-is } } - // `-Infinity` (no dependency could be stat'd) is non-finite, so the caller's - // `Number.isFinite` guard falls through to a reload just as `NaN` would. return newest; } @@ -140,7 +143,6 @@ function getSource(root: string, universe?: string): Source { return cached.source; } const source = loadASTRASource(root, universe); - // Overwrite the same key on reload so the cache never grows unbounded. projectCache.set(key, { source, mtimeMs }); return source; } @@ -180,16 +182,13 @@ function resolveScope( const priorInsightScopes: PriorInsightScope[] = []; const analysisScopes: AnalysisScope[] = []; const slugParts: string[] = []; - // The scope's results root: the project dir, extended by each descended - // sub-analysis's `path:` (relative to its parent, so nesting composes). An - // output's artifact then lives at `/results///`. let resultsBase = root; for (const seg of analysisPath) { const child = analysis.analyses?.[seg]; if (!child) { throw new Error( - `unknown sub-analysis "${seg}" (path: ${analysisPath.join('.') || ''})`, + `unknown sub-analysis "${seg}" (path: ${analysisPath.join('/') || ''})`, ); } const parentSlug = slugParts.length ? slugParts.join('/') : 'index'; @@ -226,10 +225,6 @@ function resolveScope( ...priorInsightScopes.map((s) => s.priorInsights), analysis.prior_insights ?? {}, ); - // Resolved view, keyed by declared id: aliased outputs (`from:`) inherit - // type/description/inputs/decisions/recipe from their source, so the figure/ - // table directive, the provenance disclosure, and inline cards all see the - // real artifact rather than a bare pointer. const outputsById = new Map( resolveOutputs(analysis).map(({ resolved }) => [resolved.id, resolved] as const), ); @@ -288,23 +283,17 @@ function errorNode(message: string): any { }; } -/** Split a component path into [analysisPath, componentId]. */ -function splitPath(arg: unknown): { analysisPath: string[]; id: string | null } { - const parts = String(arg ?? '') - .trim() - .split('.') - .filter(Boolean); - const id = parts.pop() ?? null; - return { analysisPath: parts, id }; +/** snake_case id → readable words, for the inline label when nothing better. */ +function humanize(id: string): string { + return id.replace(/_/g, ' '); } // ── Recognition markers ───────────────────────────────────────────────────── // // Every placed ASTRA block carries a stable `astra-` class (+ optional -// `--`) on the node that bears its `-` identifier. The class -// is harmless to book-theme but lets a rich theme select the element -// (`.astra-output`, `[identifier^="output-"]`) and join it to the resolved -// store by id (STRATEGY-A-REFACTOR.md §5). +// `--`) on the node that bears its `-` identifier, letting a +// rich theme select the element (`.astra-output`, `[identifier^="output-"]`) and +// join it to the resolved store by id. /** Add a semantic class to a node, idempotently (space-joined). */ function addClass(node: any, cls: string): void { @@ -319,12 +308,7 @@ function addClass(node: any, cls: string): void { * else the first node) with `astra-` and, when given, * `astra---`. Returns the same node array for chaining. */ -function tagComponent( - nodes: any[], - kind: string, - id: string, - subtype?: string, -): any[] { +function tagComponent(nodes: any[], kind: string, id: string, subtype?: string): any[] { const ident = `${kind}-${id}`; const carrier = nodes.find((n) => n?.identifier === ident) ?? nodes[0]; if (carrier) { @@ -334,139 +318,78 @@ function tagComponent( return nodes; } -// ── Block directives ("import") ───────────────────────────────────────────── - -/** Directive that resolves a `.` path and renders one component. */ -function componentDirective( - name: string, - render: (id: string, scope: Scope, options: Record) => any[], - options?: Record, -) { - return { - name: `astra:${name}`, - doc: `Import the ASTRA ${name} as a rich block.`, - arg: { type: String, required: true, doc: 'Component path: or .' }, - ...(options ? { options } : {}), - run(data: any): any[] { - const { analysisPath, id } = splitPath(data?.arg); - if (!id) return [errorNode(`astra:${name} requires an id`)]; - try { - const scope = resolveScope(projectRoot(), universeName(), analysisPath); - return rewriteStaticImages(render(id, scope, data?.options ?? {}), scope); - } catch (err) { - return [errorNode(`astra:${name} "${data?.arg}": ${(err as Error).message}`)]; - } - }, - }; +/** The carrier node of a rendered component (for option overrides). */ +function carrierOf(nodes: any[], identifier: string): any { + return nodes.find((n) => n?.identifier === identifier) ?? nodes[0]; } -/** Directive whose whole arg is a scope path (no trailing component). */ -function tableDirective(name: string, render: (scope: Scope) => any[]) { - return { - name: `astra:${name}`, - doc: `Render the ASTRA ${name} table for an analysis scope (default: root).`, - arg: { type: String, required: false, doc: 'Sub-analysis scope, e.g. clustering' }, - run(data: any): any[] { - const analysisPath = String(data?.arg ?? '') - .trim() - .split('.') - .filter(Boolean); - try { - const scope = resolveScope(projectRoot(), universeName(), analysisPath); - return render(scope); - } catch (err) { - return [errorNode(`astra:${name} "${data?.arg ?? ''}": ${(err as Error).message}`)]; - } - }, - }; +// ── Block directive: `:::{astra} ` ───────────────────────────────────── +// +// One directive renders any addressable element, child, or collection. The +// parsed path decides what — an element by kind, a child (option / evidence), a +// whole collection (a registry), or a bare sub-analysis (a nav card). + +interface DirectiveOptions { + label?: string; + caption?: string; + compact?: boolean; + show?: string; + hide?: string; + universe?: string; + class?: string; } -const decisionDirective = componentDirective('decision', (id, scope) => { - const decision = scope.analysis.decisions?.[id]; - if (!decision) throw new Error(`no decision "${id}" in this scope`); - if (!isDecisionRendered(decision, scope.universe)) { - throw new Error( - `decision "${id}" is a bare from-reference or its \`when\` is unmet under universe "${scope.universe.id}"`, - ); - } - return tagComponent( - renderDecision( - id, - decision, - scope.priorInsights, - scope.universe, - scope.prose, - scope.tabItem, - ), - 'decision', - id, - ); -}); +/** Resolve which finding parts to render from compact / show / hide options. */ +function findingParts(options: DirectiveOptions): Set { + const all = ['claim', 'notes', 'scope', 'evidence']; + let parts = new Set(all); + if (options.show) parts = new Set(options.show.split(/[,\s]+/).filter(Boolean)); + if (options.hide) for (const p of options.hide.split(/[,\s]+/).filter(Boolean)) parts.delete(p); + if (options.compact) parts.delete('evidence'); + return parts; +} -const outputDirective = componentDirective('output', (id, scope) => { - const output = scope.outputsById.get(id); - if (!output) throw new Error(`no output "${id}" in this scope`); - const figure = renderOneOutput(output, id, scope.results, scope.prose, { - resultUrl: resultUrl(scope.root), - }); - // The carrier (figure/table) is tagged `astra-output[ --]` for theme - // recognition; provenance UI is the rich theme's job (it reads the store — - // see astra-theme's AstraOutput ProvenanceDrawer). Plain themes show just - // the figure. - return tagComponent(figure, 'output', id, output.type); -}); - -const findingDirective = componentDirective( - 'finding', - (id, scope, options) => { - const findings = scope.analysis.findings ?? {}; - const finding = findings[id]; - if (!finding) throw new Error(`no finding "${id}" in this scope`); - const index = Object.keys(findings).indexOf(id) + 1; - // `:compact:` renders just the claim heading + notes + scope (no evidence - // figures) — used for the back-matter hover/click targets so the inline - // hover overlay stays tight and figures aren't duplicated. - if (options?.compact) { - const nodes: any[] = [ - heading(3, [text(`${index}. `), ...scope.prose.inline(finding.claim)], `finding-${id}`), - ]; - if (finding.notes) nodes.push(...scope.prose.blocks(finding.notes)); - if (finding.scope) nodes.push(paragraph([emphasis([text(`Scope: ${finding.scope}`)])])); - return tagComponent(nodes, 'finding', id); - } +/** Render one finding, honouring the requested parts (claim is always kept). */ +function renderFindingParts( + id: string, + scope: Scope, + options: DirectiveOptions, +): any[] { + const findings = scope.analysis.findings ?? {}; + const finding = findings[id]; + if (!finding) throw new Error(`no finding "${id}" in this scope`); + const index = Object.keys(findings).indexOf(id) + 1; + const parts = findingParts(options); + + // Full render (the default) covers claim + notes + scope + evidence; anything + // that drops evidence builds the lighter claim/notes/scope form by hand. + if (parts.has('evidence')) { return tagComponent( - renderFinding( - finding, - index, - id, - scope.results, - scope.outputsById, - scope.prose, - ), + renderFinding(finding, index, id, scope.results, scope.outputsById, scope.prose), 'finding', id, ); - }, - { compact: { type: Boolean, doc: 'Render claim + notes + scope only (no evidence figures).' } }, -); + } + const nodes: any[] = [ + heading(3, [text(`${index}. `), ...scope.prose.inline(finding.claim)], `finding-${id}`), + ]; + if (parts.has('notes') && finding.notes) nodes.push(...scope.prose.blocks(finding.notes)); + if (parts.has('scope') && finding.scope) { + nodes.push(paragraph([emphasis([text(`Scope: ${finding.scope}`)])])); + } + return tagComponent(nodes, 'finding', id); +} /** - * Render an author-placed prior insight (the `:::{astra:prior-insight}` block): - * the claim + evidence wrapped in a `seealso` admonition (a node every MyST - * theme renders cleanly), carrying the `prior_insight-` identifier. - * - * A `container[kind=prior-insight]` would be the natural node, but the stock - * theme rejects it ("no valid content besides caption"); the `seealso` - * admonition is the stock-friendly equivalent. + * Render a prior insight (the author-placed block): the claim + evidence wrapped + * in a `seealso` admonition — a node every MyST theme renders cleanly — carrying + * the `prior_insight-` identifier. */ function renderPriorInsightBlock(id: string, insight: Insight, prose: ProseParser): any { const titleBits = ['Prior insight']; if (insight.label) titleBits.push(insight.label); else if (insight.scope) titleBits.push(insight.scope); - const body = [ - paragraph(prose.inline(insight.claim)), - ...renderInsightEvidence(insight), - ]; + const body = [paragraph(prose.inline(insight.claim)), ...renderInsightEvidence(insight)]; const node: any = admonition('seealso', [admonitionTitle([text(titleBits.join(' — '))]), ...body], { class: 'astra-prior-insight', }); @@ -475,144 +398,435 @@ function renderPriorInsightBlock(id: string, insight: Insight, prose: ProseParse return node; } -const priorInsightDirective = componentDirective('prior-insight', (id, scope) => { - // `scope.priorInsights` already merges this analysis's own prior_insights over - // its ancestors' (see resolveScope), so it's the single lookup to use. - const insight = scope.priorInsights[id]; - if (!insight) throw new Error(`no prior_insight "${id}" in this scope`); - return [renderPriorInsightBlock(id, insight, scope.prose)]; -}); - -const inputsDirective = tableDirective('inputs', (scope) => { - const inputs = scope.analysis.inputs ?? []; - if (inputs.length === 0) return [errorNode('no inputs in this scope')]; - // Inputs are only carried by this table (no rich input block), so the - // `input-` row identifiers stay as the canonical anchor targets. - const table = renderInputsTable(inputs, scope.prose); - addClass(table, 'astra-inputs'); +/** Render a single input as a one-row registry table tagged `astra-input`. */ +function renderOneInput(id: string, scope: Scope): any[] { + const input = (scope.analysis.inputs ?? []).find((i) => i.id === id); + if (!input) throw new Error(`no input "${id}" in this scope`); + const table = renderInputsTable([input], scope.prose); + addClass(table, 'astra-input'); return [table]; -}); - -const outputsDirective = tableDirective('outputs', (scope) => { - const outputs = scope.analysis.outputs ?? []; - if (outputs.length === 0) return [errorNode('no outputs in this scope')]; - const table = renderOutputsTable(outputs, scope.prose); - // Strip row identifiers: the canonical `output-` carrier is the rich - // `:::{astra:output}` block. Leaving them here would collide when the - // report both lists an output in the registry and embeds it as a figure. - for (const row of table.children ?? []) { - delete row.identifier; - delete row.label; +} + +/** Render one Option of a Decision (label + description + supporting insights). */ +function renderOneOption(decisionId: string, optionId: string, scope: Scope): any[] { + const decision = scope.analysis.decisions?.[decisionId]; + if (!decision?.options?.[optionId]) { + throw new Error(`no option "${optionId}" on decision "${decisionId}" in this scope`); } - addClass(table, 'astra-outputs'); - return [table]; -}); + const option = decision.options[optionId]; + const selected = (scope.universe.decisions?.[decisionId] ?? decision.default) === optionId; + const identifier = `option-${decisionId}-${optionId}`; + const head: any = heading(4, [ + text(option.label), + ...(selected ? [text(' '), emphasis([text('(selected)')])] : []), + ], identifier); + const nodes: any[] = [head]; + if (option.description) nodes.push(...scope.prose.blocks(option.description)); + const insights = (option.insights ?? []) + .map((iid) => scope.priorInsights[iid]) + .filter(Boolean); + if (insights.length > 0) { + const refs = (option.insights ?? []) + .filter((iid) => scope.priorInsights[iid]) + .map((iid) => + refNode('prior_insight', iid, iid, scope.priorInsights[iid].label ?? humanize(iid)), + ); + const para: any[] = [text(refs.length === 1 ? 'Supporting insight: ' : 'Supporting insights: ')]; + refs.forEach((r, i) => { + if (i > 0) para.push(text(', ')); + para.push(r); + }); + nodes.push(paragraph(para)); + } + addClass(head, 'astra-option'); + return nodes; +} + +/** Render one Evidence record of a finding or prior insight. */ +function renderOneEvidence(p: AstraPath, scope: Scope): any[] { + const owner = + p.collection === 'findings' + ? scope.analysis.findings?.[p.id!] + : scope.priorInsights[p.id!]; + if (!owner) throw new Error(`no ${p.collection} "${p.id}" in this scope`); + const ev = (owner.evidence ?? []).find((e: any) => e.id === p.child!.id); + if (!ev) throw new Error(`no evidence "${p.child!.id}" on ${p.collection} "${p.id}"`); + // Reuse the per-insight evidence renderer for a single record. + return renderInsightEvidence({ evidence: [ev] }); +} + +/** Render a universe as a table of its decision → selected-option labels. */ +function renderUniverse(universeId: string | null, scope: Scope): any[] { + const u = scope.universe; + const selections = u.decisions ?? {}; + const ids = Object.keys(selections); + const headerRow = { + type: 'tableRow', + isHeader: true, + children: [ + { type: 'tableCell', header: true, children: [text('Decision')] }, + { type: 'tableCell', header: true, children: [text('Selected')] }, + ], + }; + const rows = ids.map((decId) => { + const dec = scope.analysis.decisions?.[decId]; + const optId = selections[decId]; + const optLabel = dec?.options?.[optId]?.label ?? optId; + return { + type: 'tableRow', + children: [ + { type: 'tableCell', children: [strong([text(dec?.label ?? decId)])] }, + { type: 'tableCell', children: [text(optLabel)] }, + ], + }; + }); + const node: any = { type: 'table', children: [headerRow, ...rows] }; + node.identifier = `universe-${universeId ?? u.id}`; + node.label = node.identifier; + addClass(node, 'astra-universe'); + return [node]; +} + +/** Render a sub-analysis as a navigation card linking to its page. */ +function renderSubAnalysisCard(parentScope: string[], subId: string, scope: Scope): any[] { + const sub = scope.analysis.analyses?.[subId]; + if (!sub) throw new Error(`no sub-analysis "${subId}" in this scope`); + const title = sub.name ?? subId; + const url = '/' + [...parentScope, subId].join('/'); + const node: any = card(title, [], url); + node.identifier = `analysis-${subId}`; + node.label = node.identifier; + addClass(node, 'astra-subanalysis'); + return [node]; +} + +/** Render a whole collection (a registry) for the current scope. */ +function renderRegistry(collection: Collection, scope: Scope): any[] { + switch (collection) { + case 'inputs': { + const inputs = scope.analysis.inputs ?? []; + if (inputs.length === 0) return [errorNode('no inputs in this scope')]; + const table = renderInputsTable(inputs, scope.prose); + addClass(table, 'astra-inputs'); + return [table]; + } + case 'outputs': { + const outputs = scope.analysis.outputs ?? []; + if (outputs.length === 0) return [errorNode('no outputs in this scope')]; + const table = renderOutputsTable(outputs, scope.prose); + // Strip row identifiers: the canonical `output-` carrier is the rich + // output block. Leaving them here would collide when the report both lists + // an output in the registry and embeds it as a figure. + for (const row of table.children ?? []) { + delete row.identifier; + delete row.label; + } + addClass(table, 'astra-outputs'); + return [table]; + } + case 'decisions': { + const decisions = scope.analysis.decisions ?? {}; + const nodes: any[] = []; + for (const [id, decision] of Object.entries(decisions)) { + if (!isDecisionRendered(decision as Decision, scope.universe)) continue; + nodes.push( + ...tagComponent( + renderDecision(id, decision as Decision, scope.priorInsights, scope.universe, scope.prose, scope.tabItem), + 'decision', + id, + ), + ); + } + return nodes.length ? nodes : [errorNode('no rendered decisions in this scope')]; + } + case 'findings': { + const findings = scope.analysis.findings ?? {}; + const nodes: any[] = []; + Object.keys(findings).forEach((id) => nodes.push(...renderFindingParts(id, scope, {}))); + return nodes.length ? nodes : [errorNode('no findings in this scope')]; + } + case 'prior_insights': { + const insights = scope.analysis.prior_insights ?? {}; + const nodes = Object.entries(insights).map(([id, ins]) => + renderPriorInsightBlock(id, ins as Insight, scope.prose), + ); + return nodes.length ? nodes : [errorNode('no prior insights in this scope')]; + } + case 'analyses': { + const subs = scope.analysis.analyses ?? {}; + const nodes = Object.keys(subs).flatMap((id) => renderSubAnalysisCard(scope.slug === 'index' ? [] : scope.slug.split('/'), id, scope)); + return nodes.length ? nodes : [errorNode('no sub-analyses in this scope')]; + } + case 'universes': + return renderUniverse(null, scope); + } +} + +/** Render a single addressed element (path has a collection + id, no child). */ +function renderElement(p: AstraPath, scope: Scope, options: DirectiveOptions): any[] { + const id = p.id!; + switch (p.collection) { + case 'decisions': { + const decision = scope.analysis.decisions?.[id]; + if (!decision) throw new Error(`no decision "${id}" in this scope`); + if (!isDecisionRendered(decision, scope.universe)) { + throw new Error( + `decision "${id}" is a bare from-reference or its \`when\` is unmet under universe "${scope.universe.id}"`, + ); + } + return tagComponent( + renderDecision(id, decision, scope.priorInsights, scope.universe, scope.prose, scope.tabItem), + 'decision', + id, + ); + } + case 'outputs': { + const output = scope.outputsById.get(id); + if (!output) throw new Error(`no output "${id}" in this scope`); + const nodes = renderOneOutput(output, id, scope.results, scope.prose, { + resultUrl: resultUrl(scope.root), + }); + if (options.caption) applyCaption(nodes, scope, options.caption); + return tagComponent(nodes, 'output', id, output.type); + } + case 'findings': + return renderFindingParts(id, scope, options); + case 'prior_insights': { + const insight = scope.priorInsights[id]; + if (!insight) throw new Error(`no prior_insight "${id}" in this scope`); + return [renderPriorInsightBlock(id, insight, scope.prose)]; + } + case 'inputs': + return renderOneInput(id, scope); + case 'analyses': + return renderSubAnalysisCard(scope.slug === 'index' ? [] : scope.slug.split('/'), id, scope); + case 'universes': { + const sub = resolveScope(scope.root, id, []); + return renderUniverse(id, sub); + } + default: + return [errorNode(`astra: cannot render "${p.raw}"`)]; + } +} -const subAnalysisDirective = { - name: 'astra:subanalysis', - doc: 'Render a navigation card linking to a sub-analysis page.', - arg: { type: String, required: true, doc: 'Sub-analysis path, e.g. reconstruction' }, +/** Replace the first caption's content with the author's override text. */ +function applyCaption(nodes: any[], scope: Scope, captionMd: string): void { + let done = false; + walkNodes(nodes, (n) => { + if (!done && n.type === 'caption') { + n.children = [paragraph(scope.prose.inline(captionMd))]; + done = true; + } + }); +} + +const astraDirective = { + name: 'astra', + doc: 'Embed any ASTRA element, child, or collection by its path (e.g. outputs/hubble_diagram).', + arg: { + type: String, + required: true, + doc: 'A path: /[//], a sub-analysis, or a collection.', + }, + options: { + label: { type: String, doc: 'Cross-reference label for the rendered block.' }, + caption: { type: String, doc: 'Caption text (figure / table outputs).' }, + compact: { type: Boolean, doc: 'Findings: claim + notes + scope only (no evidence).' }, + show: { type: String, doc: 'Findings: parts to include (claim, notes, scope, evidence).' }, + hide: { type: String, doc: 'Findings: parts to exclude.' }, + universe: { type: String, doc: 'Render as resolved under this universe id.' }, + class: { type: String, doc: 'Extra CSS class(es) on the rendered block.' }, + }, run(data: any): any[] { - const { analysisPath, id } = splitPath(data?.arg); - if (!id) return [errorNode('astra:subanalysis requires a sub-analysis id')]; + const arg = String(data?.arg ?? ''); + const options: DirectiveOptions = data?.options ?? {}; + const p = parseAstraPath(arg); + if (!p.collection && p.scope.length === 0) return [errorNode('astra: empty path')]; + + // A bare sub-analysis resolves the *parent* scope and looks the sub up there. + const isBareSub = !p.collection; + const analysisPath = isBareSub ? p.scope.slice(0, -1) : p.scope; + try { - const scope = resolveScope(projectRoot(), universeName(), analysisPath); - const sub = scope.analysis.analyses?.[id]; - if (!sub) throw new Error(`no sub-analysis "${id}" in this scope`); - const title = sub.name ?? id; - const url = '/' + [...analysisPath, id].join('/'); - const summary = firstParagraphText(sub.narrative?.summary); - const children = summary ? [paragraph([text(summary)])] : []; - const node: any = card(title, children, url); - node.identifier = `analysis-${id}`; - node.label = node.identifier; - addClass(node, 'astra-subanalysis'); - return [node]; + const scope = resolveScope(projectRoot(), options.universe ?? universeName(), analysisPath); + let nodes: any[]; + if (isBareSub) { + nodes = renderSubAnalysisCard(analysisPath, p.scope[p.scope.length - 1], scope); + } else if (p.child) { + nodes = + p.child.collection === 'options' + ? renderOneOption(p.id!, p.child.id, scope) + : renderOneEvidence(p, scope); + } else if (p.id) { + nodes = renderElement(p, scope, options); + } else { + nodes = renderRegistry(p.collection!, scope); + } + applyBlockOptions(nodes, p, options); + return rewriteStaticImages(nodes, scope); } catch (err) { - return [errorNode(`astra:subanalysis "${data?.arg}": ${(err as Error).message}`)]; + return [errorNode(`astra "${arg}": ${(err as Error).message}`)]; } }, }; -// ── Inline reference tokens (store-driven) ── -// -// Each inline ASTRA reference renders as a neutral `astra-ref` span: the best -// available label as text, plus the join key (`kind`/`id`/`path`) on -// `data.astra`. The hover card is NOT baked into the node — a rich theme -// (`lightcone-astra`) joins the key to the resolved store carrier -// (`.astra-store`, keyed by id) and renders the card, the same mechanism MyST -// uses for citations (a `cite` node's label → `references.cite.data`). On a bare -// theme (no renderer) the span degrades to plain label text. See the resolved -// store (`./transform/resolved-store.ts`) for the data the theme reads. - -type CiteKind = 'decision' | 'output' | 'finding' | 'prior_insight' | 'analysis'; - -/** snake_case id → readable words, for the inline label when nothing better. */ -function humanize(id: string): string { - return id.replace(/_/g, ' '); +/** Apply `:label:` / `:class:` to the rendered block's carrier node. */ +function applyBlockOptions(nodes: any[], p: AstraPath, options: DirectiveOptions): void { + if (!nodes.length) return; + const ident = pathIdentifier(p); + const carrier = ident ? carrierOf(nodes, ident) : nodes[0]; + if (!carrier) return; + if (options.class) for (const c of options.class.split(/\s+/).filter(Boolean)) addClass(carrier, c); + if (options.label) { + carrier.identifier = options.label; + carrier.label = options.label; + } } -// The store-driven inline node (`refNode`, in ast-helpers) carries only semantic -// classes, the label as text, and the join key on `data.astra`; a rich theme -// renders the card from the store. `value` is self-describing — see the value role. - -/** Resolve the best inline label (and output subtype) for a cited element. */ -function citeLabel( - kind: CiteKind, - id: string, +// ── Inline reference roles ─────────────────────────────────────────────────── +// +// `{astra}` renders a neutral store-driven `astra-ref` span (best label as text +// + a `data.astra` join key). A rich theme joins the key to the resolved store +// and renders a hover card; a bare theme shows the plain label. `{astra:num}` +// emits a native numbered crossReference; `{astra:cite[:t]}` emit MyST citations. + +type RefKind = + | 'decision' + | 'output' + | 'finding' + | 'prior_insight' + | 'analysis' + | 'input' + | 'option' + | 'evidence' + | 'universe'; + +/** Resolve a parsed path to its inline reference kind, label, and store key. */ +function resolveInlineRef( + p: AstraPath, scope: Scope, - display?: string | null, -): { label: string; subtype?: string } { - switch (kind) { - case 'decision': { - const dec = scope.analysis.decisions?.[id]; - return { label: display ?? dec?.label ?? humanize(id) }; - } - case 'finding': { - const f = scope.analysis.findings?.[id]; - return { label: display ?? f?.label ?? humanize(id) }; - } - case 'prior_insight': { - const ins = scope.priorInsights[id]; // already merged over ancestor scopes - return { label: display ?? ins?.label ?? humanize(id) }; - } - case 'analysis': { - const sub = scope.analysis.analyses?.[id]; - return { label: display ?? sub?.name ?? humanize(id) }; + display: string | null, +): { kind: RefKind; id: string; path: string; label: string; subtype?: string } { + // Children first — an option / evidence inline reference. + if (p.child) { + const ownerId = p.id!; + if (p.child.collection === 'options') { + const opt = scope.analysis.decisions?.[ownerId]?.options?.[p.child.id]; + return { + kind: 'option', + id: p.child.id, + path: dottedKey(p.scope, `${ownerId}.${p.child.id}`), + label: display ?? opt?.label ?? humanize(p.child.id), + }; } + return { + kind: 'evidence', + id: p.child.id, + path: dottedKey(p.scope, `${ownerId}.${p.child.id}`), + label: display ?? humanize(p.child.id), + }; + } + + // A bare sub-analysis reference. + if (!p.collection) { + const subId = p.scope[p.scope.length - 1]; + const parent = resolveScope(scope.root, undefined, p.scope.slice(0, -1)); + const sub = parent.analysis.analyses?.[subId]; + return { kind: 'analysis', id: subId, path: dottedKey(p.scope.slice(0, -1), subId), label: display ?? sub?.name ?? humanize(subId) }; + } + + const id = p.id!; + const path = dottedKey(p.scope, id); + switch (p.collection) { + case 'decisions': + return { kind: 'decision', id, path, label: display ?? scope.analysis.decisions?.[id]?.label ?? humanize(id) }; + case 'findings': + return { kind: 'finding', id, path, label: display ?? scope.analysis.findings?.[id]?.label ?? humanize(id) }; + case 'prior_insights': + return { kind: 'prior_insight', id, path, label: display ?? scope.priorInsights[id]?.label ?? humanize(id) }; + case 'analyses': + return { kind: 'analysis', id, path, label: display ?? scope.analysis.analyses?.[id]?.name ?? humanize(id) }; + case 'inputs': + return { kind: 'input', id, path, label: display ?? (scope.analysis.inputs ?? []).find((i) => i.id === id)?.label ?? humanize(id) }; + case 'universes': + return { kind: 'universe', id, path, label: display ?? id }; default: { - // output — `subtype` (figure/table/metric/…) is a second modifier class so - // a theme can give each output type its own glyph/treatment. const o = scope.outputsById.get(id); - return { label: display ?? o?.label ?? humanize(id), subtype: o?.type ?? 'output' }; + return { kind: 'output', id, path, label: display ?? o?.label ?? humanize(id), subtype: o?.type ?? 'output' }; } } } -/** Inline citation → neutral `astra-ref` token carrying the store join key. */ -function citeRole(name: string, kind: CiteKind) { +/** `{astra}` — inline store-driven reference to any element. */ +const astraRole = { + name: 'astra', + doc: 'Inline reference to an ASTRA element by path (a theme renders its hover card).', + body: { type: String, required: true, doc: 'A path, optionally `display text `.' }, + run(data: any): any[] { + const { display, path } = splitDisplay(String(data?.body ?? '')); + const p = parseAstraPath(path); + if (!p.collection && p.scope.length === 0) return [text(String(data?.body ?? ''))]; + try { + const scope = resolveScope(projectRoot(), universeName(), p.scope); + const r = resolveInlineRef(p, scope, display); + return [refNode(r.kind, r.id, r.path, r.label, r.subtype)]; + } catch { + const id = p.id ?? p.scope[p.scope.length - 1] ?? path; + return [refNode('output', id, path.replace(/\//g, '.'), display ?? humanize(id))]; + } + }, +}; + +/** `{astra:num}` — native numbered cross-reference (e.g. "Figure 3"). */ +const astraNumRole = { + name: 'astra:num', + doc: 'Numbered cross-reference to a placed output (like {numref}; supports %s).', + body: { type: String, required: true, doc: 'A path, optionally `text with %s `.' }, + run(data: any): any[] { + const { display, path } = splitDisplay(String(data?.body ?? '')); + const p = parseAstraPath(path); + const ident = pathIdentifier(p); + if (!ident) return [text(display ?? path)]; + const node: any = crossReference(ident, display ? [text(display)] : []); + node.kind = 'numref'; + return [node]; + }, +}; + +/** Gather the DOIs backing a finding or prior insight. */ +function refDois(p: AstraPath, scope: Scope): string[] { + const owner = + p.collection === 'findings' + ? scope.analysis.findings?.[p.id!] + : scope.priorInsights[p.id!]; + const dois = (owner?.evidence ?? []).map((e: any) => e.doi).filter(Boolean) as string[]; + return [...new Set(dois)]; +} + +/** `{astra:cite}` / `{astra:cite:t}` — bibliographic citation from DOI evidence. */ +function citeRole(name: string, kind: 'parenthetical' | 'narrative') { return { - name: `astra:${name}`, - doc: `Inline reference to an ASTRA ${name} (a theme renders its card from the store).`, - body: { - type: String, - required: true, - doc: 'Path: or ., optionally `|display text` for the inline label', - }, + name, + doc: `Cite a finding/prior-insight as a ${kind} author–year citation from its DOI evidence.`, + body: { type: String, required: true, doc: 'A path to a finding or prior insight.' }, run(data: any): any[] { - // Optional `|display text` overrides the inline label (the card still - // shows the element's own label/claim). - const [pathPart, ...rest] = String(data?.body ?? '').split('|'); - const display = rest.join('|').trim() || null; - const { analysisPath, id } = splitPath(pathPart); - if (!id) return [text(String(data?.body ?? ''))]; - const path = [...analysisPath, id].join('.'); + const { display, path } = splitDisplay(String(data?.body ?? '')); + const p = parseAstraPath(path); try { - const scope = resolveScope(projectRoot(), universeName(), analysisPath); - const { label, subtype } = citeLabel(kind, id, scope, display); - return [refNode(kind, id, path, label, subtype)]; - } catch { - return [refNode(kind, id, path, display ?? humanize(id))]; + const scope = resolveScope(projectRoot(), universeName(), p.scope); + if (p.collection !== 'findings' && p.collection !== 'prior_insights') { + throw new Error('astra:cite expects a finding or prior_insight path'); + } + const dois = refDois(p, scope); + if (dois.length === 0) { + // No DOI to cite — fall back to a plain reference token. + const r = resolveInlineRef(p, scope, display); + return [refNode(r.kind, r.id, r.path, r.label)]; + } + const cites = dois.map((d) => cite(d, [], kind)); + return cites.length === 1 ? cites : [citeGroup(cites, kind)]; + } catch (err) { + return [{ type: 'inlineCode', value: `⟨cite: ${(err as Error).message}⟩` }]; } }, }; @@ -624,8 +838,6 @@ function citeRole(name: string, kind: CiteKind) { function fmtNum(raw: string, sig: number): string { const x = Number(raw); if (!isFinite(x)) return String(raw); - // Round to `sig` figures, then let Number→String drop trailing zeros and - // normalise the form (e.g. 200000 not 2.000e+5, 0.0696 not 0.06960). return String(Number(x.toPrecision(sig))); } @@ -634,41 +846,57 @@ function valueError(msg: string): any { } /** - * `{astra:value}` — interpolate a real number from a materialised result - * product, so no measured value is ever hard-typed into the prose. + * `{astra:value}` — interpolate a real number from the resolved analysis, so no + * measured value is ever hard-typed into prose. * * Body grammar (whitespace-separated): - * col= [= ...] [pm] [sig=N] + * [col=] [= ...] [±|pm] [err=] [sig=N] * - * - `` output id, optionally scoped (`clustering.xi_…`). - * - `col=` the column to read (table outputs). - * - `=` row filters, e.g. `tracer=lrg3_elg1 recon=Post`. - * - `pm` also render `± _std` when that column exists. - * - `sig=N` significant figures (default 4). - * - * e.g. ``{astra:value}`bao_distance_table tracer=lrg3_elg1 col=DV_over_rd pm` `` - * reads `results//bao_distance_table/…csv` and renders `19.88 ± 0.17`. + * - `` a table/metric output (`outputs/bao_table`, scoped allowed), + * or a decision (`decisions/algorithm` → its selected option). + * - `col=` the column to read (table outputs). + * - `=`row filters, e.g. `tracer=lrg3 recon=Post`. + * - `±` / `pm` also render `± _std` when that column exists. + * - `err=` explicit uncertainty column. + * - `sig=N` significant figures (default 4). */ const valueRole = { name: 'astra:value', - doc: 'Interpolate a numeric cell from a table result product (no hard-typed numbers).', - body: { type: String, required: true, doc: ' col= [= ...] [pm] [sig=N]' }, + doc: 'Interpolate a numeric value (table cell, metric, or a decision selection).', + body: { type: String, required: true, doc: ' [col=] [= ...] [±] [sig=N]' }, run(data: any): any[] { const tokens = String(data?.body ?? '').trim().split(/\s+/).filter(Boolean); - const path = tokens.shift(); - if (!path) return [valueError('missing output path')]; + const pathStr = tokens.shift(); + if (!pathStr) return [valueError('missing path')]; const opts: Record = {}; for (const t of tokens) { + if (t === '±') { + opts['pm'] = true; + continue; + } const i = t.indexOf('='); if (i < 0) opts[t] = true; else opts[t.slice(0, i)] = t.slice(i + 1); } try { - const { analysisPath, id } = splitPath(path); - if (!id) return [valueError(`missing output id in "${path}"`)]; - const scope = resolveScope(projectRoot(), universeName(), analysisPath); + const p = parseAstraPath(pathStr); + const id = p.id; + if (!id) return [valueError(`missing element id in "${pathStr}"`)]; + const scope = resolveScope(projectRoot(), universeName(), p.scope); + + // A decision's value is the option selected under the active universe. + if (p.collection === 'decisions') { + const dec = scope.analysis.decisions?.[id]; + if (!dec) return [valueError(`no decision "${id}"`)]; + const optId = scope.universe.decisions?.[id] ?? dec.default; + const label = (optId && dec.options?.[optId]?.label) || optId || '(none)'; + const node = refNode('value', id, dottedKey(p.scope, id), label, 'decision'); + Object.assign(node.data.astra, { selection: optId }); + return [node]; + } + const abs = scope.results(id); - if (!abs) return [valueError(`no result file for "${path}"`)]; + if (!abs) return [valueError(`no result file for "${pathStr}"`)]; const tbl = parseTableData(abs); if (!tbl) return [valueError(`"${id}" is not tabular`)]; const col = typeof opts['col'] === 'string' ? (opts['col'] as string) : null; @@ -689,8 +917,6 @@ const valueRole = { } const sig = typeof opts['sig'] === 'string' ? parseInt(opts['sig'] as string, 10) : 4; let out = fmtNum(row[ci], sig); - // Uncertainty: explicit `err=`, else `pm` uses the `_std` - // convention (matches the distance table; the α table needs `err=`). const errCol = typeof opts['err'] === 'string' ? (opts['err'] as string) : opts['pm'] ? `${col}_std` : null; if (errCol) { @@ -699,24 +925,11 @@ const valueRole = { out += ` ± ${fmtNum(row[ei], 2)}`; } } - // A value isn't a standalone store element, so its node is self-describing: - // the computed number is the text, and `data.astra` carries the source - // product id + column + row filter the theme renders as provenance (it can - // still join `store.outputs[id]` for the product's label/type). No - // whole-table overlay — just where this number came from. const output = scope.outputsById.get(id); const subtype = output?.type ?? 'table'; const filterDesc = filters.map(([k, v]) => `${k}=${v as string}`).join(', '); - // Same `astra-ref` node shape as the cite roles (built by `refNode`), plus - // the value-specific provenance the theme renders: column, row filter, and - // the source product's type/label. - const node = refNode('value', id, [...analysisPath, id].join('.'), out, subtype); - Object.assign(node.data.astra, { - col, - filter: filterDesc, - type: subtype, - product: output?.label, - }); + const node = refNode('value', id, dottedKey(p.scope, id), out, subtype); + Object.assign(node.data.astra, { col, filter: filterDesc, type: subtype, product: output?.label }); return [node]; } catch (err) { return [valueError((err as Error).message)]; @@ -724,28 +937,18 @@ const valueRole = { }, }; -// ── Transform: ASTRA anchor grammar in author prose ────────────────────────── +// ── Transform: ASTRA anchor scheme in author prose ─────────────────────────── /** - * The ASTRA scope a page maps to, or `null` for non-ASTRA pages (e.g. an - * `about.md`). Scope is derived from the file's basename using the - * **dotted-filename convention**, which composes to any nesting depth with - * zero config: each `.`-segment is one analysis level, so `index.md` → root, - * `reconstruction.md` → `[reconstruction]`, and - * `reconstruction.features.md` → `[reconstruction, features]`. A page may also - * override this explicitly via the `astra_scope` frontmatter key (a dotted - * string `'reconstruction.features'` or an already-split `string[]`). + * The ASTRA scope a page maps to, or `null` for non-ASTRA pages. Scope is + * derived from the file's basename using the dotted-filename convention: each + * `.`-segment is one analysis level, so `index.md` → root, `reconstruction.md` + * → `[reconstruction]`, `reconstruction.features.md` → `[reconstruction, + * features]`. A page may override via the `astra_scope` frontmatter key. */ function scopeForFile(vfile: any): Scope | null { const base = basename(vfile?.path ?? '', '.md'); - // Dotted basename is the canonical, always-available derivation; `index` - // maps to the root scope (empty path), every other dot-segment descends one - // analysis level. `.filter(Boolean)` drops empties from a leading/trailing - // dot so a stray `.` never yields an unknown-sub-analysis throw. let analysisPath = base && base !== 'index' ? base.split('.').filter(Boolean) : []; - // Best-effort frontmatter override: if the page declares `astra_scope`, prefer - // it. Guarded defensively — the transform harness passes a bare `{ path }` - // vfile with no `data`/`frontmatter`, so this stays a bonus over the basename. const explicit = vfile?.data?.frontmatter?.astra_scope; if (Array.isArray(explicit)) { analysisPath = explicit.map((s) => String(s)).filter(Boolean); @@ -760,18 +963,14 @@ function scopeForFile(vfile: any): Scope | null { } /** - * Rewrite ASTRA tree-path anchor links (`[text](#decisions.x)`, - * `#outputs.y`, `#analyses.sub.outputs.z`, …) that appear in the *author's* - * prose into `crossReference` nodes (same page) or sub-page links — reusing - * MySTRA's `resolveNarrativeAnchors`. Directives already resolve anchors in - * the prose they render; this covers anchors the author writes directly. - * Author-written output-image anchors gain a `/static/` url here, so the - * same `rewriteStaticImages` pass the directives use rewrites them to a - * project-relative path MyST can copy. + * Rewrite `#astra:` cross-reference links the author writes directly in + * page prose into `crossReference` nodes (same page) or sub-page links — reusing + * the shared `resolveNarrativeAnchors`. Directives resolve anchors in the prose + * they render; this covers the author's own page prose. */ const anchorTransform = { name: 'astra-anchor-grammar', - doc: 'Resolve ASTRA #path.to.element anchor links to cross-references.', + doc: 'Resolve ASTRA #astra: cross-reference links to crossReferences.', stage: 'document', plugin: () => (tree: any, vfile: any) => { const scope = scopeForFile(vfile); @@ -789,15 +988,6 @@ const anchorTransform = { }; // ── Transform: emit the resolved ASTRA store for rich themes ───────────────── -// -// The theme cannot read `astra.yaml` (it only sees the build output), so the -// plugin bakes a *resolved* projection of the page's analysis scope — keyed by -// id — onto a hidden carrier node's `data`. A rich theme selects the carrier -// (`.astra-store`) and joins each placed element's identifier (`output-`, -// `decision-`, …) to its store entry, enabling cards / dependency graphs / -// alternative layouts without re-implementing ASTRA semantics. The carrier is -// an empty `display:none` div, so it is invisible on book-theme. -// See STRATEGY-A-REFACTOR.md §5. /** Ancestor input maps (innermost-last) for resolving aliased `from:` inputs. */ function parentInputMaps(scope: Scope): Map[] { @@ -807,10 +997,7 @@ function parentInputMaps(scope: Scope): Map[] { } /** - * The page scope's provenance frame, parent-linked up to the root analysis — - * lets the output tracer resolve sibling references (`reconstruction.…` seen - * from `clustering`) and `../` decision aliases. Universe narrowing per - * descent mirrors `resolveScope`. + * The page scope's provenance frame, parent-linked up to the root analysis. */ function pageProvFrame(scope: Scope): ProvFrame { const rootUniverse = getSource(scope.root, universeName()).universe; @@ -827,6 +1014,7 @@ const REF_KIND_TO_TABLE: Record.` paths against the project root at parse - * time (the label is right), but the page store only serializes the page's own - * scope — so the theme had nothing to join a cross-scope ref to and the hover - * card silently degraded to a bare token. Each referenced sub-scope's store is - * built once (cached) and the named entries are copied over with `id` rewritten - * to the path key, so downstream consumers that join by id (asset images, - * evidence rows) stay consistent. - * - * Secondary joins inside a merged entry are carried along under the same - * path-qualifying scheme when the page store lacks them: a decision's - * `option_insights` (the SUPPORTED BY evidence) and a finding's evidence - * artifacts. Ids the page already holds are left as-is — sub-scopes inherit - * ancestor prior_insights, so the plain id is the same insight. + * page store, keyed by their full dotted path. Each referenced sub-scope's store + * is built once (cached) and the named entries are copied over with `id` + * rewritten to the path key. Secondary joins (a decision's option_insights, a + * finding's evidence artifacts) ride along path-qualified. */ function mergeCrossScopeRefs(tree: any, store: ReturnType): void { const refs: { kind: string; id: string; path: string }[] = []; @@ -880,19 +1057,15 @@ function mergeCrossScopeRefs(tree: any, store: ReturnType.]` unless present. */ const adopt = (table: keyof ReturnType, prefix: string, id: string): string => { const qualified = `${prefix}.${id}`; const target = store[table] as Record; - // Sub-scopes inherit ancestor prior_insights, so the page's own entry IS - // the referenced insight — keep the plain id rather than duplicating it. - // (No other table inherits: a same-named local entry is a different one.) if (table === 'prior_insights' && target[id]) return id; if (!target[qualified]) { const entry = (subStoreFor(prefix)?.[table] as Record | undefined)?.[id]; @@ -939,61 +1112,37 @@ const storeTransform = { scope.priorInsights, pageProvFrame(scope), ); - // Cross-scope refs join entries from OTHER pages' scopes — fold those in - // (path-keyed) before the asset / DOI passes below so merged figures and - // citations ride the same pipelines. mergeCrossScopeRefs(tree, store); const carrier: any = hiddenDiv('astra-store'); carrier.identifier = 'astra-store'; carrier.data = { astra: store }; (tree.children ??= []).push(carrier); - // Route output artifacts through MyST's asset pipeline. The store's - // `resolved_path` is a project-relative path that MyST only copies (and - // url-rewrites) for image NODES — a JSON field is opaque to it, so a card - // pointing at the raw path 404s. Emitting one hidden image node per - // image-typed result lets MyST's own transforms produce a servable URL; - // each node is tagged `data.astraAsset = ` so the theme can - // join the rewritten url back onto the store entry. + // Route output artifacts through MyST's asset pipeline (a JSON path is + // opaque to it). One hidden image node per image-typed result, tagged with + // its output id, lets MyST produce a servable hashed URL the theme rejoins. const assetImages = Object.values(store.outputs) - .filter( - (o) => o.resolved_path && /\.(png|jpe?g|gif|webp|svg)$/i.test(o.resolved_path), - ) - .map((o) => ({ - type: 'image', - url: o.resolved_path, - alt: o.label ?? o.id, - data: { astraAsset: o.id }, - })); + .filter((o) => o.resolved_path && /\.(png|jpe?g|gif|webp|svg)$/i.test(o.resolved_path)) + .map((o) => ({ type: 'image', url: o.resolved_path, alt: o.label ?? o.id, data: { astraAsset: o.id } })); if (assetImages.length > 0) { (tree.children ??= []).push(hiddenDiv('astra-assets', assetImages)); } - // Register every insight DOI with MyST's citation pipeline. The store only - // carries the raw DOI string; emitting a hidden `cite` node per DOI (label - // = the DOI) lets MyST's own transforms resolve it (transformLinkedDOIs → - // transformCitations), so `references.cite.data` carries the formatted - // citation and the theme's hover cards render the same author–year - // citation as main-text DOIs — with the source listed in the bibliography. - // BOTH kinds are registered: narrative ("Chen et al. (2024)") for card - // cite rows, parenthetical ("Chen et al., 2024") for the auto-citation the - // theme appends after inline prior-insight references in prose. - const dois = [ - ...new Set( - Object.values(store.prior_insights) - .map((insight) => insight.doi) - .filter((d): d is string => !!d), - ), - ]; + // Register every DOI (prior insights + finding evidence) with MyST's + // citation pipeline: a hidden `cite` node per DOI (label = DOI) lets MyST + // resolve the formatted author–year citation and the bibliography entry, so + // both the theme's hover cards and the `{astra:cite[:t]}` roles render real + // citations. BOTH kinds are registered — narrative for card rows, parenthetical + // for the auto-append after inline references. + const insightDois = Object.values(store.prior_insights).map((i) => i.doi); + const findingDois = Object.values(store.findings).flatMap((f) => + (f.evidence ?? []).map((e: any) => e.doi), + ); + const dois = [...new Set([...insightDois, ...findingDois].filter((d): d is string => !!d))]; if (dois.length > 0) { (tree.children ??= []).push( hiddenDiv('astra-cites', [ - paragraph( - dois.flatMap((d) => [ - cite(d, [], 'narrative'), - cite(d, [], 'parenthetical'), - ]), - ), + paragraph(dois.flatMap((d) => [cite(d, [], 'narrative'), cite(d, [], 'parenthetical')])), ]), ); } @@ -1004,21 +1153,12 @@ const storeTransform = { const plugin = { name: 'astra', - directives: [ - decisionDirective, - outputDirective, - findingDirective, - priorInsightDirective, - inputsDirective, - outputsDirective, - subAnalysisDirective, - ], + directives: [astraDirective], roles: [ - citeRole('decision', 'decision'), - citeRole('output', 'output'), - citeRole('finding', 'finding'), - citeRole('prior-insight', 'prior_insight'), - citeRole('analysis', 'analysis'), + astraRole, + astraNumRole, + citeRole('astra:cite', 'parenthetical'), + citeRole('astra:cite:t', 'narrative'), valueRole, ], transforms: [anchorTransform, storeTransform], @@ -1029,6 +1169,8 @@ export default plugin; // ── Library exports (for programmatic use) ────────────────────────────────── export { loadASTRASource } from './loader.js'; export type { ASTRASource } from './loader.js'; +export { parseAstraPath } from './path.js'; +export type { AstraPath, Collection } from './path.js'; export { buildResolvedStore } from './transform/resolved-store.js'; export type { ResolvedStore, diff --git a/src/path.ts b/src/path.ts new file mode 100644 index 0000000..209cbe2 --- /dev/null +++ b/src/path.ts @@ -0,0 +1,186 @@ +/** + * The unified ASTRA reference path grammar. + * + * A *path* is a slash-separated route through the analysis tree — the same + * structure as `astra.yaml`: + * + * outputs/hubble_diagram an output in the current scope + * decisions/algorithm/options/gp a child (one Option of a Decision) + * findings/sig/evidence/fig1 a child (one Evidence of a Finding) + * reconstruction/outputs/xi a sub-analysis (the `analyses/` is implied) + * analyses/reconstruction/outputs/xi … the explicit long form + * reconstruction the sub-analysis itself + * outputs a whole collection (a registry) + * /decisions/method absolute, from the root analysis + * ../outputs/xi the parent scope + * + * `parseAstraPath` turns the string into a structured {@link AstraPath}. One + * grammar drives every surface: the `{astra}` role, the `{astra}` directive, the + * `{astra:*}` variants, and the `#astra:` cross-reference scheme. + */ + +/** The top-level ASTRA collections — exactly the keys of `astra.yaml`. */ +export type Collection = + | 'inputs' + | 'outputs' + | 'decisions' + | 'findings' + | 'prior_insights' + | 'analyses' + | 'universes'; + +/** Child collections that live *inside* an element. */ +export type ChildCollection = 'options' | 'evidence'; + +const COLLECTIONS = new Set([ + 'inputs', + 'outputs', + 'decisions', + 'findings', + 'prior_insights', + 'analyses', + 'universes', +]); + +const CHILD_COLLECTIONS = new Set(['options', 'evidence']); + +/** Map a collection to the singular `` used in mdast identifiers + classes. */ +export const KIND_BY_COLLECTION: Record = { + inputs: 'input', + outputs: 'output', + decisions: 'decision', + findings: 'finding', + prior_insights: 'prior_insight', + analyses: 'analysis', + universes: 'universe', +}; + +/** Accept the hyphenated alias `prior-insights` for the YAML key `prior_insights`. */ +function canonicalCollection(seg: string): Collection | null { + const s = seg === 'prior-insights' ? 'prior_insights' : seg; + return COLLECTIONS.has(s) ? (s as Collection) : null; +} + +export interface AstraPath { + /** The trimmed source string (scheme + display already stripped). */ + raw: string; + /** A leading `/` — resolve from the root analysis rather than the current scope. */ + absolute: boolean; + /** Count of leading `../` — scopes to climb from the current scope. */ + up: number; + /** Sub-analysis ids walked into (the analysis path), innermost last. */ + scope: string[]; + /** The target collection, or `null` when the target is a bare sub-analysis. */ + collection: Collection | null; + /** The element id; `null` when the path stops at a collection (a registry). */ + id: string | null; + /** A child target inside the element (an option or an evidence record). */ + child: { collection: ChildCollection; id: string } | null; +} + +/** + * Split a role/directive body into its display-text override and the path, + * following MyST's `text ` convention (as used by `{ref}`): + * + * "our preferred method " → { display: "our preferred method", path: "decisions/algorithm" } + * "outputs/hubble_diagram" → { display: null, path: "outputs/hubble_diagram" } + */ +export function splitDisplay(body: string): { display: string | null; path: string } { + const m = /^(.*?)<([^>]*)>\s*$/.exec(body ?? ''); + if (m) return { display: m[1].trim() || null, path: m[2].trim() }; + return { display: null, path: (body ?? '').trim() }; +} + +/** + * Parse a path string into a structured {@link AstraPath}. + * + * Resolution is left-to-right: leading `/` and `../` are consumed first, then + * each segment is either a *collection keyword* (which begins the target) or a + * *sub-analysis step* (the `analyses/` shorthand). The first non-`analyses` + * collection keyword fixes the target; everything before it is scope. + * + * The parse is purely syntactic — it never checks the element exists. Callers + * resolve {@link AstraPath} against a loaded analysis and report missing ids. + */ +export function parseAstraPath(raw: string): AstraPath { + const trimmed = (raw ?? '').trim(); + const absolute = trimmed.startsWith('/'); + const segs = trimmed + .replace(/^\//, '') + .split('/') + .map((s) => s.trim()) + .filter(Boolean); + + let up = 0; + while (segs[0] === '..') { + up++; + segs.shift(); + } + + const scope: string[] = []; + let collection: Collection | null = null; + let id: string | null = null; + let child: AstraPath['child'] = null; + + let i = 0; + while (i < segs.length) { + const seg = segs[i]; + const col = canonicalCollection(seg); + + if (col === 'analyses') { + // `analyses` as the final segment is the sub-analyses registry. + if (i === segs.length - 1) { + collection = 'analyses'; + break; + } + // `analyses/` as the final pair targets that sub-analysis itself; + // otherwise it's a scope step and parsing continues inside the sub. + const sub = segs[i + 1]; + scope.push(sub); + i += 2; + continue; + } + + if (col) { + collection = col; + i++; + if (i < segs.length) { + id = segs[i]; + i++; + } + if (i < segs.length && CHILD_COLLECTIONS.has(segs[i])) { + const cc = segs[i] as ChildCollection; + const cid = segs[i + 1]; + if (cid) child = { collection: cc, id: cid }; + } + break; + } + + // Not a collection keyword → a sub-analysis step (the `analyses/` shorthand). + scope.push(seg); + i++; + } + + return { raw: trimmed, absolute, up, scope, collection, id, child }; +} + +/** + * The in-page mdast identifier a path resolves to (`-`), or `null` + * when the path has no single anchorable element (a registry, or a bare + * sub-analysis, which is a separate page). Children collapse to their parent + * element's identifier: an option → its decision, an evidence → its + * finding/insight, matching where the rendered anchor actually lives. + */ +export function pathIdentifier(p: AstraPath): string | null { + if (!p.collection || !p.id) return null; + return `${KIND_BY_COLLECTION[p.collection]}-${p.id}`; +} + +/** + * The dotted scope key (`reconstruction.xi`) used internally as the resolved-store + * join key and the cross-scope merge prefix. Authoring is slash-based; the store + * stays dotted, so this is the single conversion point. + */ +export function dottedKey(scope: string[], id: string): string { + return [...scope, id].join('.'); +} diff --git a/src/transform/prose.ts b/src/transform/prose.ts index 7474d4e..a1fa12d 100644 --- a/src/transform/prose.ts +++ b/src/transform/prose.ts @@ -1,77 +1,46 @@ /** * The prose engine: parse the Markdown embedded in ASTRA *components*, and - * resolve ASTRA anchor links within it. + * resolve ASTRA cross-reference links within it. * * Every Markdown field on a component — `Insight.claim`, `Decision.rationale`, * `Option/Input/Output.description`, captions, finding notes — flows through * `myst-parser`, so MySTRA stays MyST-native and emits the same `mdast` themes - * consume. (This is *not* about the `narrative:` field, which Strategy A leaves - * to the author's Markdown page.) + * consume. * - * Anchor links `[text](#path.to.element)` use the ASTRA tree-path grammar; they - * arrive from myst-parser as ordinary `link` nodes, and `resolveNarrativeAnchors` - * rewrites in-scope ones into MyST `crossReference` nodes pointing at the - * matching element. Anchors that escape the host scope (`../` parent traversal) - * fall back to plain links with the original URL. + * Cross-reference links `[text](#astra:)` use the unified path grammar + * (see `../path.ts`); they arrive from myst-parser as ordinary `link` nodes, and + * `resolveNarrativeAnchors` rewrites in-page ones into MyST `crossReference` + * nodes and cross-page ones into plain links pointing at the destination page. */ import { mystParse } from 'myst-parser'; import { parse as parsePath } from 'node:path'; -import type { Analysis, Insight, Output } from '@astra-spec/sdk'; +import type { Analysis, Insight } from '@astra-spec/sdk'; import type { ArtifactResolver } from '../loader.js'; import { crossReference, link } from './ast-helpers.js'; +import { parseAstraPath, pathIdentifier } from '../path.js'; // ── Parsing ─────────────────────────────────────────────────────── /** - * Parse a Markdown string into mdast block nodes (paragraphs, - * headings, lists, …). Use this for narrative sections and other - * fields where block-level structure is meaningful. - * - * When `context` is provided, the v0.0.6 narrative anchor grammar - * (`[t](#path.to.element)`) is resolved as a post-pass so anchors - * become `crossReference` nodes pointing at the corresponding - * structural element. Without context, anchor links survive as - * plain `link` nodes. + * Parse a Markdown string into mdast block nodes (paragraphs, headings, lists, + * …). When `context` is provided, `#astra:` cross-reference links are + * resolved as a post-pass; without context they survive as plain `link` nodes. */ -export function parseProseBlocks( - md: string | undefined, - context?: ProseContext, -): any[] { +export function parseProseBlocks(md: string | undefined, context?: ProseContext): any[] { if (!md) return []; const tree = mystParse(md); const blocks = (tree.children ?? []).map(stripPositions); - return context - ? resolveNarrativeAnchors( - blocks, - context.analysis, - context.slug, - context.priorInsightScopes, - context.results, - context.analysisScopes, - ) - : blocks; + return context ? resolveWithContext(blocks, context) : blocks; } /** - * Parse a Markdown string and return only the inline phrasing - * content. Used for fields that must be inline (table cells, - * captions, headings, blockquote attribution, single-line claims). - * - * If the input parses to a single paragraph we unwrap its children; - * otherwise we flatten across blocks. Author input that - * accidentally contains block-level structure (a stray heading, a - * list, a code block) shouldn't silently vanish — extract the - * inline phrasing content from each block, separated by spaces, so - * captions and claims survive even if the author overshot what an - * inline context allows. - * - * `context` enables anchor resolution; see `parseProseBlocks`. + * Parse a Markdown string and return only the inline phrasing content. Used for + * fields that must be inline (table cells, captions, headings, single-line + * claims). A single paragraph is unwrapped; otherwise phrasing is flattened + * across blocks so author input that overshoots an inline context still survives. */ -export function parseProseInline( - md: string | undefined, - context?: ProseContext, -): any[] { +export function parseProseInline(md: string | undefined, context?: ProseContext): any[] { if (!md) return []; const tree = mystParse(md); const blocks = tree.children ?? []; @@ -80,10 +49,6 @@ export function parseProseInline( if (blocks.length === 1 && blocks[0].type === 'paragraph') { inline = (blocks[0].children ?? []).map(stripPositions); } else { - // Multi-block input where only inline is allowed: extract - // phrasing from each block and concatenate. Paragraphs and - // headings expose `children` directly; lists / code blocks - // need a recursive walk to collect text. inline = []; for (let i = 0; i < blocks.length; i++) { const phrasing = extractInline(blocks[i]); @@ -92,40 +57,36 @@ export function parseProseInline( inline.push(...phrasing); } } - return context - ? resolveNarrativeAnchors( - inline, - context.analysis, - context.slug, - context.priorInsightScopes, - context.results, - context.analysisScopes, - ) - : inline; + return context ? resolveWithContext(inline, context) : inline; +} + +function resolveWithContext(nodes: any[], context: ProseContext): any[] { + return resolveNarrativeAnchors( + nodes, + context.analysis, + context.slug, + context.priorInsightScopes, + context.results, + context.analysisScopes, + ); } /** - * Pull inline phrasing content out of a single block-level mdast - * node, dropping the block wrapper. Paragraphs and headings expose - * inline children directly; lists / blockquotes recurse; code - * blocks surface their text as a single text node. + * Pull inline phrasing content out of a single block-level mdast node, dropping + * the block wrapper. Paragraphs and headings expose inline children directly; + * lists / blockquotes recurse; code blocks surface their text as a text node. */ function extractInline(node: any): any[] { if (!node || typeof node !== 'object') return []; switch (node.type) { case 'paragraph': case 'heading': - return Array.isArray(node.children) - ? node.children.map(stripPositions) - : []; + return Array.isArray(node.children) ? node.children.map(stripPositions) : []; case 'code': - // Fenced/indented code in an inline context: surface as text - // so the content survives even if styling is lost. return typeof node.value === 'string' ? [{ type: 'text', value: node.value }] : []; case 'thematicBreak': return []; default: - // list, blockquote, table, container, … — flatten children. if (!Array.isArray(node.children)) return []; const out: any[] = []; for (const child of node.children) { @@ -139,9 +100,9 @@ function extractInline(node: any): any[] { } /** - * Resolution context carried through every render-* helper that - * touches prose. Created once per scope (by the plugin's `resolveScope`) - * and threaded into the renderers via the `ProseParser` factory. + * Resolution context carried through every render-* helper that touches prose. + * Created once per scope (by the plugin's `resolveScope`) and threaded into the + * renderers via the `ProseParser` factory. */ export interface ProseContext { analysis: Analysis; @@ -161,10 +122,7 @@ export interface AnalysisScope { analysis: Analysis; } -/** - * Pre-bound parser pair — convenient when one render helper makes - * many parse calls. Equivalent to passing `context` on each call. - */ +/** Pre-bound parser pair — convenient when one render helper makes many calls. */ export interface ProseParser { blocks(md: string | undefined): any[]; inline(md: string | undefined): any[]; @@ -178,42 +136,8 @@ export function makeProseParser(context: ProseContext): ProseParser { } /** - * Extract the first paragraph of a Markdown string as a plain-text - * single line — suitable for OpenGraph / SEO descriptions and - * sub-analysis card previews. Walks the parsed mdast and collects - * `text`/`inlineCode` leaves under the first `paragraph` block, - * preserving order and inserting natural spaces. Anchor links and - * formatting (emphasis, strong, code) collapse to their visible - * text. Returns `undefined` if the input is empty or contains no - * paragraph block. - */ -export function firstParagraphText(md: string | undefined): string | undefined { - if (!md) return undefined; - const tree = mystParse(md); - const blocks = tree.children ?? []; - const para = blocks.find((b: any) => b?.type === 'paragraph'); - if (!para) return undefined; - const collected = collectVisibleText(para).trim().replace(/\s+/g, ' '); - return collected || undefined; -} - -function collectVisibleText(node: any): string { - if (!node || typeof node !== 'object') return ''; - // Leaf-level visible text. `text` and `inlineCode` carry literal - // strings; `link` children carry the link's display text (the URL - // doesn't belong in an SEO description). - if (node.type === 'text' && typeof node.value === 'string') return node.value; - if (node.type === 'inlineCode' && typeof node.value === 'string') return node.value; - if (Array.isArray(node.children)) { - return node.children.map(collectVisibleText).join(''); - } - return ''; -} - -/** - * Recursively strip the `position` field that markdown-it injects. - * The book-theme ignores it, but it bloats the JSON payload and - * makes test snapshots noisy. + * Recursively strip the `position` field markdown-it injects. The book-theme + * ignores it, but it bloats the JSON payload and makes snapshots noisy. */ function stripPositions(node: any): any { if (!node || typeof node !== 'object') return node; @@ -227,102 +151,87 @@ function stripPositions(node: any): any { // ── Anchor resolution ───────────────────────────────────────────── /** - * Tree-path anchor resolution. Returns either an `identifier` (for an - * in-page crossReference) or a `url` (for an off-page or unresolvable - * link). The host analysis is needed to know which IDs exist locally; - * the slug for the analysis is needed to build sub-analysis links. + * Resolve a `#astra:` cross-reference URL against the page scope. * - * Identifier scheme (every structural element + narrative chunk gets - * a stable id anchor — `-`): + * Returns either an `identifier` (an in-page `crossReference`) or a `url` (a + * cross-page link, or an unresolved fallback). Paths use the unified grammar + * (see ../path.ts): they resolve relative to the page scope, with a leading `/` + * for the root analysis and `../` to climb scopes. * - * decisions → `decision-` - * findings → `finding-` - * prior_insights → `prior_insight-` - * inputs → `input-` - * outputs → `output-` - * narrative → `narrative-
` - * sub-analyses → cross-page URL (separate pages, not in-page anchors) - * parent (`../`) → inert link (cross-scope resolution is its own thing) + * Identifier scheme — every rendered element carries `-`: + * decisions → decision- findings → finding- inputs → input- + * outputs → output- prior_insights → prior_insight- + * options/evidence children collapse to their parent element's identifier. */ -export function resolveAnchorPath( - path: string, +export function resolveAstraAnchor( + url: string, analysis: Analysis, slug: string, priorInsightScopes: PriorInsightScope[] = [], ): { identifier: string } | { url: string } { - // Strip a leading '#'; tolerate either form. - const ref = path.replace(/^#/, ''); - - // `../prior_insights.` escapes to the nearest ancestor page - // that owns the prior_insight carrier. Other parent traversals - // still fall back until their categories have explicit scope - // context. - if (ref.startsWith('../')) { - const parentRef = ref.slice(3); - const [parentHead, ...parentRest] = parentRef.split('.'); - if (parentHead === 'prior_insights' && parentRest.length === 1) { - const ancestor = nearestPriorInsightScope(priorInsightScopes, parentRest[0]); - if (ancestor) { - return { url: `${pageUrl(ancestor.slug)}#prior_insight-${parentRest[0]}` }; - } + const raw = url.replace(/^#astra:/, ''); + const p = parseAstraPath(raw); + const pageScope = slug === 'index' ? [] : slug.split('/'); + const targetScope = p.absolute + ? [...p.scope] + : [...pageScope.slice(0, Math.max(0, pageScope.length - p.up)), ...p.scope]; + const samePage = arraysEqual(targetScope, pageScope); + const fallback = { url: `#astra:${raw}` }; + + // Prior insights inherit down the tree; the carrier lives on whichever + // ancestor page declares it, so search the ancestor scope stack. + if (p.collection === 'prior_insights' && p.id) { + if (samePage && p.id in (analysis.prior_insights ?? {})) { + return { identifier: `prior_insight-${p.id}` }; } - return { url: `#${ref}` }; + const anc = nearestPriorInsightScope(priorInsightScopes, p.id); + if (anc) return { url: `${pageUrl(anc.slug)}#prior_insight-${p.id}` }; + return samePage ? fallback : { url: `${pageUrlFor(targetScope)}#prior_insight-${p.id}` }; } - const segments = ref.split('.'); - const [head, ...rest] = segments; - - // Sub-analysis traversal — `#analyses.[...rest]` or the bare - // analysis-as-leaf shorthand `#.outputs.` where - // is an ID in `analysis.analyses`. - if (head === 'analyses' && rest.length >= 1) { - return subAnalysisUrl(rest[0], rest.slice(1), slug); + // A bare sub-analysis → a link to its page. + if (!p.collection) { + return targetScope.length ? { url: pageUrlFor(targetScope) } : fallback; } - if (analysis.analyses && head in analysis.analyses) { - return subAnalysisUrl(head, rest, slug); + // A whole collection (a registry) is not a single anchor target. + if (!p.id) return fallback; + + const ident = pathIdentifier(p)!; + if (samePage) { + return existsInScope(analysis, p.collection, p.id) ? { identifier: ident } : fallback; } + return { url: `${pageUrlFor(targetScope)}#${ident}` }; +} - // In-scope categories. - switch (head) { - case 'findings': - return rest.length === 1 && rest[0] in (analysis.findings ?? {}) - ? { identifier: `finding-${rest[0]}` } - : { url: `#${ref}` }; +function existsInScope(analysis: Analysis, collection: string, id: string): boolean { + switch (collection) { case 'decisions': - // decisions. and decisions..options. both resolve - // to the decision heading; option-level identifiers don't yet - // exist in MySTRA's xref scheme. - return rest.length >= 1 && rest[0] in (analysis.decisions ?? {}) - ? { identifier: `decision-${rest[0]}` } - : { url: `#${ref}` }; - case 'prior_insights': - if (rest.length === 1 && rest[0] in (analysis.prior_insights ?? {})) { - return { identifier: `prior_insight-${rest[0]}` }; - } - if (rest.length === 1) { - const ancestor = nearestPriorInsightScope(priorInsightScopes, rest[0]); - if (ancestor) return { url: `${pageUrl(ancestor.slug)}#prior_insight-${rest[0]}` }; - } - return { url: `#${ref}` }; + return !!analysis.decisions?.[id]; + case 'findings': + return !!analysis.findings?.[id]; case 'inputs': - return rest.length === 1 && (analysis.inputs ?? []).some((i) => i.id === rest[0]) - ? { identifier: `input-${rest[0]}` } - : { url: `#${ref}` }; + return (analysis.inputs ?? []).some((i) => i.id === id); case 'outputs': - return rest.length === 1 && (analysis.outputs ?? []).some((o) => o.id === rest[0]) - ? { identifier: `output-${rest[0]}` } - : { url: `#${ref}` }; - // (`#narrative.
` is not resolved: Strategy A renders no narrative - // sections — the author writes that prose in the Markdown page itself.) + return (analysis.outputs ?? []).some((o) => o.id === id); + case 'analyses': + return !!analysis.analyses?.[id]; default: - return { url: `#${ref}` }; + return false; } } +function arraysEqual(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((x, i) => x === b[i]); +} + function pageUrl(slug: string): string { return slug === 'index' ? '/' : `/${slug}`; } +function pageUrlFor(scope: string[]): string { + return scope.length === 0 ? '/' : `/${scope.join('/')}`; +} + function nearestPriorInsightScope( scopes: PriorInsightScope[], insightId: string, @@ -333,70 +242,11 @@ function nearestPriorInsightScope( return undefined; } -function subAnalysisUrl( - subId: string, - rest: string[], - hostSlug: string, -): { url: string } { - const base = hostSlug === 'index' ? `/${subId}` : `/${hostSlug}/${subId}`; - if (rest.length === 0) return { url: base }; - // Translate ASTRA tree-path grammar (`outputs.features`, - // `decisions.scaling`) to the local mdast id convention - // (`output-features`, `decision-scaling`) before stitching the - // URL fragment, so cross-page anchors resolve to real ids on - // the destination page. - const fragment = astraPathToFragment(rest); - return fragment ? { url: `${base}#${fragment}` } : { url: base }; -} - /** - * `[category, id]` → `-` (matching the local in-page - * identifier convention). Multi-segment paths beyond `[category, id]` - * (e.g. `decisions..options.`) collapse to the parent - * decision identifier — option-level anchors don't yet exist in - * MySTRA's xref scheme, identical to the in-page resolver branch. - * - * `narrative.
` and `analyses....` are not handled here - * because the caller has already stripped the head category; this - * helper only sees the remainder of a sub-analysis traversal. - */ -const CATEGORY_TO_KIND: Record = { - findings: 'finding', - decisions: 'decision', - prior_insights: 'prior_insight', - inputs: 'input', - outputs: 'output', - analyses: 'analysis', -}; - -function astraPathToFragment(segments: string[]): string { - if (segments.length === 0) return ''; - const [head, ...rest] = segments; - - // narrative.
stays as-is — the carrier id is - // `narrative-
` on the destination page. - if (head === 'narrative' && rest.length === 1) { - return `narrative-${rest[0]}`; - } - - const kind = CATEGORY_TO_KIND[head]; - if (kind && rest.length >= 1) { - // `decisions..options.` collapses to the parent - // decision; same fallback as the in-scope resolver. - return `${kind}-${rest[0]}`; - } - - // Unrecognised path — fall back to dot-joined raw segments so - // the URL still points at *something*; the destination renderer - // can decide whether to treat it as a real id. - return segments.join('.'); -} - -/** - * Walk a node tree and rewrite `link` nodes whose URL is an anchor - * (`#...`) into the resolver's verdict — either a `crossReference` - * (identifier resolved) or a plain `link` (left as-is, anchor or - * sub-analysis URL). + * Walk a node tree and rewrite `link` nodes whose URL is a `#astra:` reference + * into the resolver's verdict — either a `crossReference` (identifier resolved) + * or a plain `link` (cross-page / unresolved). `![](#astra:outputs/)` image + * embeds (in-scope figures) are rewritten to their `/static/` artifact URL. */ export function resolveNarrativeAnchors( nodes: any[], @@ -404,29 +254,13 @@ export function resolveNarrativeAnchors( slug: string, priorInsightScopes: PriorInsightScope[] = [], results?: ArtifactResolver, - analysisScopes: AnalysisScope[] = [], + _analysisScopes: AnalysisScope[] = [], ): any[] { return nodes.flatMap((node) => - flatten( - rewrite( - node, - analysis, - slug, - priorInsightScopes, - results, - analysisScopes, - ), - ), + flatten(rewrite(node, analysis, slug, priorInsightScopes, results)), ); } -/** - * Normalize the `any | any[] | null | undefined` result of `rewrite()` - * into a flat array suitable for `flatMap`/`children:` slots. Used at - * every recursion boundary so a node can collapse into zero, one, or - * many siblings (mystDirective unwrapping, broken-image drops, etc.) - * without each call site re-implementing the destructuring. - */ function flatten(r: any | any[] | null | undefined): any[] { if (r === null || r === undefined) return []; return Array.isArray(r) ? r : [r]; @@ -438,36 +272,27 @@ function rewrite( slug: string, priorInsightScopes: PriorInsightScope[], results: ArtifactResolver | undefined, - analysisScopes: AnalysisScope[], ): any | any[] | null { if (!node || typeof node !== 'object') return node; - if (node.type === 'link' && typeof node.url === 'string' && node.url.startsWith('#')) { - const verdict = resolveAnchorPath(node.url, analysis, slug, priorInsightScopes); - if ('identifier' in verdict) { - return crossReference(verdict.identifier, node.children ?? []); - } - return link(verdict.url, node.children ?? []); + if (node.type === 'link' && typeof node.url === 'string' && node.url.startsWith('#astra:')) { + const verdict = resolveAstraAnchor(node.url, analysis, slug, priorInsightScopes); + return 'identifier' in verdict + ? crossReference(verdict.identifier, node.children ?? []) + : link(verdict.url, node.children ?? []); } - if (node.type === 'image' && isOutputImageAnchor(node.url)) { - return rewriteOutputImage(node, analysis, results, analysisScopes); + if (node.type === 'image' && typeof node.url === 'string' && node.url.startsWith('#astra:')) { + return rewriteOutputImage(node, analysis, results); } - // Unwrap the `mystDirective` wrapper that `mystParse` keeps around - // expanded directive content. The wrapper carries `name`/`args`/`value` - // metadata about the source directive but no longer represents structure - // — the actual directive output (e.g. `container[kind:figure]`, - // `admonition`, `container[kind:table]`) sits inside as a single child. - // Downstream renderers (myst-to-react and our @lightcone/renderer - // overrides) consume the canonical node, not the wrapper; without this - // unwrap, `:::{figure}` lands in the React tree as an unknown directive - // and shows the "Unknown Directive" placeholder. Children get - // recursively rewritten so URL anchors inside the directive (image - // `#outputs.X`, link `#decisions.X`) still resolve. + // Unwrap the `mystDirective` wrapper myst-parser keeps around expanded + // directive content: the canonical directive output (a `container`, an + // `admonition`, …) sits inside as a single child, which downstream renderers + // consume. Children are still rewritten so anchors inside resolve. if (node.type === 'mystDirective' && Array.isArray(node.children)) { return node.children.flatMap((c: any) => - flatten(rewrite(c, analysis, slug, priorInsightScopes, results, analysisScopes)), + flatten(rewrite(c, analysis, slug, priorInsightScopes, results)), ); } @@ -475,95 +300,49 @@ function rewrite( return { ...node, children: node.children.flatMap((c: any) => - flatten(rewrite(c, analysis, slug, priorInsightScopes, results, analysisScopes)), + flatten(rewrite(c, analysis, slug, priorInsightScopes, results)), ), }; } return node; } -function isOutputImageAnchor(url: unknown): url is string { - return typeof url === 'string' && url.startsWith('#') && url.includes('outputs.'); -} - +/** + * Rewrite an `![](#astra:outputs/)` figure embed to its artifact URL. Only + * in-scope figure outputs are embeddable this way (the page's artifact resolver + * is scope-local); a non-figure, cross-scope, unknown, or unproduced target is + * dropped with a warning. + */ function rewriteOutputImage( node: any, analysis: Analysis, results: ArtifactResolver | undefined, - analysisScopes: AnalysisScope[], ): any | null { - if (!results) return node; - - const target = resolveOutputTarget(node.url, analysis, analysisScopes); - const outputId = target?.id ?? outputIdFromAnchor(node.url); - if (!target?.output || !outputId) { - console.warn( - `[mystra] Narrative image embed references unknown output id "${outputId ?? node.url}" — broken reference dropped from output.`, - ); + const p = parseAstraPath(String(node.url).replace(/^#astra:/, '')); + if (p.collection !== 'outputs' || !p.id) { + console.warn(`[mystra] image embed "${node.url}" does not point at an output — dropped.`); return null; } - - if (target.output.type !== 'figure') { + if (p.scope.length > 0 || p.absolute || !results) { + console.warn(`[mystra] image embed "${node.url}" must be an in-scope output — dropped.`); + return null; + } + const output = (analysis.outputs ?? []).find((o) => o.id === p.id); + if (!output) { + console.warn(`[mystra] image embed references unknown output "${p.id}" — dropped.`); + return null; + } + if (output.type !== 'figure') { console.warn( - `[mystra] Narrative image embed references non-figure output "${outputId}" (type: ${target.output.type}) — dropping image.`, + `[mystra] image embed references non-figure output "${p.id}" (type: ${output.type}) — dropped.`, ); return null; } - - const resultPath = results(outputId); + const resultPath = results(p.id); if (!resultPath) { - console.warn( - `[mystra] Narrative image embed references unproduced output id "${outputId}" — dropping image.`, - ); + console.warn(`[mystra] image embed references unproduced output "${p.id}" — dropped.`); return null; } - const ext = parsePath(resultPath).ext.slice(1).toLowerCase(); - return { ...node, url: `/static/${outputId}.${ext}` }; -} - -function resolveOutputTarget( - path: string, - analysis: Analysis, - analysisScopes: AnalysisScope[], -): { id: string; output: Output | undefined } | undefined { - const ref = path.replace(/^#/, ''); - - if (ref.startsWith('../')) { - const parent = analysisScopes[analysisScopes.length - 1]?.analysis; - return parent ? outputTargetFromSegments(parent, ref.slice(3).split('.')) : undefined; - } - - return outputTargetFromSegments(analysis, ref.split('.')); -} - -function outputTargetFromSegments( - analysis: Analysis, - segments: string[], -): { id: string; output: Output | undefined } | undefined { - const [head, ...rest] = segments; - - if (head === 'outputs' && rest.length === 1) { - return { - id: rest[0], - output: (analysis.outputs ?? []).find((output) => output.id === rest[0]), - }; - } - - if (head === 'analyses' && rest.length >= 1) { - const child = analysis.analyses?.[rest[0]]; - return child ? outputTargetFromSegments(child, rest.slice(1)) : undefined; - } - - if (analysis.analyses && head in analysis.analyses) { - return outputTargetFromSegments(analysis.analyses[head], rest); - } - - return undefined; -} - -function outputIdFromAnchor(url: string): string | undefined { - const segments = url.replace(/^#/, '').split('.'); - const outputIndex = segments.indexOf('outputs'); - return outputIndex >= 0 ? segments[outputIndex + 1] : undefined; + return { ...node, url: `/static/${p.id}.${ext}` }; } diff --git a/src/transform/resolved-store.ts b/src/transform/resolved-store.ts index 1fcead7..764d1df 100644 --- a/src/transform/resolved-store.ts +++ b/src/transform/resolved-store.ts @@ -39,7 +39,6 @@ import type { export type { SerializedProvenanceDecision, SerializedRootInput }; import { isDecisionRendered } from './render-methods.js'; -import { firstParagraphText } from './prose.js'; import { parseTableData, type TableData } from './parse-table-data.js'; // ── Serialized shapes ─────────────────────────────────────────────────────── @@ -250,7 +249,8 @@ export function buildResolvedStore( subanalyses[id] = { id, name: sub.name, - summary: firstParagraphText(sub.narrative?.summary), + // ASTRA no longer carries a `narrative` section, so there is no authored + // summary to surface on the card; the theme renders name + counts. url: '/' + (base ? `${base}/${id}` : id), decisions: Object.keys(sub.decisions ?? {}).length, outputs: (sub.outputs ?? []).length, diff --git a/tests/path.test.ts b/tests/path.test.ts new file mode 100644 index 0000000..b64f5ee --- /dev/null +++ b/tests/path.test.ts @@ -0,0 +1,174 @@ +/** + * Unit tests for the unified ASTRA path grammar (src/path.ts). + */ + +import { describe, it, expect } from 'vitest'; +import { + parseAstraPath, + pathIdentifier, + splitDisplay, + dottedKey, + KIND_BY_COLLECTION, +} from '../src/path.js'; + +describe('parseAstraPath', () => { + it('parses a collection + id in the current scope', () => { + expect(parseAstraPath('outputs/hubble_diagram')).toMatchObject({ + absolute: false, + up: 0, + scope: [], + collection: 'outputs', + id: 'hubble_diagram', + child: null, + }); + }); + + it('parses a child (option of a decision)', () => { + expect(parseAstraPath('decisions/algorithm/options/gp')).toMatchObject({ + collection: 'decisions', + id: 'algorithm', + child: { collection: 'options', id: 'gp' }, + }); + }); + + it('parses a child (evidence of a finding)', () => { + expect(parseAstraPath('findings/sig/evidence/fig1')).toMatchObject({ + collection: 'findings', + id: 'sig', + child: { collection: 'evidence', id: 'fig1' }, + }); + }); + + it('treats a leading bare id as a sub-analysis scope step (analyses/ implied)', () => { + expect(parseAstraPath('reconstruction/outputs/xi')).toMatchObject({ + scope: ['reconstruction'], + collection: 'outputs', + id: 'xi', + }); + }); + + it('parses the explicit analyses//… long form identically', () => { + expect(parseAstraPath('analyses/reconstruction/outputs/xi')).toMatchObject({ + scope: ['reconstruction'], + collection: 'outputs', + id: 'xi', + }); + }); + + it('parses nested scopes', () => { + expect(parseAstraPath('clustering/correlation/outputs/xi')).toMatchObject({ + scope: ['clustering', 'correlation'], + collection: 'outputs', + id: 'xi', + }); + }); + + it('parses a bare sub-analysis target (collection null)', () => { + expect(parseAstraPath('reconstruction')).toMatchObject({ + scope: ['reconstruction'], + collection: null, + id: null, + }); + expect(parseAstraPath('analyses/reconstruction')).toMatchObject({ + scope: ['reconstruction'], + collection: null, + id: null, + }); + }); + + it('parses a whole collection (a registry)', () => { + expect(parseAstraPath('outputs')).toMatchObject({ collection: 'outputs', id: null }); + expect(parseAstraPath('reconstruction/inputs')).toMatchObject({ + scope: ['reconstruction'], + collection: 'inputs', + id: null, + }); + expect(parseAstraPath('analyses')).toMatchObject({ collection: 'analyses', id: null }); + }); + + it('handles leading / (absolute) and ../ (parent climbs)', () => { + expect(parseAstraPath('/decisions/method')).toMatchObject({ + absolute: true, + up: 0, + collection: 'decisions', + id: 'method', + }); + expect(parseAstraPath('../../outputs/xi')).toMatchObject({ + absolute: false, + up: 2, + collection: 'outputs', + id: 'xi', + }); + }); + + it('accepts the prior-insights hyphen alias for prior_insights', () => { + expect(parseAstraPath('prior-insights/recon')).toMatchObject({ + collection: 'prior_insights', + id: 'recon', + }); + }); + + it('tolerates surrounding/duplicate slashes and whitespace', () => { + expect(parseAstraPath(' outputs//xi/ ')).toMatchObject({ + collection: 'outputs', + id: 'xi', + }); + }); +}); + +describe('pathIdentifier', () => { + it('maps collection + id to -', () => { + expect(pathIdentifier(parseAstraPath('outputs/xi'))).toBe('output-xi'); + expect(pathIdentifier(parseAstraPath('decisions/m'))).toBe('decision-m'); + expect(pathIdentifier(parseAstraPath('prior_insights/p'))).toBe('prior_insight-p'); + }); + + it('collapses children to their parent element identifier', () => { + expect(pathIdentifier(parseAstraPath('decisions/m/options/a'))).toBe('decision-m'); + expect(pathIdentifier(parseAstraPath('findings/f/evidence/e'))).toBe('finding-f'); + }); + + it('returns null for registries and bare sub-analyses', () => { + expect(pathIdentifier(parseAstraPath('outputs'))).toBeNull(); + expect(pathIdentifier(parseAstraPath('reconstruction'))).toBeNull(); + }); +}); + +describe('splitDisplay', () => { + it('extracts MyST-style display text ', () => { + expect(splitDisplay('our method ')).toEqual({ + display: 'our method', + path: 'decisions/algorithm', + }); + }); + + it('returns a bare path unchanged with null display', () => { + expect(splitDisplay('outputs/xi')).toEqual({ display: null, path: 'outputs/xi' }); + }); + + it('supports %s placeholders in the display text', () => { + expect(splitDisplay('see Fig. %s ')).toEqual({ + display: 'see Fig. %s', + path: 'outputs/xi', + }); + }); +}); + +describe('dottedKey + KIND_BY_COLLECTION', () => { + it('builds the dotted store key from scope + id', () => { + expect(dottedKey([], 'xi')).toBe('xi'); + expect(dottedKey(['reconstruction'], 'xi')).toBe('reconstruction.xi'); + }); + + it('maps every collection to its singular kind', () => { + expect(KIND_BY_COLLECTION).toMatchObject({ + outputs: 'output', + inputs: 'input', + decisions: 'decision', + findings: 'finding', + prior_insights: 'prior_insight', + analyses: 'analysis', + universes: 'universe', + }); + }); +}); diff --git a/tests/plugin-core.test.ts b/tests/plugin-core.test.ts index 994946c..2c3ac60 100644 --- a/tests/plugin-core.test.ts +++ b/tests/plugin-core.test.ts @@ -2,17 +2,13 @@ * Plugin-core emission tests — self-contained, no external fixture. * * Builds a tiny but complete ASTRA project in a temp dir (its own astra.yaml, - * universe, and result artifacts), then drives the plugin's directives, roles - * and transforms against it and asserts the emitted mdast. Mirrors the temp-dir - * pattern in `loader-validation.test.ts` so the suite is green in any clean - * checkout (no `prototype/` dependency). + * universe, and result artifacts), then drives the plugin's single `{astra}` + * directive, the inline roles ({astra}, {astra:num}, {astra:cite[:t]}, + * {astra:value}), and the transforms against it, asserting the emitted mdast. * - * The fixture exercises every surface the Strategy-A refactor introduced: - * the seven block directives, the cite + value roles, the resolved store - * (outputs/inputs/decisions/findings/insights/subanalyses, inlined table_data - * and metric, project-relative image urls, universe-resolved selections), the - * transitive provenance tracer (cross-scope inputs_root / decisions_transitive - * with universe narrowing), and the astra.yaml/universe mtime cache. + * Exercises the unified path grammar: elements, children (options / evidence), + * collections (registries), bare sub-analyses, scoped paths, directive options, + * the resolved store, the transitive provenance tracer, and the cache. */ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; @@ -26,25 +22,13 @@ import { traceProvenance, pageFrames } from '../src/transform/provenance.js'; // ── Fixture project ────────────────────────────────────────────────────── -// Decisions: `method` is overridden by the universe to `grid` (≠ its `mcmc` -// default) so we can prove the universe selection wins. The sub-analysis owns -// `sub_decision` (narrowed to `beta` only inside the sub scope) and an -// `inherited_method` aliased to the root via `from: ../method`. +// `method` is overridden by the universe to `grid` (≠ its `mcmc` default) so we +// can prove the universe selection wins. The sub-analysis owns `sub_decision` +// (narrowed to `beta` inside the sub scope) and an `inherited_method` aliased to +// the root via `from: ../method`. ASTRA no longer carries a `narrative` section. const ASTRA_YAML = `version: "1.0" name: Test Analysis authors: [Tester] -narrative: - summary: | - A minimal analysis for tests. ![Scatter](#outputs.scatter_plot) - inputs: | - Uses the [raw catalog](#inputs.raw_catalog). - methods: | - Driven by the [fit method](#decisions.method); see - [the sub-analysis](#analyses.sub). - outputs: | - Produces [measurements](#outputs.measurements). - findings: | - We report [a detection](#findings.signal_detected). decisions: method: label: "Fit method" @@ -115,9 +99,6 @@ findings: analyses: sub: name: "Sub Analysis" - narrative: - summary: | - A nested sub-analysis. decisions: sub_decision: label: "Sub decision" @@ -148,9 +129,6 @@ analyses: command: "python subplot.py {output}" `; -// Universe: `method` → grid (overrides the mcmc default); `sub_decision` is -// alpha at the root level but narrowed to beta inside the sub scope, so we can -// prove per-scope universe narrowing in the provenance tracer. const BASELINE_YAML = `id: baseline description: Baseline test universe. decisions: @@ -180,8 +158,6 @@ function buildFixture(): string { writeFileSync(join(root, 'astra.yaml'), ASTRA_YAML); mkdirSync(join(root, 'universes'), { recursive: true }); writeFileSync(join(root, 'universes', 'baseline.yaml'), BASELINE_YAML); - // Result artifacts. PNG bytes are irrelevant (only the path is read for - // figures); the CSV/JSON are parsed for table_data / metric / value. writeResult(root, 'scatter_plot', 'scatter_plot.png', 'PNG'); writeResult(root, 'measurements', 'measurements.csv', MEASUREMENTS_CSV); writeResult(root, 'summary_metric', 'summary_metric.json', JSON.stringify({ value: 1.5, uncertainty: 0.3, unit: 'Mpc' })); @@ -237,21 +213,21 @@ function textOf(nodes: Node[] | Node): string { return out; } -function directive(name: string) { - const d = plugin.directives.find((x: any) => x.name === `astra:${name}`); - if (!d) throw new Error(`no directive astra:${name}`); - return d; +const astraDirective = () => { + const d = plugin.directives.find((x: any) => x.name === 'astra'); + if (!d) throw new Error('no {astra} directive'); + return d as any; +}; +function runAstra(arg?: string, options: Record = {}): Node[] { + return astraDirective().run({ arg, options }) as Node[]; } function role(name: string) { - const r = plugin.roles.find((x: any) => x.name === `astra:${name}`); - if (!r) throw new Error(`no role astra:${name}`); - return r; -} -function runDirective(name: string, arg?: string, options: Record = {}): Node[] { - return (directive(name) as any).run({ arg, options }) as Node[]; + const r = plugin.roles.find((x: any) => x.name === name); + if (!r) throw new Error(`no role ${name}`); + return r as any; } function runRole(name: string, body: string): Node[] { - return (role(name) as any).run({ body }) as Node[]; + return role(name).run({ body }) as Node[]; } function runStore(path: string): Record { const t = plugin.transforms.find((x: any) => x.name === 'astra-resolved-store'); @@ -262,23 +238,22 @@ function runStore(path: string): Record { return carrier.data.astra; } -// ── Block directives ────────────────────────────────────────────────────── +// ── Block directive: elements ─────────────────────────────────────────────── -describe('block directives', () => { - it('decision → tabSet carrier tagged astra-decision with identifier', () => { - const nodes = runDirective('decision', 'method'); +describe('directive — elements', () => { + it('decisions/ → tabSet carrier tagged astra-decision with identifier', () => { + const nodes = runAstra('decisions/method'); const carrier = byIdentifier(nodes, 'decision-method'); expect(carrier).toBeDefined(); expect(hasClass(carrier, 'astra-decision')).toBe(true); expect(findFirst(nodes, (n) => n.type === 'tabSet')).toBeDefined(); - // selected option (grid, per universe) is reordered to the first tab - const firstTab = findFirst(nodes, (n) => n.type === 'tabItem'); - expect(firstTab?.title).toContain('Grid search'); + // selected option (grid, per universe) reordered to the first tab + expect(findFirst(nodes, (n) => n.type === 'tabItem')?.title).toContain('Grid search'); expect(JSON.stringify(nodes)).not.toContain('/static/'); }); - it('figure output → container[figure] with project-relative image url + markers', () => { - const nodes = runDirective('output', 'scatter_plot'); + it('outputs/ figure → container[figure] with project-relative image url', () => { + const nodes = runAstra('outputs/scatter_plot'); const carrier = byIdentifier(nodes, 'output-scatter_plot'); expect(carrier?.type).toBe('container'); expect(carrier?.kind).toBe('figure'); @@ -286,171 +261,263 @@ describe('block directives', () => { expect(hasClass(carrier, 'astra-output--figure')).toBe(true); const image = findFirst(nodes, (n) => n.type === 'image'); expect(image?.url).toBe('results/baseline/scatter_plot/scatter_plot.png'); - expect(image?.url.startsWith('/static/')).toBe(false); }); - it('table output → container[table] tagged astra-output--table', () => { - const nodes = runDirective('output', 'measurements'); + it('outputs/ table → container[table] tagged astra-output--table', () => { + const nodes = runAstra('outputs/measurements'); const carrier = byIdentifier(nodes, 'output-measurements'); - expect(carrier?.type).toBe('container'); expect(carrier?.kind).toBe('table'); expect(hasClass(carrier, 'astra-output--table')).toBe(true); expect(findFirst(nodes, (n) => n.type === 'table')).toBeDefined(); }); - it('metric output → carrier tagged astra-output--metric with identifier', () => { - const nodes = runDirective('output', 'summary_metric'); - const carrier = byIdentifier(nodes, 'output-summary_metric'); - expect(carrier).toBeDefined(); - expect(hasClass(carrier, 'astra-output--metric')).toBe(true); + it('outputs/ metric → carrier tagged astra-output--metric', () => { + expect(hasClass(byIdentifier(runAstra('outputs/summary_metric'), 'output-summary_metric'), 'astra-output--metric')).toBe(true); }); it('aliased output (from: sub.sub_plot) resolves the source type → figure', () => { - const nodes = runDirective('output', 'aliased_plot'); - const carrier = byIdentifier(nodes, 'output-aliased_plot'); - expect(carrier?.kind).toBe('figure'); - expect(hasClass(carrier, 'astra-output--figure')).toBe(true); + expect(byIdentifier(runAstra('outputs/aliased_plot'), 'output-aliased_plot')?.kind).toBe('figure'); }); - it('finding → astra-finding carrier; evidence image is project-relative', () => { - const nodes = runDirective('finding', 'signal_detected'); - const carrier = byIdentifier(nodes, 'finding-signal_detected'); - expect(carrier).toBeDefined(); - expect(hasClass(carrier, 'astra-finding')).toBe(true); - // evidence figure went through rewriteStaticImages → no /static scheme + it('findings/ → astra-finding carrier; no /static scheme leaks', () => { + const nodes = runAstra('findings/signal_detected'); + expect(hasClass(byIdentifier(nodes, 'finding-signal_detected'), 'astra-finding')).toBe(true); expect(JSON.stringify(nodes)).not.toContain('/static/'); }); - it('finding :compact: → heading + scope, no evidence figure', () => { - const nodes = runDirective('finding', 'signal_detected', { compact: true }); - expect(byIdentifier(nodes, 'finding-signal_detected')).toBeDefined(); - expect(findFirst(nodes, (n) => n.type === 'image')).toBeUndefined(); - expect(textOf(nodes)).toContain('baseline universe'); - }); - - it('prior-insight → seealso admonition tagged astra-prior-insight', () => { - const nodes = runDirective('prior-insight', 'prior_literature_result'); - const adm = findFirst(nodes, (n) => n.type === 'admonition'); + it('prior_insights/ → seealso admonition tagged astra-prior-insight', () => { + const adm = findFirst(runAstra('prior_insights/prior_literature_result'), (n) => n.type === 'admonition'); expect(adm?.kind).toBe('seealso'); expect(hasClass(adm, 'astra-prior-insight')).toBe(true); expect(adm?.identifier).toBe('prior_insight-prior_literature_result'); }); - it('subanalysis → card linking to the sub-page, tagged astra-subanalysis', () => { - const nodes = runDirective('subanalysis', 'sub'); - const carrier = byIdentifier(nodes, 'analysis-sub'); + it('inputs/ → one-row registry table tagged astra-input', () => { + const nodes = runAstra('inputs/raw_catalog'); + expect(hasClass(nodes[0], 'astra-input')).toBe(true); + expect(textOf(nodes)).toContain('Raw catalog'); + }); + + it('bare sub-analysis → card linking to the sub-page, tagged astra-subanalysis', () => { + const carrier = byIdentifier(runAstra('sub'), 'analysis-sub'); expect(carrier?.type).toBe('card'); expect(hasClass(carrier, 'astra-subanalysis')).toBe(true); expect(carrier?.url).toBe('/sub'); expect(carrier?.title).toBe('Sub Analysis'); }); - it('inputs / outputs tables carry their registry classes', () => { - expect(hasClass(runDirective('inputs')[0], 'astra-inputs')).toBe(true); - expect(hasClass(runDirective('outputs')[0], 'astra-outputs')).toBe(true); + it('universes/ → selections table tagged astra-universe', () => { + const nodes = runAstra('universes/baseline'); + expect(hasClass(byIdentifier(nodes, 'universe-baseline'), 'astra-universe')).toBe(true); + expect(textOf(nodes)).toContain('Grid search'); }); +}); - it('a bare from-reference decision yields an error admonition (not a render)', () => { - const nodes = runDirective('decision', 'sub.inherited_method'); - expect(nodes[0].type).toBe('admonition'); - expect(nodes[0].kind).toBe('error'); +// ── Block directive: children ──────────────────────────────────────────────── + +describe('directive — children', () => { + it('decisions//options/ → one option, selected marker, astra-option', () => { + const nodes = runAstra('decisions/method/options/grid'); + const head = byIdentifier(nodes, 'option-method-grid'); + expect(hasClass(head, 'astra-option')).toBe(true); + expect(textOf(nodes)).toContain('Grid search'); + expect(textOf(nodes)).toContain('(selected)'); // grid is the universe selection }); - it('an unknown component id yields an error admonition', () => { - const nodes = runDirective('output', 'no_such_output'); - expect(nodes[0].kind).toBe('error'); + it('findings//evidence/ → the single evidence record', () => { + const nodes = runAstra('findings/signal_detected/evidence/f1'); + expect(textOf(nodes)).toContain('A clear peak appears.'); + // the source artifact id renders as inline code + expect(findFirst(nodes, (n) => n.type === 'inlineCode' && n.value === 'scatter_plot')).toBeDefined(); + }); +}); + +// ── Block directive: collections (registries) ──────────────────────────────── + +describe('directive — registries', () => { + it('inputs / outputs registries carry their classes', () => { + expect(hasClass(runAstra('inputs')[0], 'astra-inputs')).toBe(true); + expect(hasClass(runAstra('outputs')[0], 'astra-outputs')).toBe(true); + }); + + it('decisions registry renders each rendered decision', () => { + expect(byIdentifier(runAstra('decisions'), 'decision-method')).toBeDefined(); + }); + + it('findings registry renders each finding', () => { + expect(byIdentifier(runAstra('findings'), 'finding-signal_detected')).toBeDefined(); + }); + + it('analyses registry renders a card per sub-analysis', () => { + expect(byIdentifier(runAstra('analyses'), 'analysis-sub')?.type).toBe('card'); }); }); -// ── Scoped sub-analysis resolution ────────────────────────────────────────── +// ── Block directive: options ───────────────────────────────────────────────── + +describe('directive — options', () => { + it(':caption: overrides an output caption', () => { + const nodes = runAstra('outputs/scatter_plot', { caption: 'Custom caption text' }); + const cap = findFirst(nodes, (n) => n.type === 'caption'); + expect(textOf(cap as Node)).toBe('Custom caption text'); + }); + + it(':label: overrides the carrier identifier', () => { + const nodes = runAstra('outputs/scatter_plot', { label: 'fig-custom' }); + expect(byIdentifier(nodes, 'fig-custom')).toBeDefined(); + }); + + it(':class: adds a CSS class to the carrier', () => { + const nodes = runAstra('findings/signal_detected', { class: 'highlight' }); + expect(hasClass(byIdentifier(nodes, 'finding-signal_detected'), 'highlight')).toBe(true); + }); + + it(':compact: / :hide: evidence trims a finding to claim + scope (no figure)', () => { + const nodes = runAstra('findings/signal_detected', { compact: true }); + expect(findFirst(nodes, (n) => n.type === 'image')).toBeUndefined(); + expect(textOf(nodes)).toContain('baseline universe'); + expect(findFirst(runAstra('findings/signal_detected', { hide: 'evidence' }), (n) => n.type === 'image')).toBeUndefined(); + }); +}); + +// ── Block directive: scoping + errors ──────────────────────────────────────── + +describe('directive — scoping & errors', () => { + it('resolves a scoped table output (sub/outputs/sub_table)', () => { + expect(byIdentifier(runAstra('sub/outputs/sub_table'), 'output-sub_table')?.kind).toBe('table'); + }); + + it('resolves a scoped decision (sub/decisions/sub_decision)', () => { + expect(byIdentifier(runAstra('sub/decisions/sub_decision'), 'decision-sub_decision')).toBeDefined(); + }); -describe('sub-analysis scope', () => { - it('resolves a scoped table output (sub.sub_table)', () => { - const nodes = runDirective('output', 'sub.sub_table'); - expect(byIdentifier(nodes, 'output-sub_table')?.kind).toBe('table'); + it('a bare from-reference decision yields an error admonition', () => { + const nodes = runAstra('sub/decisions/inherited_method'); + expect(nodes[0].type).toBe('admonition'); + expect(nodes[0].kind).toBe('error'); }); - it('resolves a scoped decision (sub.sub_decision)', () => { - const nodes = runDirective('decision', 'sub.sub_decision'); - expect(byIdentifier(nodes, 'decision-sub_decision')).toBeDefined(); + it('an unknown component id yields an error admonition', () => { + expect(runAstra('outputs/no_such_output')[0].kind).toBe('error'); }); }); -// ── Inline roles ──────────────────────────────────────────────────────────── +// ── Inline role: {astra} ───────────────────────────────────────────────────── -describe('inline roles', () => { - it('cite role → neutral astra-ref token carrying the store join key', () => { - const [token] = runRole('decision', 'method'); +describe('role {astra}', () => { + it('→ neutral astra-ref token carrying the store join key', () => { + const [token] = runRole('astra', 'decisions/method'); expect(hasClass(token, 'astra-ref')).toBe(true); expect(hasClass(token, 'astra-ref--decision')).toBe(true); expect(token.data?.astra).toEqual({ kind: 'decision', id: 'method', path: 'method' }); }); - it('output cite carries the output subtype modifier class', () => { - const [token] = runRole('output', 'scatter_plot'); + it('an output ref carries the output subtype modifier class', () => { + const [token] = runRole('astra', 'outputs/scatter_plot'); expect(hasClass(token, 'astra-ref--output')).toBe(true); expect(hasClass(token, 'astra-ref--figure')).toBe(true); }); - it('cite role honours a |display override for the inline label', () => { - const [token] = runRole('prior-insight', 'prior_literature_result|the prior'); + it('honours MyST display text override', () => { + const [token] = runRole('astra', 'the prior '); expect(textOf([token])).toBe('the prior'); - expect(token.data?.astra?.id).toBe('prior_literature_result'); - expect(token.data?.astra?.kind).toBe('prior_insight'); + expect(token.data?.astra).toMatchObject({ kind: 'prior_insight', id: 'prior_literature_result' }); }); - it('analysis cite resolves to the subanalyses store table', () => { - const [token] = runRole('analysis', 'sub'); + it('an input ref resolves to the inputs kind', () => { + expect(runRole('astra', 'inputs/raw_catalog')[0].data?.astra).toMatchObject({ kind: 'input', id: 'raw_catalog' }); + }); + + it('an option ref resolves to the option label + kind', () => { + const [token] = runRole('astra', 'decisions/method/options/grid'); + expect(token.data?.astra).toMatchObject({ kind: 'option', id: 'grid' }); + expect(textOf([token])).toBe('Grid search'); + }); + + it('a bare sub-analysis ref resolves to the subanalyses store table', () => { + const [token] = runRole('astra', 'sub'); expect(token.data?.astra).toMatchObject({ kind: 'analysis', id: 'sub' }); expect(runStore('index.md').subanalyses['sub']).toBeDefined(); }); - it('finding cite join key resolves in the store', () => { - const [token] = runRole('finding', 'signal_detected'); - expect(runStore('index.md').findings[token.data.astra.id]).toBeDefined(); + it('a scoped ref keeps the dotted store path', () => { + expect(runRole('astra', 'sub/outputs/sub_table')[0].data?.astra).toMatchObject({ + id: 'sub_table', + path: 'sub.sub_table', + }); + }); +}); + +// ── Inline role: {astra:num} ───────────────────────────────────────────────── + +describe('role {astra:num}', () => { + it('emits a numbered crossReference to the output carrier', () => { + const [node] = runRole('astra:num', 'outputs/scatter_plot'); + expect(node.type).toBe('crossReference'); + expect(node.kind).toBe('numref'); + expect(node.identifier).toBe('output-scatter_plot'); + }); + + it('carries %s display text through', () => { + const [node] = runRole('astra:num', 'see Fig. %s '); + expect(textOf([node])).toBe('see Fig. %s'); + expect(node.identifier).toBe('output-scatter_plot'); + }); +}); + +// ── Inline roles: {astra:cite} / {astra:cite:t} ────────────────────────────── + +describe('roles {astra:cite} / {astra:cite:t}', () => { + it('cite → a parenthetical cite from the insight DOI', () => { + const [node] = runRole('astra:cite', 'prior_insights/prior_literature_result'); + expect(node.type).toBe('cite'); + expect(node.kind).toBe('parenthetical'); + expect(node.label).toBe('10.1234/example.doi'); + }); + + it('cite:t → a narrative cite from the insight DOI', () => { + expect(runRole('astra:cite:t', 'prior_insights/prior_literature_result')[0].kind).toBe('narrative'); }); - it('scoped cite (sub.sub_table) keeps the dotted path', () => { - const [token] = runRole('output', 'sub.sub_table'); - expect(token.data?.astra).toMatchObject({ id: 'sub_table', path: 'sub.sub_table' }); + it('falls back to a plain reference when there is no DOI', () => { + const [node] = runRole('astra:cite', 'findings/signal_detected'); + expect(hasClass(node, 'astra-ref--finding')).toBe(true); }); }); -// ── Value interpolation role ──────────────────────────────────────────────── +// ── Inline role: {astra:value} ─────────────────────────────────────────────── -describe('value role', () => { +describe('role {astra:value}', () => { it('interpolates a real cell with ± uncertainty (pm convention)', () => { - const [token] = runRole('value', 'measurements tracer=lrg col=value pm'); + const [token] = runRole('astra:value', 'outputs/measurements tracer=lrg col=value pm'); expect(textOf([token])).toBe('19.88 ± 0.17'); expect(token.data?.astra).toMatchObject({ kind: 'value', id: 'measurements', col: 'value' }); }); - it('honours an explicit err=', () => { - const [token] = runRole('value', 'measurements tracer=lrg col=value err=value_std'); - expect(textOf([token])).toBe('19.88 ± 0.17'); + it('accepts the ± token as a synonym for pm', () => { + expect(textOf(runRole('astra:value', 'outputs/measurements tracer=lrg col=value ±'))).toBe('19.88 ± 0.17'); }); - it('formats to significant figures without ±', () => { - expect(textOf(runRole('value', 'measurements tracer=elg col=value'))).toBe('0.0696'); + it('honours an explicit err=', () => { + expect(textOf(runRole('astra:value', 'outputs/measurements tracer=lrg col=value err=value_std'))).toBe('19.88 ± 0.17'); }); - it('respects sig=N', () => { - expect(textOf(runRole('value', 'measurements tracer=lrg col=value sig=2'))).toBe('20'); + it('formats to significant figures and respects sig=N', () => { + expect(textOf(runRole('astra:value', 'outputs/measurements tracer=elg col=value'))).toBe('0.0696'); + expect(textOf(runRole('astra:value', 'outputs/measurements tracer=lrg col=value sig=2'))).toBe('20'); }); - it('resolves a scoped product (sub.sub_table)', () => { - expect(textOf(runRole('value', 'sub.sub_table tracer=lrg col=value'))).toBe('19.88'); + it('resolves a scoped product (sub/outputs/sub_table)', () => { + expect(textOf(runRole('astra:value', 'sub/outputs/sub_table tracer=lrg col=value'))).toBe('19.88'); }); - it('surfaces a clear error for a missing column', () => { - const [node] = runRole('value', 'measurements tracer=lrg col=not_a_column'); - expect(node.type).toBe('inlineCode'); - expect(node.value).toContain('value'); + it('a decision value resolves to the selected option label', () => { + expect(textOf(runRole('astra:value', 'decisions/method'))).toBe('Grid search'); }); - it('surfaces a clear error for a non-matching row filter', () => { - const [node] = runRole('value', 'measurements tracer=ghost col=value'); - expect(node.type).toBe('inlineCode'); + it('surfaces clear errors for a missing column / non-matching row', () => { + expect(runRole('astra:value', 'outputs/measurements tracer=lrg col=nope')[0].type).toBe('inlineCode'); + expect(runRole('astra:value', 'outputs/measurements tracer=ghost col=value')[0].type).toBe('inlineCode'); }); }); @@ -460,23 +527,13 @@ describe('resolved-store transform', () => { it('emits a hidden carrier with the resolved model keyed by id (root scope)', () => { const store = runStore('index.md'); - const fig = store.outputs['scatter_plot']; - expect(fig.type).toBe('figure'); - expect(fig.resolved_path).toBe('results/baseline/scatter_plot/scatter_plot.png'); - expect(fig.recipe).toMatchObject({ command: 'python plot.py {output}', container: 'astro:1' }); - - const tbl = store.outputs['measurements']; - expect(tbl.type).toBe('table'); - expect(tbl.table_data?.headers).toContain('value'); - - const metric = store.outputs['summary_metric']; - expect(metric.metric).toMatchObject({ value: 1.5, uncertainty: 0.3, unit: 'Mpc' }); + expect(store.outputs['scatter_plot'].type).toBe('figure'); + expect(store.outputs['scatter_plot'].resolved_path).toBe('results/baseline/scatter_plot/scatter_plot.png'); + expect(store.outputs['measurements'].table_data?.headers).toContain('value'); + expect(store.outputs['summary_metric'].metric).toMatchObject({ value: 1.5, uncertainty: 0.3, unit: 'Mpc' }); // universe selection wins over the declared default (mcmc → grid) expect(store.decisions['method'].selected).toBe('grid'); - expect(store.decisions['method'].options).toMatchObject({ grid: 'Grid search' }); - - // input, finding, insight, subanalysis presence expect(store.inputs['raw_catalog'].label).toBe('Raw catalog'); expect(store.findings['signal_detected']).toBeDefined(); expect(store.prior_insights['prior_literature_result'].doi).toBe('10.1234/example.doi'); @@ -485,45 +542,32 @@ describe('resolved-store transform', () => { it('serializes finding evidence and strips the universe clause from scope', () => { const finding = runStore('index.md').findings['signal_detected']; - expect(finding.evidence).toEqual([ - { artifact: 'scatter_plot', doi: undefined, quote: 'A clear peak appears.' }, - ]); - // the authored scope was ONLY the universe clause → dropped entirely + expect(finding.evidence).toEqual([{ artifact: 'scatter_plot', doi: undefined, quote: 'A clear peak appears.' }]); expect(finding.scope).toBeUndefined(); }); it('serializes option insights on decisions that cite them', () => { - const sub = runStore('sub.md'); - // beta cites nothing → omitted from the record entirely - expect(sub.decisions['sub_decision'].option_insights).toEqual({ - alpha: ['prior_literature_result'], - }); - // the root `method` decision cites an insight on its mcmc option - const root = runStore('index.md'); - expect(root.decisions['method'].option_insights).toEqual({ - mcmc: ['prior_literature_result'], - }); + expect(runStore('sub.md').decisions['sub_decision'].option_insights).toEqual({ alpha: ['prior_literature_result'] }); + expect(runStore('index.md').decisions['method'].option_insights).toEqual({ mcmc: ['prior_literature_result'] }); }); - it('emits a hidden astra-assets carrier routing result images through the asset pipeline', () => { + it('routes result images through a hidden astra-assets carrier', () => { const t = plugin.transforms.find((x: any) => x.name === 'astra-resolved-store'); const tree: Node = { type: 'root', children: [] }; (t as any).plugin()(tree, { path: 'index.md' }); const assets = tree.children.find((n: any) => n.class === 'astra-assets'); expect(assets?.style).toEqual({ display: 'none' }); - const img = assets!.children.find((n: any) => n.data?.astraAsset === 'scatter_plot'); - expect(img).toMatchObject({ + expect(assets!.children.find((n: any) => n.data?.astraAsset === 'scatter_plot')).toMatchObject({ type: 'image', url: 'results/baseline/scatter_plot/scatter_plot.png', }); }); - it('the carrier is an invisible div on book-theme', () => { + it('the store carrier is invisible on book-theme', () => { const t = plugin.transforms.find((x: any) => x.name === 'astra-resolved-store'); const tree: Node = { type: 'root', children: [] }; (t as any).plugin()(tree, { path: 'index.md' }); - const carrier = tree.children.find((n: any) => n.class === 'astra-store'); - expect(carrier?.style).toEqual({ display: 'none' }); + expect(tree.children.find((n: any) => n.class === 'astra-store')?.style).toEqual({ display: 'none' }); }); it('emits a hidden astra-cites carrier with narrative + parenthetical cites per DOI', () => { @@ -531,23 +575,19 @@ describe('resolved-store transform', () => { const tree: Node = { type: 'root', children: [] }; (t as any).plugin()(tree, { path: 'index.md' }); const cites = tree.children.find((n: any) => n.class === 'astra-cites'); - expect(cites?.style).toEqual({ display: 'none' }); const nodes = cites!.children[0].children; expect(nodes.map((c: any) => [c.label, c.kind])).toEqual([ ['10.1234/example.doi', 'narrative'], ['10.1234/example.doi', 'parenthetical'], ]); - expect(nodes.every((c: any) => c.type === 'cite')).toBe(true); }); it('scopes the store to a sub-analysis page (dotted basename)', () => { const store = runStore('sub.md'); expect(store.analysis.slug).toBe('sub'); expect(store.outputs['sub_table']).toBeDefined(); - // sub_decision is narrowed to beta inside the sub scope - expect(store.decisions['sub_decision'].selected).toBe('beta'); - // the bare-from inherited_method has no carrier → not in the store - expect(store.decisions['inherited_method']).toBeUndefined(); + expect(store.decisions['sub_decision'].selected).toBe('beta'); // narrowed in sub + expect(store.decisions['inherited_method']).toBeUndefined(); // bare-from, no carrier }); }); @@ -557,9 +597,6 @@ describe('dotted-filename page scope', () => { it('index.md maps to the root scope', () => { expect(runStore('index.md').analysis.slug).toBe('index'); }); - it('a trailing dot is tolerated and still resolves the scope', () => { - expect(runStore('sub..md').analysis.slug).toBe('sub'); - }); it('a non-ASTRA basename yields no store carrier (null scope)', () => { const t = plugin.transforms.find((x: any) => x.name === 'astra-resolved-store'); const tree: Node = { type: 'root', children: [] }; @@ -572,12 +609,9 @@ describe('dotted-filename page scope', () => { describe('decision option-tab supporting insights', () => { it('emits store-driven astra-ref tokens, not native crossReferences', () => { - const nodes = runDirective('decision', 'method'); + const nodes = runAstra('decisions/method'); const tok = findFirst(nodes, (n) => hasClass(n, 'astra-ref--prior_insight')); expect(tok?.data?.astra).toMatchObject({ kind: 'prior_insight', id: 'prior_literature_result' }); - expect( - findFirst(nodes, (n) => n.type === 'crossReference' && String(n.identifier).startsWith('prior_insight-')), - ).toBeUndefined(); }); }); @@ -586,30 +620,19 @@ describe('decision option-tab supporting insights', () => { describe('transitive provenance', () => { it('traces inputs_root and decisions_transitive across scopes with narrowing', () => { const out = runStore('index.md').outputs['measurements']; - - // root input reached through the dotted cross-link (sub.sub_table) and the - // sub's `from: raw_catalog` input alias expect(out.inputs_root.map((i: any) => i.id)).toEqual(['raw_catalog']); - const byId = Object.fromEntries(out.decisions_transitive.map((d: any) => [d.id, d])); - // direct decision on the output: no `via`, universe selection resolved expect(byId['method']).toMatchObject({ via: undefined, selection: 'Grid search' }); - // picked up inside the sub scope: `via` set, narrowed selection (beta) expect(byId['sub_decision']).toMatchObject({ via: 'sub', selection: 'Beta' }); - // method appears exactly once despite also being reached via ../method alias expect(out.decisions_transitive.filter((d: any) => d.id === 'method')).toHaveLength(1); }); }); -// ── Provenance unit: multi-level ../ decision alias ────────────────────────── - describe('traceProvenance ../ traversal', () => { it('climbs one scope per ../ for a multi-level decision alias', () => { const root: any = { decisions: { deep: { label: 'Deep', default: 'd1', options: { d1: { label: 'Deep One' } } } }, - inputs: [], - outputs: [], - analyses: {}, + inputs: [], outputs: [], analyses: {}, }; const mid: any = { decisions: {}, inputs: [], outputs: [], analyses: {} }; const leaf: any = { @@ -618,44 +641,34 @@ describe('traceProvenance ../ traversal', () => { outputs: [{ id: 'leaf_out', type: 'metric', decisions: ['esc'], inputs: [], recipe: { command: 'x' } }], analyses: {}, }; - const rootU: any = { - decisions: { deep: 'd1' }, - analyses: { mid: { decisions: {}, analyses: { leaf: { decisions: {} } } } }, - }; + const rootU: any = { decisions: { deep: 'd1' }, analyses: { mid: { decisions: {}, analyses: { leaf: { decisions: {} } } } } }; const frame = pageFrames([root, mid, leaf], rootU, ['mid', 'leaf']); - const traced = traceProvenance(leaf.outputs[0], frame); - - // `../../deep` must resolve in root (two levels up), not collapse to one climb - expect(traced.decisions_transitive).toEqual([ + expect(traceProvenance(leaf.outputs[0], frame).decisions_transitive).toEqual([ { id: 'deep', label: 'Deep', selection: 'Deep One', via: 'root' }, ]); }); }); -// ── buildResolvedStore direct call (no transform / no env) ─────────────────── +// ── buildResolvedStore direct call ─────────────────────────────────────────── describe('buildResolvedStore (direct)', () => { it('builds a keyed store from a minimal Analysis with no result files', () => { const analysis: any = { - id: 'mini', - name: 'Mini', + id: 'mini', name: 'Mini', decisions: { d: { label: 'D', default: 'x', options: { x: { label: 'X' }, y: { label: 'Y' } } } }, inputs: [{ id: 'in', type: 'data', label: 'In' }], outputs: [{ id: 'o', type: 'figure', label: 'O', inputs: ['in'], decisions: ['d'], recipe: { command: 'c' } }], - findings: {}, - prior_insights: {}, - analyses: {}, + findings: {}, prior_insights: {}, analyses: {}, }; const universe: any = { id: 'u', decisions: { d: 'y' } }; const store = buildResolvedStore(analysis, universe, () => undefined, 'index', (p) => p); - expect(store.outputs['o'].resolved_path).toBeUndefined(); // no artifact on disk - expect(store.outputs['o'].decisions).toEqual(['d']); - expect(store.decisions['d'].selected).toBe('y'); // universe override + expect(store.outputs['o'].resolved_path).toBeUndefined(); + expect(store.decisions['d'].selected).toBe('y'); expect(store.inputs['in'].label).toBe('In'); }); }); -// ── astra.yaml / universe mtime cache freshness ────────────────────────────── +// ── Cache freshness ────────────────────────────────────────────────────────── describe('source cache freshness', () => { let tmpRoot: string; @@ -665,22 +678,18 @@ describe('source cache freshness', () => { if (tmpRoot) rmSync(tmpRoot, { recursive: true, force: true }); }); - function storeSlug(): string { - return runStore('index.md').analysis.slug; - } - it('reuses the cache for an unchanged mtime and re-reads after the universe file advances', () => { tmpRoot = mkdtempSync(join(tmpdir(), 'mystra-reload-')); cpSync(PROJECT_ROOT, tmpRoot, { recursive: true }); process.env.ASTRA_PROJECT_ROOT = tmpRoot; - expect(storeSlug()).toBe('index'); // populate - expect(storeSlug()).toBe('index'); // cache hit + const slug = () => runStore('index.md').analysis.slug; + expect(slug()).toBe('index'); + expect(slug()).toBe('index'); - // Advancing the *universe* file (not astra.yaml) must also bust the cache. const uni = join(tmpRoot, 'universes', 'baseline.yaml'); const future = statSync(uni).mtimeMs / 1000 + 100; utimesSync(uni, future, future); - expect(storeSlug()).toBe('index'); // re-parse still yields a valid store + expect(slug()).toBe('index'); }); }); diff --git a/tests/prose.test.ts b/tests/prose.test.ts index 49daf50..394d7f7 100644 --- a/tests/prose.test.ts +++ b/tests/prose.test.ts @@ -1,6 +1,6 @@ /** - * Tests for the prose parser: Markdown → mdast and anchor → crossRef - * resolution per the ASTRA anchor grammar. + * Tests for the prose parser: Markdown → mdast and `#astra:` cross-reference + * resolution per the unified path grammar. */ import { describe, it, expect, vi } from 'vitest'; @@ -9,11 +9,10 @@ import { parseProseBlocks, parseProseInline, resolveNarrativeAnchors, - resolveAnchorPath, + resolveAstraAnchor, } from '../src/transform/prose.js'; -/** Minimal Analysis fixture with one finding, one decision, one - * sub-analysis — enough to exercise every resolution branch. */ +/** Minimal Analysis fixture: one finding, one decision, one sub-analysis. */ function fixtureAnalysis(): Analysis { return { name: 'Test', @@ -22,12 +21,7 @@ function fixtureAnalysis(): Analysis { }, prior_insights: {}, findings: { - best_model: { - id: 'best_model', - claim: 'SVM wins', - created_at: '2024-01-01', - evidence: [], - }, + best_model: { id: 'best_model', claim: 'SVM wins', created_at: '2024-01-01', evidence: [] }, }, inputs: [{ id: 'iris_data', type: 'data' }], outputs: [ @@ -36,11 +30,7 @@ function fixtureAnalysis(): Analysis { { id: 'results_table', type: 'table' }, ], analyses: { - preprocessing: { - decisions: {}, - prior_insights: {}, - findings: {}, - }, + preprocessing: { decisions: {}, prior_insights: {}, findings: {} }, }, }; } @@ -48,10 +38,7 @@ function fixtureAnalysis(): Analysis { function collectNodes(nodes: any[], type: string): any[] { const collected: any[] = []; const walk = (node: any) => { - if (Array.isArray(node)) { - node.forEach(walk); - return; - } + if (Array.isArray(node)) return node.forEach(walk); if (!node || typeof node !== 'object') return; if (node.type === type) collected.push(node); Object.values(node).forEach(walk); @@ -64,22 +51,19 @@ describe('parseProseBlocks (via myst-parser)', () => { it('splits paragraphs on blank lines', () => { const out = parseProseBlocks('First paragraph.\n\nSecond paragraph.'); expect(out).toHaveLength(2); - expect(out[0].type).toBe('paragraph'); - expect(out[1].type).toBe('paragraph'); + expect(out.every((n) => n.type === 'paragraph')).toBe(true); }); - it('preserves anchor links as link nodes pre-resolution', () => { - const out = parseProseBlocks( - 'See the [scaling decision](#decisions.scaling) for details.', - ); + it('preserves #astra: links as link nodes pre-resolution', () => { + const out = parseProseBlocks('See the [scaling](#astra:decisions/scaling) decision.'); const links = (out[0].children as any[]).filter((c) => c.type === 'link'); expect(links).toHaveLength(1); - expect(links[0].url).toBe('#decisions.scaling'); + expect(links[0].url).toBe('#astra:decisions/scaling'); }); it('parses inline strong/emphasis/code alongside anchors', () => { const out = parseProseBlocks( - 'Run **fast** _slow_ with `python` and see [finding](#findings.best_model).', + 'Run **fast** _slow_ with `python` and see [finding](#astra:findings/best_model).', ); const types = (out[0].children as any[]).map((c) => c.type); expect(types).toContain('strong'); @@ -88,35 +72,13 @@ describe('parseProseBlocks (via myst-parser)', () => { expect(types).toContain('link'); }); - it('emits myst-parser-shaped mdast (not the legacy approximation)', () => { - // The bespoke inline-parser used a single regex and produced - // shallow nodes without round-tripping nested formatting. This - // test pins down the mdast shape we now get from myst-parser: - // strong wraps text directly, links carry url + children, and - // inline code is `inlineCode` with a `value` (no children). - const out = parseProseBlocks('A **bold _nested_ word** in `code`.'); - const inline = out[0].children as any[]; - const strong = inline.find((c) => c.type === 'strong')!; - expect(strong.children[0].type).toBe('text'); - // Nested emphasis should survive inside strong — the bespoke - // parser flattened nested patterns. - expect(strong.children.some((c: any) => c.type === 'emphasis')).toBe(true); - const code = inline.find((c) => c.type === 'inlineCode')!; - expect(code.value).toBe('code'); - expect(code.children).toBeUndefined(); - }); - it('parses block-level structures (lists, code blocks, headings)', () => { const md = '# Heading\n\nA paragraph.\n\n- one\n- two\n\n```python\nx = 1\n```'; - const out = parseProseBlocks(md); - const types = out.map((n) => n.type); - expect(types).toContain('heading'); - expect(types).toContain('paragraph'); - expect(types).toContain('list'); - expect(types).toContain('code'); + const types = parseProseBlocks(md).map((n) => n.type); + expect(types).toEqual(expect.arrayContaining(['heading', 'paragraph', 'list', 'code'])); }); - it('strips position fields from output (no markdown-it noise)', () => { + it('strips position fields from output', () => { const out = parseProseBlocks('hello'); const stack: any[] = [...out]; while (stack.length) { @@ -130,10 +92,8 @@ describe('parseProseBlocks (via myst-parser)', () => { describe('parseProseInline', () => { it('unwraps a single paragraph to its inline children', () => { const out = parseProseInline('A **bold** claim.'); - // No paragraph wrapper, just the inline phrasing nodes. expect(out.every((n: any) => n.type !== 'paragraph')).toBe(true); - const types = out.map((n: any) => n.type); - expect(types).toContain('strong'); + expect(out.map((n: any) => n.type)).toContain('strong'); }); it('handles undefined and empty strings', () => { @@ -142,128 +102,122 @@ describe('parseProseInline', () => { }); it('preserves text from non-paragraph blocks (lists, headings, code)', () => { - // Author input that overshoots inline shouldn't silently - // vanish. `parseProseInline` walks each block and pulls out - // its phrasing content; previously only paragraphs survived. const md = '# heading\n\n- item one\n- item two\n\n```\nfenced code\n```'; - const out = parseProseInline(md); - const flat = out.map((n: any) => (n.type === 'text' ? n.value : '')).join(''); + const flat = parseProseInline(md) + .map((n: any) => (n.type === 'text' ? n.value : '')) + .join(''); expect(flat).toContain('heading'); expect(flat).toContain('item one'); - expect(flat).toContain('item two'); expect(flat).toContain('fenced code'); }); - it('keeps inline anchor links when input is a heading (block context)', () => { - const md = '# See [the finding](#findings.best_model) for details'; - const a = fixtureAnalysis(); - const out = parseProseInline(md, { analysis: a, slug: 'index' }); + it('resolves inline #astra: links when input is a heading (block context)', () => { + const out = parseProseInline('# See [the finding](#astra:findings/best_model) for details', { + analysis: fixtureAnalysis(), + slug: 'index', + }); const xrefs = out.filter((c: any) => c.type === 'crossReference'); expect(xrefs).toHaveLength(1); expect(xrefs[0].identifier).toBe('finding-best_model'); }); }); -describe('parseProseBlocks/Inline with context (anchor resolution)', () => { - // The same anchor-resolver post-pass that runs on narrative - // sections runs on every prose call site when a context is - // provided — so authors can cite from rationales, descriptions, - // claims, etc., not just narrative. +describe('parseProse* with context (anchor resolution)', () => { const a = fixtureAnalysis(); - it('Option.description: resolves [see finding](#findings.) to a crossReference', () => { - const md = 'See [the finding](#findings.best_model) for context.'; - const out = parseProseBlocks(md, { analysis: a, slug: 'index' }); - const inline = out[0].children as any[]; - const xrefs = inline.filter((c) => c.type === 'crossReference'); + it('resolves [finding](#astra:findings/) to a crossReference', () => { + const out = parseProseBlocks('See [the finding](#astra:findings/best_model).', { + analysis: a, + slug: 'index', + }); + const xrefs = (out[0].children as any[]).filter((c) => c.type === 'crossReference'); expect(xrefs).toHaveLength(1); expect(xrefs[0].identifier).toBe('finding-best_model'); }); - it('Decision.rationale: resolves [input](#inputs.) to a crossReference', () => { - const md = 'Driven by the [iris dataset](#inputs.iris_data).'; - const out = parseProseBlocks(md, { analysis: a, slug: 'index' }); - const inline = out[0].children as any[]; - const xrefs = inline.filter((c) => c.type === 'crossReference'); - expect(xrefs).toHaveLength(1); + it('resolves [input](#astra:inputs/) to a crossReference', () => { + const out = parseProseBlocks('Driven by the [iris dataset](#astra:inputs/iris_data).', { + analysis: a, + slug: 'index', + }); + const xrefs = (out[0].children as any[]).filter((c) => c.type === 'crossReference'); expect(xrefs[0].identifier).toBe('input-iris_data'); }); - it('Insight.claim (inline): leaves [parent](#../decisions.) as a plain link (parent escape)', () => { - const md = 'Echoes the [parent decision](#../decisions.method).'; - const out = parseProseInline(md, { analysis: a, slug: 'index' }); - const xrefs = out.filter((c: any) => c.type === 'crossReference'); - const links = out.filter((c: any) => c.type === 'link'); - expect(xrefs).toHaveLength(0); - expect(links).toHaveLength(1); - // ../ escapes preserve the original href; cross-scope - // resolution is left to the consumer. - expect(links[0].url).toBe('#../decisions.method'); - }); - - it('without context: anchors remain plain links (back-compat)', () => { - const md = 'See [it](#findings.best_model).'; - const out = parseProseBlocks(md); + it('without context: #astra: anchors remain plain links (back-compat)', () => { + const out = parseProseBlocks('See [it](#astra:findings/best_model).'); const inline = out[0].children as any[]; expect(inline.filter((c) => c.type === 'crossReference')).toHaveLength(0); expect(inline.filter((c) => c.type === 'link')).toHaveLength(1); }); }); -describe('resolveAnchorPath', () => { +describe('resolveAstraAnchor', () => { const a = fixtureAnalysis(); - it('resolves #findings. to a finding- identifier', () => { - expect(resolveAnchorPath('#findings.best_model', a, 'index')).toEqual({ + it('resolves in-scope elements to - identifiers', () => { + expect(resolveAstraAnchor('#astra:findings/best_model', a, 'index')).toEqual({ identifier: 'finding-best_model', }); + expect(resolveAstraAnchor('#astra:decisions/scaling', a, 'index')).toEqual({ + identifier: 'decision-scaling', + }); + expect(resolveAstraAnchor('#astra:inputs/iris_data', a, 'index')).toEqual({ + identifier: 'input-iris_data', + }); + expect(resolveAstraAnchor('#astra:outputs/accuracy', a, 'index')).toEqual({ + identifier: 'output-accuracy', + }); }); - it('resolves #decisions. to a decision- identifier', () => { - expect(resolveAnchorPath('#decisions.scaling', a, 'index')).toEqual({ + it('collapses an option child to the parent decision identifier', () => { + expect(resolveAstraAnchor('#astra:decisions/scaling/options/standard', a, 'index')).toEqual({ identifier: 'decision-scaling', }); }); - it('resolves #decisions..options. to the parent decision', () => { - // Option-level identifiers don't exist in MySTRA's xref scheme yet, - // so option anchors fall back to the parent decision heading. - expect( - resolveAnchorPath('#decisions.scaling.options.standard', a, 'index'), - ).toEqual({ identifier: 'decision-scaling' }); + it('falls back to a link URL for unknown in-scope ids', () => { + expect(resolveAstraAnchor('#astra:findings/nope', a, 'index')).toEqual({ + url: '#astra:findings/nope', + }); + expect(resolveAstraAnchor('#astra:outputs/nope', a, 'index')).toEqual({ + url: '#astra:outputs/nope', + }); }); - it('resolves #inputs. to an input- identifier', () => { - expect(resolveAnchorPath('#inputs.iris_data', a, 'index')).toEqual({ - identifier: 'input-iris_data', + it('routes a bare sub-analysis to a relative page URL', () => { + expect(resolveAstraAnchor('#astra:preprocessing', a, 'index')).toEqual({ url: '/preprocessing' }); + expect(resolveAstraAnchor('#astra:preprocessing', a, 'foo')).toEqual({ + url: '/foo/preprocessing', }); }); - it('resolves #outputs. to an output- identifier', () => { - expect(resolveAnchorPath('#outputs.accuracy', a, 'index')).toEqual({ - identifier: 'output-accuracy', + it('builds a cross-page URL with the - fragment for sub-analysis elements', () => { + expect(resolveAstraAnchor('#astra:preprocessing/outputs/features', a, 'index')).toEqual({ + url: '/preprocessing#output-features', + }); + expect(resolveAstraAnchor('#astra:preprocessing/decisions/scaling', a, 'index')).toEqual({ + url: '/preprocessing#decision-scaling', }); }); - it('resolves #prior_insights. to a prior_insight- identifier', () => { - const aWithPrior: Analysis = { - ...a, - prior_insights: { - compute_scaling: { - id: 'compute_scaling', - claim: 'Scaling matters', - created_at: '2024-01-01', - evidence: [], - }, - }, - }; - expect(resolveAnchorPath('#prior_insights.compute_scaling', aWithPrior, 'index')).toEqual({ - identifier: 'prior_insight-compute_scaling', + it('resolves an absolute /path from the root (cross-page when off-root)', () => { + expect(resolveAstraAnchor('#astra:/findings/best_model', a, 'index')).toEqual({ + identifier: 'finding-best_model', + }); + expect(resolveAstraAnchor('#astra:/findings/best_model', a, 'preprocessing')).toEqual({ + url: '/#finding-best_model', + }); + }); + + it('climbs scopes with ../', () => { + expect(resolveAstraAnchor('#astra:../decisions/scaling', a, 'preprocessing')).toEqual({ + url: '/#decision-scaling', }); }); - it('routes parent-scope #prior_insights. to the parent page carrier', () => { - const priorInsightScopes = [ + it('routes prior insights to the ancestor page that declares them', () => { + const scopes = [ { slug: 'index', priorInsights: { @@ -276,146 +230,65 @@ describe('resolveAnchorPath', () => { }, }, ]; - expect( - resolveAnchorPath( - '#prior_insights.compute_scaling', - a, - 'preprocessing', - priorInsightScopes, - ), + resolveAstraAnchor('#astra:prior_insights/compute_scaling', a, 'preprocessing', scopes), ).toEqual({ url: '/#prior_insight-compute_scaling' }); - expect( - resolveAnchorPath( - '#../prior_insights.compute_scaling', - a, - 'preprocessing', - priorInsightScopes, - ), + resolveAstraAnchor('#astra:../prior_insights/compute_scaling', a, 'preprocessing', scopes), ).toEqual({ url: '/#prior_insight-compute_scaling' }); }); - it('falls back when #inputs./#outputs./#prior_insights. targets are unknown', () => { - expect(resolveAnchorPath('#inputs.unknown', a, 'index')).toEqual({ - url: '#inputs.unknown', - }); - expect(resolveAnchorPath('#outputs.unknown', a, 'index')).toEqual({ - url: '#outputs.unknown', - }); - expect(resolveAnchorPath('#prior_insights.unknown', a, 'index')).toEqual({ - url: '#prior_insights.unknown', - }); - }); - - it('falls back to a link URL for missing finding ids', () => { - expect(resolveAnchorPath('#findings.unknown', a, 'index')).toEqual({ - url: '#findings.unknown', - }); - }); - - it('routes #analyses. to a relative URL on the host slug', () => { - expect(resolveAnchorPath('#analyses.preprocessing', a, 'index')).toEqual({ - url: '/preprocessing', - }); - expect(resolveAnchorPath('#analyses.preprocessing', a, 'foo')).toEqual({ - url: '/foo/preprocessing', - }); - }); - - it('appends sub-analysis path to the URL hash, translating ASTRA grammar to mdast ids', () => { - // `outputs.features` on the destination page renders as - // `output-features` (the structural-element id convention), so - // the cross-page URL fragment must use that form to land on a - // real anchor — not the raw ASTRA tree path. - expect( - resolveAnchorPath('#analyses.preprocessing.outputs.features', a, 'index'), - ).toEqual({ url: '/preprocessing#output-features' }); - }); - - it('translates other ASTRA categories to - in cross-page URLs', () => { - expect( - resolveAnchorPath('#analyses.preprocessing.decisions.scaling', a, 'index'), - ).toEqual({ url: '/preprocessing#decision-scaling' }); - expect( - resolveAnchorPath('#analyses.preprocessing.findings.best_model', a, 'index'), - ).toEqual({ url: '/preprocessing#finding-best_model' }); - expect( - resolveAnchorPath('#analyses.preprocessing.inputs.iris_data', a, 'index'), - ).toEqual({ url: '/preprocessing#input-iris_data' }); - expect( - resolveAnchorPath('#analyses.preprocessing.prior_insights.compute_scaling', a, 'index'), - ).toEqual({ url: '/preprocessing#prior_insight-compute_scaling' }); - }); - - it('routes #narrative.
on a sub-analysis to the destination narrative carrier', () => { - expect( - resolveAnchorPath('#analyses.preprocessing.narrative.summary', a, 'index'), - ).toEqual({ url: '/preprocessing#narrative-summary' }); - }); - - it('collapses #analyses..decisions..options. to the parent decision', () => { - expect( - resolveAnchorPath('#analyses.preprocessing.decisions.scaling.options.standard', a, 'index'), - ).toEqual({ url: '/preprocessing#decision-scaling' }); - }); - - it('falls back for ../ parent escapes (parent context unavailable)', () => { - expect(resolveAnchorPath('#../decisions.method', a, 'index')).toEqual({ - url: '#../decisions.method', + it('resolves a local prior insight to its identifier', () => { + const withPrior: Analysis = { + ...a, + prior_insights: { + compute_scaling: { id: 'compute_scaling', claim: 'x', created_at: '2024-01-01', evidence: [] }, + }, + }; + expect(resolveAstraAnchor('#astra:prior_insights/compute_scaling', withPrior, 'index')).toEqual({ + identifier: 'prior_insight-compute_scaling', }); }); - - it('treats a leading sub-analysis id as #analyses. shorthand', () => { - // `#preprocessing.outputs.features` — sub-analysis ID at the head - // is the shorthand documented in the spec example block; same - // grammar translation applies as the explicit form. - expect( - resolveAnchorPath('#preprocessing.outputs.features', a, 'index'), - ).toEqual({ url: '/preprocessing#output-features' }); - }); - }); describe('resolveNarrativeAnchors', () => { - it('rewrites in-scope anchor links to crossReference nodes', () => { + it('rewrites in-scope anchor links to crossReference nodes, keeping the text', () => { const a = fixtureAnalysis(); - const md = 'See [the finding](#findings.best_model) and [scaling](#decisions.scaling).'; + const md = 'See [the finding](#astra:findings/best_model) and [scaling](#astra:decisions/scaling).'; const resolved = resolveNarrativeAnchors(parseProseBlocks(md), a, 'index'); - - const inline = resolved[0].children as any[]; - const xrefs = inline.filter((c) => c.type === 'crossReference'); - expect(xrefs).toHaveLength(2); - expect(xrefs.map((x) => x.identifier).sort()).toEqual( - ['decision-scaling', 'finding-best_model'], + const xrefs = (resolved[0].children as any[]).filter((c) => c.type === 'crossReference'); + expect(xrefs.map((x) => x.identifier).sort()).toEqual(['decision-scaling', 'finding-best_model']); + expect(xrefs.find((x) => x.identifier === 'finding-best_model').children[0].value).toBe( + 'the finding', ); - // Children (the link text) survive the rewrite. - expect(xrefs[0].children[0].value).toBe('the finding'); }); it('leaves unresolvable anchors as plain link nodes', () => { const a = fixtureAnalysis(); - const md = 'See [missing](#findings.does_not_exist).'; - const resolved = resolveNarrativeAnchors(parseProseBlocks(md), a, 'index'); - const inline = resolved[0].children as any[]; - const links = inline.filter((c) => c.type === 'link'); + const resolved = resolveNarrativeAnchors( + parseProseBlocks('See [missing](#astra:findings/does_not_exist).'), + a, + 'index', + ); + const links = (resolved[0].children as any[]).filter((c) => c.type === 'link'); expect(links).toHaveLength(1); - expect(links[0].url).toBe('#findings.does_not_exist'); + expect(links[0].url).toBe('#astra:findings/does_not_exist'); }); - it('routes sub-analysis references to relative page URLs (link nodes)', () => { + it('routes a sub-analysis reference to a relative page URL', () => { const a = fixtureAnalysis(); - const md = 'See [pre](#analyses.preprocessing).'; - const resolved = resolveNarrativeAnchors(parseProseBlocks(md), a, 'index'); - const inline = resolved[0].children as any[]; - const links = inline.filter((c) => c.type === 'link'); - expect(links).toHaveLength(1); + const resolved = resolveNarrativeAnchors( + parseProseBlocks('See [pre](#astra:preprocessing).'), + a, + 'index', + ); + const links = (resolved[0].children as any[]).filter((c) => c.type === 'link'); expect(links[0].url).toBe('/preprocessing'); }); - it('rewrites narrative image output anchors to static artifact URLs', () => { + it('rewrites in-scope figure image embeds to /static artifact URLs', () => { const a = fixtureAnalysis(); - const resolved = parseProseBlocks('![Accuracy](#outputs.accuracy_plot)', { + const resolved = parseProseBlocks('![Accuracy](#astra:outputs/accuracy_plot)', { analysis: a, slug: 'index', results: (id) => (id === 'accuracy_plot' ? '/tmp/accuracy_plot.PNG' : undefined), @@ -427,31 +300,27 @@ describe('resolveNarrativeAnchors', () => { it('rewrites image URLs inside MyST figure directives', () => { const a = fixtureAnalysis(); - const resolved = parseProseBlocks( - ':::{figure} #outputs.accuracy_plot\nAccuracy by model\n:::', - { - analysis: a, - slug: 'index', - results: (id) => (id === 'accuracy_plot' ? '/tmp/accuracy_plot.svg' : undefined), - }, - ); + const resolved = parseProseBlocks(':::{figure} #astra:outputs/accuracy_plot\nCaption\n:::', { + analysis: a, + slug: 'index', + results: (id) => (id === 'accuracy_plot' ? '/tmp/accuracy_plot.svg' : undefined), + }); const images = collectNodes(resolved, 'image'); expect(images).toHaveLength(1); expect(images[0].url).toBe('/static/accuracy_plot.svg'); }); - it('drops narrative image embeds that point at non-figure outputs', () => { + it('drops image embeds that point at non-figure outputs', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const a = fixtureAnalysis(); - const resolved = parseProseBlocks('![Table](#outputs.results_table)', { + const resolved = parseProseBlocks('![Table](#astra:outputs/results_table)', { analysis: a, slug: 'index', results: (id) => (id === 'results_table' ? '/tmp/results_table.csv' : undefined), }); - expect(collectNodes(resolved, 'image')).toHaveLength(0); expect(warn).toHaveBeenCalledWith( - '[mystra] Narrative image embed references non-figure output "results_table" (type: table) — dropping image.', + '[mystra] image embed references non-figure output "results_table" (type: table) — dropped.', ); warn.mockRestore(); }); From 4bd80190496572a3044f7036c2a4d8e58bce9b41 Mon Sep 17 00:00:00 2001 From: Francois Lanusse Date: Tue, 30 Jun 2026 14:46:19 +0200 Subject: [PATCH 2/7] refactor: simplify the referencing layer per cleanup review - resolveInlineRef: drop a redundant resolveScope for bare sub-analysis references (the caller already resolved into the sub; read its name) - universes/: resolve the universe once in the directive instead of re-resolving inside renderElement - renderUniverse: build the table with the ast-helpers constructors - extract renderDecisionBlock to dedupe the decision render in element + registry paths - cache slugParts on Scope, removing repeated slug.split('/') - parseAstraPath: read the analyses branch left-to-right No behavior change; 105 tests pass. Signed-off-by: Francois Lanusse Co-Authored-By: Claude Opus 4.8 (1M context) --- src/index.ts | 88 +++++++++++++++++++++++++++------------------------- src/path.ts | 18 +++++------ 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4326a6c..0851f45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,6 +70,9 @@ import { paragraph, refNode, strong, + table, + tableCell, + tableRow, text, walkNodes, } from './transform/ast-helpers.js'; @@ -160,6 +163,8 @@ interface Scope { priorInsights: Record; outputsById: Map; slug: string; + /** The scope's sub-analysis ids (`[]` at root) — the slug, pre-split. */ + slugParts: string[]; tabItem: ReturnType; priorInsightScopes: PriorInsightScope[]; analysisScopes: AnalysisScope[]; @@ -238,6 +243,7 @@ function resolveScope( priorInsights, outputsById, slug, + slugParts, tabItem: makeTabItem(), priorInsightScopes, analysisScopes, @@ -459,28 +465,19 @@ function renderOneEvidence(p: AstraPath, scope: Scope): any[] { function renderUniverse(universeId: string | null, scope: Scope): any[] { const u = scope.universe; const selections = u.decisions ?? {}; - const ids = Object.keys(selections); - const headerRow = { - type: 'tableRow', - isHeader: true, - children: [ - { type: 'tableCell', header: true, children: [text('Decision')] }, - { type: 'tableCell', header: true, children: [text('Selected')] }, - ], - }; - const rows = ids.map((decId) => { + const headerRow = tableRow( + [tableCell([text('Decision')], true), tableCell([text('Selected')], true)], + true, + ); + const rows = Object.keys(selections).map((decId) => { const dec = scope.analysis.decisions?.[decId]; const optId = selections[decId]; - const optLabel = dec?.options?.[optId]?.label ?? optId; - return { - type: 'tableRow', - children: [ - { type: 'tableCell', children: [strong([text(dec?.label ?? decId)])] }, - { type: 'tableCell', children: [text(optLabel)] }, - ], - }; + return tableRow([ + tableCell([strong([text(dec?.label ?? decId)])]), + tableCell([text(dec?.options?.[optId]?.label ?? optId)]), + ]); }); - const node: any = { type: 'table', children: [headerRow, ...rows] }; + const node: any = table([headerRow, ...rows]); node.identifier = `universe-${universeId ?? u.id}`; node.label = node.identifier; addClass(node, 'astra-universe'); @@ -500,6 +497,15 @@ function renderSubAnalysisCard(parentScope: string[], subId: string, scope: Scop return [node]; } +/** Render one decision block (heading + tabbed options), tagged for recognition. */ +function renderDecisionBlock(id: string, decision: Decision, scope: Scope): any[] { + return tagComponent( + renderDecision(id, decision, scope.priorInsights, scope.universe, scope.prose, scope.tabItem), + 'decision', + id, + ); +} + /** Render a whole collection (a registry) for the current scope. */ function renderRegistry(collection: Collection, scope: Scope): any[] { switch (collection) { @@ -529,13 +535,7 @@ function renderRegistry(collection: Collection, scope: Scope): any[] { const nodes: any[] = []; for (const [id, decision] of Object.entries(decisions)) { if (!isDecisionRendered(decision as Decision, scope.universe)) continue; - nodes.push( - ...tagComponent( - renderDecision(id, decision as Decision, scope.priorInsights, scope.universe, scope.prose, scope.tabItem), - 'decision', - id, - ), - ); + nodes.push(...renderDecisionBlock(id, decision as Decision, scope)); } return nodes.length ? nodes : [errorNode('no rendered decisions in this scope')]; } @@ -554,7 +554,7 @@ function renderRegistry(collection: Collection, scope: Scope): any[] { } case 'analyses': { const subs = scope.analysis.analyses ?? {}; - const nodes = Object.keys(subs).flatMap((id) => renderSubAnalysisCard(scope.slug === 'index' ? [] : scope.slug.split('/'), id, scope)); + const nodes = Object.keys(subs).flatMap((id) => renderSubAnalysisCard(scope.slugParts, id, scope)); return nodes.length ? nodes : [errorNode('no sub-analyses in this scope')]; } case 'universes': @@ -574,11 +574,7 @@ function renderElement(p: AstraPath, scope: Scope, options: DirectiveOptions): a `decision "${id}" is a bare from-reference or its \`when\` is unmet under universe "${scope.universe.id}"`, ); } - return tagComponent( - renderDecision(id, decision, scope.priorInsights, scope.universe, scope.prose, scope.tabItem), - 'decision', - id, - ); + return renderDecisionBlock(id, decision, scope); } case 'outputs': { const output = scope.outputsById.get(id); @@ -599,11 +595,10 @@ function renderElement(p: AstraPath, scope: Scope, options: DirectiveOptions): a case 'inputs': return renderOneInput(id, scope); case 'analyses': - return renderSubAnalysisCard(scope.slug === 'index' ? [] : scope.slug.split('/'), id, scope); - case 'universes': { - const sub = resolveScope(scope.root, id, []); - return renderUniverse(id, sub); - } + return renderSubAnalysisCard(scope.slugParts, id, scope); + case 'universes': + // The directive already resolved this scope under universe `id`. + return renderUniverse(id, scope); default: return [errorNode(`astra: cannot render "${p.raw}"`)]; } @@ -646,9 +641,12 @@ const astraDirective = { // A bare sub-analysis resolves the *parent* scope and looks the sub up there. const isBareSub = !p.collection; const analysisPath = isBareSub ? p.scope.slice(0, -1) : p.scope; + // `universes/` resolves under that universe; an explicit :universe: wins. + const universe = + options.universe ?? (p.collection === 'universes' ? p.id ?? undefined : undefined) ?? universeName(); try { - const scope = resolveScope(projectRoot(), options.universe ?? universeName(), analysisPath); + const scope = resolveScope(projectRoot(), universe, analysisPath); let nodes: any[]; if (isBareSub) { nodes = renderSubAnalysisCard(analysisPath, p.scope[p.scope.length - 1], scope); @@ -727,12 +725,16 @@ function resolveInlineRef( }; } - // A bare sub-analysis reference. + // A bare sub-analysis reference. The caller resolved scope to `p.scope`, so + // `scope.analysis` is the sub-analysis itself — no need to re-resolve. if (!p.collection) { const subId = p.scope[p.scope.length - 1]; - const parent = resolveScope(scope.root, undefined, p.scope.slice(0, -1)); - const sub = parent.analysis.analyses?.[subId]; - return { kind: 'analysis', id: subId, path: dottedKey(p.scope.slice(0, -1), subId), label: display ?? sub?.name ?? humanize(subId) }; + return { + kind: 'analysis', + id: subId, + path: dottedKey(p.scope.slice(0, -1), subId), + label: display ?? scope.analysis.name ?? humanize(subId), + }; } const id = p.id!; @@ -1001,7 +1003,7 @@ function parentInputMaps(scope: Scope): Map[] { */ function pageProvFrame(scope: Scope): ProvFrame { const rootUniverse = getSource(scope.root, universeName()).universe; - const segs = scope.slug === 'index' ? [] : scope.slug.split('/'); + const segs = scope.slugParts; const analyses = [...scope.analysisScopes.map((s) => s.analysis), scope.analysis]; return pageFrames(analyses, rootUniverse, segs); } diff --git a/src/path.ts b/src/path.ts index 209cbe2..f29bb07 100644 --- a/src/path.ts +++ b/src/path.ts @@ -128,17 +128,15 @@ export function parseAstraPath(raw: string): AstraPath { const col = canonicalCollection(seg); if (col === 'analyses') { - // `analyses` as the final segment is the sub-analyses registry. - if (i === segs.length - 1) { - collection = 'analyses'; - break; + // `analyses/` is a scope step (the sub becomes the target only when + // it's the final segment); a trailing bare `analyses` is the registry. + if (i + 1 < segs.length) { + scope.push(segs[i + 1]); + i += 2; + continue; } - // `analyses/` as the final pair targets that sub-analysis itself; - // otherwise it's a scope step and parsing continues inside the sub. - const sub = segs[i + 1]; - scope.push(sub); - i += 2; - continue; + collection = 'analyses'; + break; } if (col) { From a11e0a683351410b222c92d6b103804adbf10ec0 Mon Sep 17 00:00:00 2001 From: Francois Lanusse Date: Tue, 30 Jun 2026 17:35:30 +0200 Subject: [PATCH 3/7] fix: resolve astra cross-references as links, not crossReference nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MyST's reference resolver fills the number/label for `link` nodes during its own pipeline but leaves plugin-injected `crossReference` nodes unresolved (LaTeX `\ref{undefined}`, no figure number). Found while porting the DESI DR1 example: every {astra:num} and #astra: in-page reference rendered undefined. Emit `link` nodes (url `#`) instead so MyST resolves them natively: - {astra:num} role → link (empty/%s text filled by MyST as "Figure N") - the #astra: anchor transform's in-page verdict → link to # Tests updated to assert the link form; 105 pass. Verified against the example: numbered figure/table references now resolve. Signed-off-by: Francois Lanusse Co-Authored-By: Claude Opus 4.8 (1M context) --- src/index.ts | 16 +++++++++++----- src/transform/prose.ts | 7 +++++-- tests/plugin-core.test.ts | 14 +++++++++----- tests/prose.test.ts | 40 +++++++++++++++++++-------------------- 4 files changed, 44 insertions(+), 33 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0851f45..0bc831f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,10 +62,10 @@ import { card, cite, citeGroup, - crossReference, emphasis, heading, hiddenDiv, + link, makeTabItem, paragraph, refNode, @@ -779,7 +779,15 @@ const astraRole = { }, }; -/** `{astra:num}` — native numbered cross-reference (e.g. "Figure 3"). */ +/** + * `{astra:num}` — native numbered cross-reference (e.g. "Figure 3"). + * + * Emits a `link` to the target identifier (NOT a `crossReference` node): MyST's + * reference resolver fills the number/label for link nodes during its own + * pipeline, but leaves plugin-injected `crossReference` nodes unresolved + * (`\ref{undefined}`). The empty/`%s` link text is filled by MyST, matching how + * a plain `[](#output-id)` link numbers a figure. + */ const astraNumRole = { name: 'astra:num', doc: 'Numbered cross-reference to a placed output (like {numref}; supports %s).', @@ -789,9 +797,7 @@ const astraNumRole = { const p = parseAstraPath(path); const ident = pathIdentifier(p); if (!ident) return [text(display ?? path)]; - const node: any = crossReference(ident, display ? [text(display)] : []); - node.kind = 'numref'; - return [node]; + return [link(`#${ident}`, display ? [text(display)] : [])]; }, }; diff --git a/src/transform/prose.ts b/src/transform/prose.ts index a1fa12d..f95f292 100644 --- a/src/transform/prose.ts +++ b/src/transform/prose.ts @@ -17,7 +17,7 @@ import { mystParse } from 'myst-parser'; import { parse as parsePath } from 'node:path'; import type { Analysis, Insight } from '@astra-spec/sdk'; import type { ArtifactResolver } from '../loader.js'; -import { crossReference, link } from './ast-helpers.js'; +import { link } from './ast-helpers.js'; import { parseAstraPath, pathIdentifier } from '../path.js'; // ── Parsing ─────────────────────────────────────────────────────── @@ -277,8 +277,11 @@ function rewrite( if (node.type === 'link' && typeof node.url === 'string' && node.url.startsWith('#astra:')) { const verdict = resolveAstraAnchor(node.url, analysis, slug, priorInsightScopes); + // Emit a `link` to the local identifier rather than a `crossReference` node: + // MyST's resolver fills the number/label for links during its own pipeline + // but leaves plugin-injected crossReferences unresolved (`\ref{undefined}`). return 'identifier' in verdict - ? crossReference(verdict.identifier, node.children ?? []) + ? link(`#${verdict.identifier}`, node.children ?? []) : link(verdict.url, node.children ?? []); } diff --git a/tests/plugin-core.test.ts b/tests/plugin-core.test.ts index 2c3ac60..e4b6634 100644 --- a/tests/plugin-core.test.ts +++ b/tests/plugin-core.test.ts @@ -451,17 +451,21 @@ describe('role {astra}', () => { // ── Inline role: {astra:num} ───────────────────────────────────────────────── describe('role {astra:num}', () => { - it('emits a numbered crossReference to the output carrier', () => { + // Emits a link to the output identifier (not a crossReference node): MyST's + // own resolver fills the "Figure N" number for link nodes, but leaves + // plugin-injected crossReferences unresolved. + it('emits a link to the output carrier identifier', () => { const [node] = runRole('astra:num', 'outputs/scatter_plot'); - expect(node.type).toBe('crossReference'); - expect(node.kind).toBe('numref'); - expect(node.identifier).toBe('output-scatter_plot'); + expect(node.type).toBe('link'); + expect(node.url).toBe('#output-scatter_plot'); + expect(node.children).toEqual([]); // empty → MyST fills "Figure N" }); it('carries %s display text through', () => { const [node] = runRole('astra:num', 'see Fig. %s '); + expect(node.type).toBe('link'); + expect(node.url).toBe('#output-scatter_plot'); expect(textOf([node])).toBe('see Fig. %s'); - expect(node.identifier).toBe('output-scatter_plot'); }); }); diff --git a/tests/prose.test.ts b/tests/prose.test.ts index 394d7f7..dc61941 100644 --- a/tests/prose.test.ts +++ b/tests/prose.test.ts @@ -116,39 +116,39 @@ describe('parseProseInline', () => { analysis: fixtureAnalysis(), slug: 'index', }); - const xrefs = out.filter((c: any) => c.type === 'crossReference'); - expect(xrefs).toHaveLength(1); - expect(xrefs[0].identifier).toBe('finding-best_model'); + const links = out.filter((c: any) => c.type === 'link'); + expect(links).toHaveLength(1); + expect(links[0].url).toBe('#finding-best_model'); }); }); describe('parseProse* with context (anchor resolution)', () => { const a = fixtureAnalysis(); - it('resolves [finding](#astra:findings/) to a crossReference', () => { + it('resolves [finding](#astra:findings/) to a local-identifier link', () => { const out = parseProseBlocks('See [the finding](#astra:findings/best_model).', { analysis: a, slug: 'index', }); - const xrefs = (out[0].children as any[]).filter((c) => c.type === 'crossReference'); - expect(xrefs).toHaveLength(1); - expect(xrefs[0].identifier).toBe('finding-best_model'); + const links = (out[0].children as any[]).filter((c) => c.type === 'link'); + expect(links).toHaveLength(1); + expect(links[0].url).toBe('#finding-best_model'); // MyST resolves the number/label }); - it('resolves [input](#astra:inputs/) to a crossReference', () => { + it('resolves [input](#astra:inputs/) to a local-identifier link', () => { const out = parseProseBlocks('Driven by the [iris dataset](#astra:inputs/iris_data).', { analysis: a, slug: 'index', }); - const xrefs = (out[0].children as any[]).filter((c) => c.type === 'crossReference'); - expect(xrefs[0].identifier).toBe('input-iris_data'); + const links = (out[0].children as any[]).filter((c) => c.type === 'link'); + expect(links[0].url).toBe('#input-iris_data'); }); - it('without context: #astra: anchors remain plain links (back-compat)', () => { + it('without context: #astra: anchors are left untouched', () => { const out = parseProseBlocks('See [it](#astra:findings/best_model).'); - const inline = out[0].children as any[]; - expect(inline.filter((c) => c.type === 'crossReference')).toHaveLength(0); - expect(inline.filter((c) => c.type === 'link')).toHaveLength(1); + const links = (out[0].children as any[]).filter((c) => c.type === 'link'); + expect(links).toHaveLength(1); + expect(links[0].url).toBe('#astra:findings/best_model'); }); }); @@ -252,18 +252,16 @@ describe('resolveAstraAnchor', () => { }); describe('resolveNarrativeAnchors', () => { - it('rewrites in-scope anchor links to crossReference nodes, keeping the text', () => { + it('rewrites in-scope anchors to local-identifier links, keeping the text', () => { const a = fixtureAnalysis(); const md = 'See [the finding](#astra:findings/best_model) and [scaling](#astra:decisions/scaling).'; const resolved = resolveNarrativeAnchors(parseProseBlocks(md), a, 'index'); - const xrefs = (resolved[0].children as any[]).filter((c) => c.type === 'crossReference'); - expect(xrefs.map((x) => x.identifier).sort()).toEqual(['decision-scaling', 'finding-best_model']); - expect(xrefs.find((x) => x.identifier === 'finding-best_model').children[0].value).toBe( - 'the finding', - ); + const links = (resolved[0].children as any[]).filter((c) => c.type === 'link'); + expect(links.map((x) => x.url).sort()).toEqual(['#decision-scaling', '#finding-best_model']); + expect(links.find((x) => x.url === '#finding-best_model').children[0].value).toBe('the finding'); }); - it('leaves unresolvable anchors as plain link nodes', () => { + it('leaves unresolvable anchors as their original #astra: link', () => { const a = fixtureAnalysis(); const resolved = resolveNarrativeAnchors( parseProseBlocks('See [missing](#astra:findings/does_not_exist).'), From e1d9d166af110ef0102980bf16f22733ff513adf Mon Sep 17 00:00:00 2001 From: Francois Lanusse Date: Tue, 30 Jun 2026 22:55:58 +0200 Subject: [PATCH 4/7] refactor: rename {astra:num} to {astra:numref} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match MyST's actual role name ({numref}) rather than inventing a `num` abbreviation — the redesign's whole premise is to mirror MyST conventions, and unlike cite:p/cite:t (which abbreviate nothing) `num` shortens the established full name for no benefit. Signed-off-by: Francois Lanusse Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 ++-- design_proposal.md | 18 +++++++++--------- src/index.ts | 12 ++++++------ tests/plugin-core.test.ts | 10 +++++----- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 2d45f24..d1a910f 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,8 @@ preview card). A few specialised variants follow MyST's colon convention (`{cite:p}` / `{cite:t}`): ```markdown -{astra:num}`outputs/hubble_diagram` # "Figure 3" (like {numref}; supports %s) -{astra:num}`see Fig. %s ` +{astra:numref}`outputs/hubble_diagram` # "Figure 3" (like {numref}; supports %s) +{astra:numref}`see Fig. %s ` {astra:cite}`prior_insights/recon_sharpens_bao` # "(Chen et al., 2024)" — parenthetical {astra:cite:t}`prior_insights/recon_sharpens_bao` # "Chen et al. (2024)" — textual ``` diff --git a/design_proposal.md b/design_proposal.md index ca94992..3e5eab3 100644 --- a/design_proposal.md +++ b/design_proposal.md @@ -71,7 +71,7 @@ role and a directive. Everything else on this page is detail. anything ASTRA-specific. 5. **Variants follow MyST's colon convention.** Just as MyST has `{cite:p}` and `{cite:t}`, the small number of specialised behaviours are colon-suffixed: - `{astra:num}`, `{astra:value}`, `{astra:cite}`. Nothing ad-hoc. + `{astra:numref}`, `{astra:value}`, `{astra:cite}`. Nothing ad-hoc. --- @@ -210,17 +210,17 @@ are colon-suffixed. Each takes the same path grammar. | Role | Purpose | Example | Renders | |------|---------|---------|---------| | `{astra}` | smart linked reference (default per kind) | `` {astra}`outputs/hubble_diagram` `` | "Hubble diagram" (link) | -| `{astra:num}` | numbered reference (like `{numref}`) | `` {astra:num}`outputs/hubble_diagram` `` | "Figure 3" | +| `{astra:numref}` | numbered reference (like `{numref}`) | `` {astra:numref}`outputs/hubble_diagram` `` | "Figure 3" | | `{astra:value}` | extract a live value (see §6) | `` {astra:value}`outputs/h0` `` | "67.4" | | `{astra:cite}` | bibliographic citation, parenthetical | `` {astra:cite}`prior_insights/recon_sharpens_bao` `` | "(Chen et al., 2024)" | | `{astra:cite:t}` | bibliographic citation, textual | `` {astra:cite:t}`prior_insights/recon_sharpens_bao` `` | "Chen et al. (2024)" | -`{astra:num}` supports the `%s` number placeholder and the `{label}` placeholder +`{astra:numref}` supports the `%s` number placeholder and the `{label}` placeholder in custom text, just like `{numref}`: ```markdown -{astra:num}`see Fig. %s ` → "see Fig. 3" -{astra:num}`the {label} (Fig. %s) ` +{astra:numref}`see Fig. %s ` → "see Fig. 3" +{astra:numref}`the {label} (Fig. %s) ` ``` --- @@ -334,7 +334,7 @@ inline reference fills in) and the default *block presentation*. | Kind | Inline default (`{astra}`) | Block default (`:::{astra}`) | |------|----------------------------|------------------------------| | `inputs/` | input label | a row/card describing the source | -| `outputs/` | output label; `{astra:num}` → "Figure/Table N" | the figure / table / metric, with caption + provenance | +| `outputs/` | output label; `{astra:numref}` → "Figure/Table N" | the figure / table / metric, with caption + provenance | | `decisions/` | decision label | its options (tabs by default) + rationale | | `decisions//options/` | option label | one option (description, support) | | `findings/` | finding label/claim | claim + scope + notes + evidence blocks | @@ -345,7 +345,7 @@ inline reference fills in) and the default *block presentation*. Auto-label resolution always falls back gracefully: explicit `label` → humanised id. Inline references to outputs participate in MyST numbering, so -`{astra:num}` yields stable "Figure 3" / "Table 2" style text. +`{astra:numref}` yields stable "Figure 3" / "Table 2" style text. --- @@ -449,7 +449,7 @@ central one is {astra}`decisions/algorithm`; we adopt ## Results -The headline result is the {astra:num}`outputs/hubble_diagram`: +The headline result is the {astra:numref}`outputs/hubble_diagram`: :::{astra} outputs/hubble_diagram :label: fig-hubble @@ -487,7 +487,7 @@ PATHS (mirror your astra.yaml; '/', '..', leading '/' as in file paths) INLINE (roles) {astra}`PATH` smart linked reference {astra}`text ` custom display text - {astra:num}`PATH` "Figure 3" (supports %s, {label}) + {astra:numref}`PATH` "Figure 3" (supports %s, {label}) {astra:value}`PATH col=C key=v ± sig=3` live value from results {astra:cite}`PATH` {astra:cite:t}`PATH` citation (paren / textual) [](#astra:PATH) @astra:PATH native link / shorthand forms diff --git a/src/index.ts b/src/index.ts index 0bc831f..ad427c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ * Inline reference (role): * {astra}`outputs/hubble_diagram` link + hover card * {astra}`our method ` custom display text - * {astra:num}`outputs/hubble_diagram` numbered ("Figure 3") + * {astra:numref}`outputs/hubble_diagram` numbered ("Figure 3") * {astra:value}`outputs/bao_table col=DV tracer=lrg3 ±` live number * {astra:cite}`prior_insights/recon` parenthetical citation * {astra:cite:t}`prior_insights/recon` textual citation @@ -685,7 +685,7 @@ function applyBlockOptions(nodes: any[], p: AstraPath, options: DirectiveOptions // // `{astra}` renders a neutral store-driven `astra-ref` span (best label as text // + a `data.astra` join key). A rich theme joins the key to the resolved store -// and renders a hover card; a bare theme shows the plain label. `{astra:num}` +// and renders a hover card; a bare theme shows the plain label. `{astra:numref}` // emits a native numbered crossReference; `{astra:cite[:t]}` emit MyST citations. type RefKind = @@ -780,7 +780,7 @@ const astraRole = { }; /** - * `{astra:num}` — native numbered cross-reference (e.g. "Figure 3"). + * `{astra:numref}` — native numbered cross-reference (e.g. "Figure 3"). * * Emits a `link` to the target identifier (NOT a `crossReference` node): MyST's * reference resolver fills the number/label for link nodes during its own @@ -788,8 +788,8 @@ const astraRole = { * (`\ref{undefined}`). The empty/`%s` link text is filled by MyST, matching how * a plain `[](#output-id)` link numbers a figure. */ -const astraNumRole = { - name: 'astra:num', +const astraNumrefRole = { + name: 'astra:numref', doc: 'Numbered cross-reference to a placed output (like {numref}; supports %s).', body: { type: String, required: true, doc: 'A path, optionally `text with %s `.' }, run(data: any): any[] { @@ -1164,7 +1164,7 @@ const plugin = { directives: [astraDirective], roles: [ astraRole, - astraNumRole, + astraNumrefRole, citeRole('astra:cite', 'parenthetical'), citeRole('astra:cite:t', 'narrative'), valueRole, diff --git a/tests/plugin-core.test.ts b/tests/plugin-core.test.ts index e4b6634..b29fc5e 100644 --- a/tests/plugin-core.test.ts +++ b/tests/plugin-core.test.ts @@ -3,7 +3,7 @@ * * Builds a tiny but complete ASTRA project in a temp dir (its own astra.yaml, * universe, and result artifacts), then drives the plugin's single `{astra}` - * directive, the inline roles ({astra}, {astra:num}, {astra:cite[:t]}, + * directive, the inline roles ({astra}, {astra:numref}, {astra:cite[:t]}, * {astra:value}), and the transforms against it, asserting the emitted mdast. * * Exercises the unified path grammar: elements, children (options / evidence), @@ -448,21 +448,21 @@ describe('role {astra}', () => { }); }); -// ── Inline role: {astra:num} ───────────────────────────────────────────────── +// ── Inline role: {astra:numref} ───────────────────────────────────────────────── -describe('role {astra:num}', () => { +describe('role {astra:numref}', () => { // Emits a link to the output identifier (not a crossReference node): MyST's // own resolver fills the "Figure N" number for link nodes, but leaves // plugin-injected crossReferences unresolved. it('emits a link to the output carrier identifier', () => { - const [node] = runRole('astra:num', 'outputs/scatter_plot'); + const [node] = runRole('astra:numref', 'outputs/scatter_plot'); expect(node.type).toBe('link'); expect(node.url).toBe('#output-scatter_plot'); expect(node.children).toEqual([]); // empty → MyST fills "Figure N" }); it('carries %s display text through', () => { - const [node] = runRole('astra:num', 'see Fig. %s '); + const [node] = runRole('astra:numref', 'see Fig. %s '); expect(node.type).toBe('link'); expect(node.url).toBe('#output-scatter_plot'); expect(textOf([node])).toBe('see Fig. %s'); From ed48397b94acc11aba6968bd2d21c576942ece6b Mon Sep 17 00:00:00 2001 From: Francois Lanusse Date: Tue, 30 Jun 2026 22:55:58 +0200 Subject: [PATCH 5/7] chore: update @astra-spec/sdk to 0.0.5 Bump from 0.0.3 to the latest 0.0.5. The narrative section was removed from ASTRA in this range, so the SDK no longer exports the narrative validators (validateNarrativeAnchors, checkNarrativeCoverage, validateNarrativeSections); drop them from loader validation (MySTRA was already narrative-free). Typecheck clean, 105 tests pass. Signed-off-by: Francois Lanusse Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 8 ++++---- package.json | 2 +- src/loader.ts | 24 +++++++++--------------- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 41b55d9..32eba5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "license": "BSD-3-Clause", "dependencies": { - "@astra-spec/sdk": "^0.0.3", + "@astra-spec/sdk": "^0.0.5", "myst-parser": "^1.7.1", "papaparse": "^5.4.0" }, @@ -26,9 +26,9 @@ } }, "node_modules/@astra-spec/sdk": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@astra-spec/sdk/-/sdk-0.0.3.tgz", - "integrity": "sha512-WboQVD5v+520IFbTh7YWZwEDYe4vf7LW3ABpd+anXO1onjy0ibpsYI8Imi2rJonaEcERF8jwUDcWJlNODYN0hA==", + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@astra-spec/sdk/-/sdk-0.0.5.tgz", + "integrity": "sha512-UgP+fx6ZK+7J9BtnRdX4NSyo5f71HfOZK0564BvBmVEdwKNcI1OqgQHgazeqjTuwg3bx1ayN5KsH8UOnCimr+w==", "license": "BSD-3-Clause", "dependencies": { "ajv": "^8.17.1", diff --git a/package.json b/package.json index 36c3dec..f593e8a 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "url": "https://github.com/LightconeResearch/MySTRA" }, "dependencies": { - "@astra-spec/sdk": "^0.0.3", + "@astra-spec/sdk": "^0.0.5", "myst-parser": "^1.7.1", "papaparse": "^5.4.0" }, diff --git a/src/loader.ts b/src/loader.ts index 147797f..822dda8 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -13,9 +13,6 @@ import { loadYaml, resolveAnalysisTree, validateAnalysis, - validateNarrativeAnchors, - checkNarrativeCoverage, - validateNarrativeSections, validateAnalysisData, } from '@astra-spec/sdk'; import type { Analysis, Universe } from '@astra-spec/sdk'; @@ -50,12 +47,11 @@ export function loadASTRASource(projectDir: string, universeName?: string): ASTR * they flag through the `[mystra]` warning channel — never by throwing. * * Policy: validation here is purely *advisory*. A malformed spec (a dangling - * `from:`, an unknown decision in a `when:`, a narrative anchor pointing at a - * non-existent element) should be reported loudly, but rendering must still - * proceed on whatever the resolver can make of the tree — a missing field is - * far better diagnosed by a clear warning than by an opaque late crash. So both - * `SemanticError`s and `NarrativeWarning`s are emitted as warnings, and *no* - * validator outcome aborts the load. + * `from:`, an unknown decision in a `when:`) should be reported loudly, but + * rendering must still proceed on whatever the resolver can make of the tree — a + * missing field is far better diagnosed by a clear warning than by an opaque + * late crash. So `SemanticError`s are emitted as warnings, and *no* validator + * outcome aborts the load. * * @astra-spec/sdk is still v0.0.x, so the validators themselves are not yet * load-bearing: a validator that throws on some shape it didn't anticipate must @@ -65,14 +61,12 @@ export function loadASTRASource(projectDir: string, universeName?: string): ASTR function reportValidation(projectDir: string, raw: RawSpec): void { const opts = { basePath: projectDir }; - // Each entry is a validator (semantic or narrative). Their results all expose - // `.code`, `.message`, `.path?` and a `toString()`, so one loop handles both - // `SemanticError[]` and `NarrativeWarning[]`. + // Each entry is a validator; results expose `.code`, `.message`, `.path?` and a + // `toString()`, so one loop handles them. (The narrative validators were + // dropped in @astra-spec/sdk 0.0.5 alongside the removal of the narrative + // section from ASTRA.) const checks: Array<[name: string, run: () => Array<{ toString(): string }>]> = [ ['validateAnalysis', () => validateAnalysis(raw, opts)], - ['validateNarrativeAnchors', () => validateNarrativeAnchors(raw, opts)], - ['checkNarrativeCoverage', () => checkNarrativeCoverage(raw, opts)], - ['validateNarrativeSections', () => validateNarrativeSections(raw, opts)], ]; for (const [name, run] of checks) { From 7a9a6c38dc91dc3e5b0a3f06eeffe1fdc9cff515 Mon Sep 17 00:00:00 2001 From: Francois Lanusse Date: Tue, 30 Jun 2026 23:15:40 +0200 Subject: [PATCH 6/7] fix: clear MyST build warnings (duplicate store id, decision heading depth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The resolved-store carrier set a fixed `astra-store` identifier on every page, colliding across the project ("Duplicate identifier"). It's selected by its `.astra-store` class, so drop the identifier. - Decisions rendered as h4; placed under a typical `## ` section that skips a level (MyST "missing heading depth 3"). Render at h3 — contiguous, and the same level findings already use. 105 tests pass; the DESI example now builds warning-free. Signed-off-by: Francois Lanusse Co-Authored-By: Claude Opus 4.8 (1M context) --- src/index.ts | 4 +++- src/transform/render-methods.ts | 11 ++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index ad427c7..2c7e9bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1121,8 +1121,10 @@ const storeTransform = { pageProvFrame(scope), ); mergeCrossScopeRefs(tree, store); + // The carrier is selected by its `.astra-store` class, not by identifier; + // a fixed identifier collides across pages ("Duplicate identifier in + // project"), so it carries none. const carrier: any = hiddenDiv('astra-store'); - carrier.identifier = 'astra-store'; carrier.data = { astra: store }; (tree.children ??= []).push(carrier); diff --git a/src/transform/render-methods.ts b/src/transform/render-methods.ts index 303849a..2f647f0 100644 --- a/src/transform/render-methods.ts +++ b/src/transform/render-methods.ts @@ -70,11 +70,12 @@ export function renderDecision( const nodes: any[] = []; - // h4 heading for the decision; identifier follows the - // structural-element scheme `-`. Tags ride along on the - // mdast `data` slot — surface for downstream consumers that want - // to group decisions, without imposing any grouping ourselves. - const head: any = heading(4, [text(decisionLabel)], `decision-${id}`); + // h3 heading for the decision (same level as a finding); identifier follows + // the structural-element scheme `-`. h3 sits contiguously under a + // typical `## ` section, where h4 skipped a level (MyST "missing heading depth + // 3"). Tags ride along on the mdast `data` slot — surface for downstream + // consumers that want to group decisions, without imposing any grouping. + const head: any = heading(3, [text(decisionLabel)], `decision-${id}`); if (decision.tags && decision.tags.length > 0) { head.data = { ...(head.data ?? {}), tags: decision.tags }; } From d3d0ebb2916373cc85d305e968061fe61c1e70de Mon Sep 17 00:00:00 2001 From: Francois Lanusse Date: Wed, 1 Jul 2026 10:50:36 +0200 Subject: [PATCH 7/7] =?UTF-8?q?Revert=20store-carrier=20identifier=20drop?= =?UTF-8?q?=20=E2=80=94=20it=20broke=20theme=20hover=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rich theme locates the resolved-store carrier by its `astra-store` identifier; dropping it (to silence the advisory "Duplicate identifier in project" warning) left the theme with no store to join, so inline hover preview cards stopped rendering. The identifier is load-bearing — restore it and keep the (benign, always-present) duplicate-identifier warning. The decision heading-depth fix from the same batch is unrelated and retained. Signed-off-by: Francois Lanusse Co-Authored-By: Claude Opus 4.8 (1M context) --- src/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2c7e9bc..6213779 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1121,10 +1121,14 @@ const storeTransform = { pageProvFrame(scope), ); mergeCrossScopeRefs(tree, store); - // The carrier is selected by its `.astra-store` class, not by identifier; - // a fixed identifier collides across pages ("Duplicate identifier in - // project"), so it carries none. + // The rich theme locates this carrier by its `astra-store` identifier (a + // provider reads its `data.astra` and feeds every inline `.astra-ref` token + // for the hover-card join), so the identifier is load-bearing — do NOT drop + // it. It is the same on every page, which makes MyST log an advisory + // "Duplicate identifier in project" warning; that is benign (each page keeps + // its own carrier) and must not be traded away for the hover feature. const carrier: any = hiddenDiv('astra-store'); + carrier.identifier = 'astra-store'; carrier.data = { astra: store }; (tree.children ??= []).push(carrier);