diff --git a/README.md b/README.md index eb9dda1..d1a910f 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: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 +``` + +### 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..3e5eab3 --- /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:numref}`, `{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: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:numref}` supports the `%s` number placeholder and the `{label}` placeholder +in custom text, just like `{numref}`: + +```markdown +{astra:numref}`see Fig. %s ` → "see Fig. 3" +{astra:numref}`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: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 | +| `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:numref}` 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:numref}`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: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 + +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/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/index.ts b/src/index.ts index fbf60f1..6213779 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: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 * - * 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,18 @@ import { admonitionTitle, card, cite, + citeGroup, emphasis, heading, hiddenDiv, + link, makeTabItem, paragraph, refNode, + strong, + table, + tableCell, + tableRow, text, walkNodes, } from './transform/ast-helpers.js'; @@ -85,6 +84,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 +135,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 +146,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; } @@ -158,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[]; @@ -180,16 +187,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 +230,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), ); @@ -243,6 +243,7 @@ function resolveScope( priorInsights, outputsById, slug, + slugParts, tabItem: makeTabItem(), priorInsightScopes, analysisScopes, @@ -288,23 +289,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 +314,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 +324,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 +404,437 @@ 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] }); +} -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' }, +/** 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 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]; + return tableRow([ + tableCell([strong([text(dec?.label ?? decId)])]), + tableCell([text(dec?.options?.[optId]?.label ?? optId)]), + ]); + }); + const node: any = table([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 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) { + 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(...renderDecisionBlock(id, decision as Decision, scope)); + } + 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.slugParts, 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 renderDecisionBlock(id, decision, scope); + } + 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.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}"`)]; + } +} + +/** 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; + // `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(), 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(), universe, 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:numref}` +// 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. 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]; + return { + kind: 'analysis', + id: subId, + path: dottedKey(p.scope.slice(0, -1), subId), + label: display ?? scope.analysis.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: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 + * 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 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[] { + const { display, path } = splitDisplay(String(data?.body ?? '')); + const p = parseAstraPath(path); + const ident = pathIdentifier(p); + if (!ident) return [text(display ?? path)]; + return [link(`#${ident}`, display ? [text(display)] : [])]; + }, +}; + +/** 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 +846,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 +854,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 +925,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 +933,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 +945,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 +971,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 +996,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,14 +1005,11 @@ 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; - 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); } @@ -827,6 +1022,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 +1065,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 +1120,43 @@ 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); + // 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); - // 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 +1167,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, + astraNumrefRole, + citeRole('astra:cite', 'parenthetical'), + citeRole('astra:cite:t', 'narrative'), valueRole, ], transforms: [anchorTransform, storeTransform], @@ -1029,6 +1183,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/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) { diff --git a/src/path.ts b/src/path.ts new file mode 100644 index 0000000..f29bb07 --- /dev/null +++ b/src/path.ts @@ -0,0 +1,184 @@ +/** + * 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/` 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; + } + collection = 'analyses'; + break; + } + + 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..f95f292 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 { 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,30 @@ 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); + // 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 + ? link(`#${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 +303,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/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 }; } 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..b29fc5e 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:numref}, {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,267 @@ 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:numref} ───────────────────────────────────────────────── + +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: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:numref', 'see Fig. %s '); + expect(node.type).toBe('link'); + expect(node.url).toBe('#output-scatter_plot'); + expect(textOf([node])).toBe('see Fig. %s'); + }); +}); + +// ── 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 +531,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 +546,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 +579,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 +601,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 +613,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 +624,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 +645,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 +682,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..dc61941 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' }); - const xrefs = out.filter((c: any) => c.type === 'crossReference'); - expect(xrefs).toHaveLength(1); - expect(xrefs[0].identifier).toBe('finding-best_model'); + 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 links = out.filter((c: any) => c.type === 'link'); + expect(links).toHaveLength(1); + expect(links[0].url).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'); - expect(xrefs).toHaveLength(1); - expect(xrefs[0].identifier).toBe('finding-best_model'); + 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 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('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); - expect(xrefs[0].identifier).toBe('input-iris_data'); + 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 links = (out[0].children as any[]).filter((c) => c.type === 'link'); + expect(links[0].url).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); + it('without context: #astra: anchors are left untouched', () => { + const out = parseProseBlocks('See [it](#astra:findings/best_model).'); + const links = (out[0].children as any[]).filter((c) => c.type === 'link'); 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); - 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); + expect(links[0].url).toBe('#astra:findings/best_model'); }); }); -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,63 @@ 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 anchors to local-identifier links, 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'], - ); - // Children (the link text) survive the rewrite. - expect(xrefs[0].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 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 +298,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(); });