diff --git a/packages/myst-common/src/rule-severities.ts b/packages/myst-common/src/rule-severities.ts index 9df66fb76..5c507e4fd 100644 --- a/packages/myst-common/src/rule-severities.ts +++ b/packages/myst-common/src/rule-severities.ts @@ -28,7 +28,7 @@ export const RULE_DEFAULT_SEVERITY: Record = { [RuleId.validProjectConfig]: 'error', // validationOpts in packages/myst-cli/src/config.ts [RuleId.configHasNoDeprecatedFields]: 'error', // Uses both error (2×) and warn (5×); fileError, fileWarn, errorLogFn, warningLogFn in packages/mys... [RuleId.frontmatterIsYaml]: 'error', // fileError in packages/myst-transforms/src/frontmatter.ts - [RuleId.validPageFrontmatter]: 'error', // Uses both error (6×) and warn (7×); fileError, fileWarn, errorLogFn, warningLogFn in packages/mys... + [RuleId.validPageFrontmatter]: 'error', // Uses both error (6×) and warn (10×); fileError, fileWarn, errorLogFn, warningLogFn in packages/my... [RuleId.validFrontmatterExportList]: 'error', // Uses both error (1×) and warn (2×); fileError, fileWarn in packages/myst-cli/src/build/utils/coll... // Export rules [RuleId.docxRenders]: 'error', // Uses both error (5×) and warn (3×); fileError, fileWarn, errorLogFn, warningLogFn in packages/mys... diff --git a/packages/myst-frontmatter/src/numbering/numbering.spec.ts b/packages/myst-frontmatter/src/numbering/numbering.spec.ts index 0f3785177..452af2160 100644 --- a/packages/myst-frontmatter/src/numbering/numbering.spec.ts +++ b/packages/myst-frontmatter/src/numbering/numbering.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { fillNumbering } from './validators'; +import { fillNumbering, validateNumbering } from './validators'; describe('fillNumbering', () => { it('empty numberings return empty', async () => { @@ -115,3 +115,88 @@ describe('fillNumbering', () => { }); }); }); + +describe('validateNumbering — counter aliasing (#34)', () => { + const opts = () => ({ messages: {}, property: 'numbering' }) as any; + + it('passes through a bare proof-family counter target normalized to fully-qualified', () => { + const out = validateNumbering({ 'proof:lemma': { counter: 'theorem' } }, opts()); + expect(out?.['proof:lemma']?.counter).toBe('proof:theorem'); + }); + + it('leaves a fully-qualified target untouched', () => { + const out = validateNumbering({ 'proof:lemma': { counter: 'proof:theorem' } }, opts()); + expect(out?.['proof:lemma']?.counter).toBe('proof:theorem'); + }); + + it('warns and drops start/format/continue/scope/reset_on_part on an aliased kind', () => { + const o = opts(); + const out = validateNumbering( + { + 'proof:lemma': { + counter: 'theorem', + start: 5, + format: 'roman', + continue: true, + scope: 'section', + reset_on_part: true, + }, + }, + o, + ); + // counter alias is kept; owner-fields are dropped + expect(out?.['proof:lemma']).toEqual({ + counter: 'proof:theorem', + enabled: true, + }); + const warns = o.messages.warnings ?? []; + expect(warns.length).toBeGreaterThanOrEqual(5); + for (const field of ['start', 'format', 'continue', 'scope', 'reset_on_part']) { + expect(warns.some((m: any) => m.message.includes(`'${field}'`))).toBe(true); + } + }); + + it('preserves owner-fields on a cross-family key (alias is ignored, not the rest of the entry)', () => { + // Regression for the Copilot review on PR #40: a user typo like + // `figure.counter: proof:theorem` is a cross-family alias the + // engine refuses to honor. The validator must NOT silently drop + // `figure.start`/`figure.scope` etc. just because `counter` is + // set — otherwise valid figure config disappears. + const o = opts(); + const out = validateNumbering( + { + figure: { + counter: 'proof:theorem', + start: 5, + scope: 'section', + }, + }, + o, + ); + // counter field is kept (engine emits the family warning), and + // figure-owned fields stay intact. + expect(out?.figure?.counter).toBe('proof:theorem'); + expect(out?.figure?.start).toBe(5); + expect(out?.figure?.scope).toBe('heading_2'); + // No owner-field drop warnings should have fired. + const warns = o.messages.warnings ?? []; + expect(warns.some((m: any) => m.message.includes("'start'"))).toBe(false); + expect(warns.some((m: any) => m.message.includes("'scope'"))).toBe(false); + }); + + it('does NOT drop label or template on an aliased kind (rendering stays per-kind)', () => { + const out = validateNumbering( + { + 'proof:lemma': { + counter: 'theorem', + template: 'L. %s', + label: 'Lemma %s', + }, + }, + opts(), + ); + expect(out?.['proof:lemma']?.template).toBe('L. %s'); + expect(out?.['proof:lemma']?.label).toBe('Lemma %s'); + expect(out?.['proof:lemma']?.counter).toBe('proof:theorem'); + }); +}); diff --git a/packages/myst-frontmatter/src/numbering/types.ts b/packages/myst-frontmatter/src/numbering/types.ts index afca12e09..1cf913e62 100644 --- a/packages/myst-frontmatter/src/numbering/types.ts +++ b/packages/myst-frontmatter/src/numbering/types.ts @@ -49,6 +49,28 @@ export type NumberingItem = { * wins. `numbering.all.scope` is the project-wide default. */ scope?: NumberingScope; + /** + * Shared-counter alias (#34, LaTeX `\newtheorem{name}[other]{Heading}` + * parity). When set, this kind steps the *other* kind's counter slot + * instead of its own. The aliased kind keeps its own `label`/`template` + * for rendering — only counter mechanics are shared. + * + * Currently restricted to the proof family: `proof:lemma.counter: + * theorem` makes lemma share the theorem counter, so an interleaved + * `Lemma` between two `Theorem`s advances the next theorem number. + * Bare names within the proof family are normalized to the + * fully-qualified form (`theorem` → `proof:theorem`). + * + * Slot-owner-wins for counter mechanics: `start`, `format`, `continue`, + * `reset_on_part`, and `scope` are read from the slot owner. Setting + * those on an aliased proof-family kind emits a validator warning and + * is dropped. Cross-family aliasing and cycles are detected at + * transform time (in `myst-transforms/src/enumerate.ts` when the + * resolved-counter map is built) and degrade to per-kind behaviour + * with a warning — owner-fields on cross-family entries are left + * intact since the alias itself is ignored. + */ + counter?: string; }; /** diff --git a/packages/myst-frontmatter/src/numbering/validators.ts b/packages/myst-frontmatter/src/numbering/validators.ts index 95e24831a..ad2183508 100644 --- a/packages/myst-frontmatter/src/numbering/validators.ts +++ b/packages/myst-frontmatter/src/numbering/validators.ts @@ -37,8 +37,48 @@ const NUMBERING_ITEM_KEYS = [ 'label', 'reset_on_part', 'scope', + 'counter', ]; +/** + * Counter-mechanics fields that are read from the slot owner when a kind + * is aliased via `counter:` (#34). Setting these on an aliased kind has + * no effect — the slot's owner wins — so the validator warns and drops + * them to keep the rendered output consistent with the LaTeX + * `\newtheorem{name}[other]{Heading}` semantics this models. + * + * `label` and `template` (the "{Heading}" arg) are intentionally absent: + * they remain per-kind so an aliased `lemma` still renders "Lemma 1.2.2" + * while sharing the theorem counter slot. + */ +const COUNTER_OWNER_FIELDS = ['start', 'format', 'continue', 'reset_on_part', 'scope'] as const; + +/** + * Is this kind a proof-family kind for the purposes of `counter:` + * aliasing (#34)? Mirrors `isProofFamilyKind` in + * `myst-transforms/src/enumerate.ts` — both files need to agree on the + * family membership rule but neither owns it conceptually, so each + * keeps a local copy rather than introducing a shared dependency. + */ +function isProofFamilyKindForCounter(kind: string): boolean { + return kind === 'proof' || kind.startsWith('proof:') || kind.startsWith('prf:'); +} + +/** + * Normalize a bare proof-family `counter:` target (`theorem`) to its + * fully-qualified form (`proof:theorem`). The key — not the value — is + * always fully qualified in the numbering object, so authors who write + * `proof:lemma: { counter: theorem }` (the LaTeX-natural form) and + * authors who write `counter: proof:theorem` (the verbose form) both + * get the same resolution. Cross-family targets are returned as-is so + * the engine's family check can warn on them. + */ +function normalizeCounterTarget(keyKind: string, target: string): string { + if (target.includes(':')) return target; + if (isProofFamilyKindForCounter(keyKind)) return `proof:${target}`; + return target; +} + const COUNTER_FORMATS: CounterFormat[] = ['arabic', 'alph', 'Alph', 'roman', 'Roman']; const CONTINUE_STRINGS = ['continue', 'next']; @@ -222,6 +262,13 @@ export function validateNumberingItem( } } } + if (defined(value.counter)) { + const counter = validateString(value.counter, incrementOptions('counter', opts)); + if (defined(counter)) { + output.counter = counter; + output.enabled = output.enabled ?? true; + } + } if (Object.keys(output).length === 0) return undefined; return output; } @@ -340,6 +387,38 @@ export function validateNumbering(input: any, opts: ValidationOptions): Numberin } } }); + // #34: post-process `counter:` aliases — normalize bare proof-family + // targets to their fully-qualified form, and warn (then drop) any + // counter-mechanics fields set on aliased kinds. Cycle detection and + // cross-family enforcement happen at runtime in + // `myst-transforms/src/enumerate.ts` where node-attached warnings are + // natural; here we only handle the per-entry concerns the validator + // already has full local knowledge of. + // + // The owner-field drop is gated on the *key* being a proof-family + // kind. Cross-family entries (`figure.counter: proof:theorem`) keep + // both their `counter` field (so the engine can emit its family + // warning) and all their owner-fields (`figure.start`, `figure.scope`, + // etc.) intact — otherwise a typo'd alias would silently delete + // valid configuration the engine then refuses to honor. + Object.entries(output) + .filter(([key]) => !NUMBERING_OPTIONS.includes(key)) + .forEach(([key, item]) => { + if (!item?.counter) return; + const normalized = normalizeCounterTarget(key, item.counter); + if (normalized !== item.counter) item.counter = normalized; + if (!isProofFamilyKindForCounter(key)) return; + const itemOpts = incrementOptions(key, opts); + for (const field of COUNTER_OWNER_FIELDS) { + if (defined((item as any)[field])) { + validationWarning( + `'${field}' is read from the slot owner ('${item.counter}') when 'counter' is set; ignoring on '${key}'`, + itemOpts, + ); + delete (item as any)[field]; + } + } + }); if (Object.keys(output).length === 0) return undefined; return output; } diff --git a/packages/myst-transforms/src/enumerate.spec.ts b/packages/myst-transforms/src/enumerate.spec.ts index 9988dac63..81e733c75 100644 --- a/packages/myst-transforms/src/enumerate.spec.ts +++ b/packages/myst-transforms/src/enumerate.spec.ts @@ -628,6 +628,242 @@ describe('Book-mode auto-prefix (§3.4(6,7))', () => { expect(state.getTarget('fig:2')?.node.enumerator).toBe('3.2.1'); }); + // --------------------------------------------------------------------- + // #34: shared counters across proof:* kinds (LaTeX + // `\newtheorem{name}[other]{Heading}` parity). `proof:lemma.counter: + // theorem` makes lemma step the theorem counter slot. Combined with + // #28's `scope: section`, this produces the LaTeX-PDF-canonical + // chapter.section.index enumerators (see book-dp1 Vol I §1.2). + // --------------------------------------------------------------------- + + test('book-dp1 table: theorem/lemma/proposition share the theorem slot under scope=section (#34)', () => { + // Verbatim transcription of the LaTeX-PDF expected enumerators from + // book-dp1 Vol I ch_intro §1.2 and §2.1 (issue body): + // t-nsl → Theorem 1.2.1 + // l-rsnb → Lemma 1.2.2 + // t-bfpt → Theorem 1.2.3 + // l-rxrn → Lemma 1.2.4 + // p-iccm (§2) → Proposition 2.1.1 + // t-hartgrob → Theorem 2.1.3 (gap is intentional — the LaTeX + // PDF shows 2.1.2 going to another + // theorem-family env in the book, + // so a third increment of the + // shared slot lands on 3) + const tree = u('root', [ + // ch 1 §1.1 (placeholder to advance the section counter to 2) + u('heading', { identifier: 's11', depth: 2 }), + // ch 1 §1.2 — the section the issue body cites + u('heading', { identifier: 's12', depth: 2 }), + u('proof', { kind: 'theorem', identifier: 't-nsl' }), + u('proof', { kind: 'lemma', identifier: 'l-rsnb' }), + u('proof', { kind: 'theorem', identifier: 't-bfpt' }), + u('proof', { kind: 'lemma', identifier: 'l-rxrn' }), + ]); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + heading_2: { enabled: true }, + proof: { scope: 'section' }, + 'proof:lemma': { counter: 'theorem' }, + 'proof:proposition': { counter: 'theorem' }, + 'proof:corollary': { counter: 'theorem' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('t-nsl')?.node.enumerator).toBe('1.2.1'); + expect(state.getTarget('l-rsnb')?.node.enumerator).toBe('1.2.2'); + expect(state.getTarget('t-bfpt')?.node.enumerator).toBe('1.2.3'); + expect(state.getTarget('l-rxrn')?.node.enumerator).toBe('1.2.4'); + + // ch 2 §2.1 — separate state to mirror per-page processing + const tree2 = u('root', [ + u('heading', { identifier: 's21', depth: 2 }), + u('proof', { kind: 'proposition', identifier: 'p-iccm' }), + // simulate the missing "2.1.2" slot from the book — a third + // theorem-family env exists in the source between p-iccm and + // t-hartgrob, so the shared counter advances twice more + u('proof', { kind: 'lemma', identifier: 'l-gap' }), + u('proof', { kind: 'theorem', identifier: 't-hartgrob' }), + ]); + const state2 = new ReferenceState('ch2.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true, start: 2 }, + heading_2: { enabled: true }, + proof: { scope: 'section' }, + 'proof:lemma': { counter: 'theorem' }, + 'proof:proposition': { counter: 'theorem' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree2, { state: state2 }); + expect(state2.getTarget('p-iccm')?.node.enumerator).toBe('2.1.1'); + expect(state2.getTarget('l-gap')?.node.enumerator).toBe('2.1.2'); + expect(state2.getTarget('t-hartgrob')?.node.enumerator).toBe('2.1.3'); + }); + + test('aliased kind keeps its own template/label for rendering (#34)', () => { + // Slot-owner-wins applies only to counter *mechanics*. The + // enumerator wrap (the `Lemma %s` template) stays per-kind so + // cross-refs and render output read correctly while the slot is + // shared. + const tree = u('root', [ + u('proof', { kind: 'theorem', identifier: 't1' }), + u('proof', { kind: 'lemma', identifier: 'l1' }), + ]); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + 'proof:theorem': { enumerator: 'T %s' }, + 'proof:lemma': { counter: 'theorem', enumerator: 'L %s' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('t1')?.node.enumerator).toBe('T 1.1'); + expect(state.getTarget('l1')?.node.enumerator).toBe('L 1.2'); + }); + + test('shared slot resets together on scope change (#34)', () => { + // Both theorem and lemma share the theorem slot; crossing into a + // new section resets the slot exactly once. If lemma had its own + // `lastScopeKeyByKind` entry, the second crossing would re-fire + // the reset and clobber the counter to 0. + const tree = u('root', [ + u('heading', { identifier: 's1', depth: 2 }), + u('proof', { kind: 'theorem', identifier: 't1' }), + u('proof', { kind: 'lemma', identifier: 'l1' }), + u('heading', { identifier: 's2', depth: 2 }), + u('proof', { kind: 'theorem', identifier: 't2' }), + u('proof', { kind: 'lemma', identifier: 'l2' }), + ]); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + heading_2: { enabled: true }, + proof: { scope: 'section' }, + 'proof:lemma': { counter: 'theorem' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('t1')?.node.enumerator).toBe('1.1.1'); + expect(state.getTarget('l1')?.node.enumerator).toBe('1.1.2'); + // crossing into §1.2 — single reset, then theorem=1, lemma=2 + expect(state.getTarget('t2')?.node.enumerator).toBe('1.2.1'); + expect(state.getTarget('l2')?.node.enumerator).toBe('1.2.2'); + }); + + test('transitive resolution: a → b → c flattens to c (#34)', () => { + // proposition aliases to lemma; lemma aliases to theorem. All + // three step the theorem slot. + const tree = u('root', [ + u('proof', { kind: 'theorem', identifier: 't1' }), + u('proof', { kind: 'lemma', identifier: 'l1' }), + u('proof', { kind: 'proposition', identifier: 'p1' }), + u('proof', { kind: 'theorem', identifier: 't2' }), + ]); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + 'proof:lemma': { counter: 'theorem' }, + 'proof:proposition': { counter: 'lemma' }, + }, + }, + vfile: new VFile(), + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('t1')?.node.enumerator).toBe('1.1'); + expect(state.getTarget('l1')?.node.enumerator).toBe('1.2'); + expect(state.getTarget('p1')?.node.enumerator).toBe('1.3'); + expect(state.getTarget('t2')?.node.enumerator).toBe('1.4'); + }); + + test('cycle: a → b → a falls back to per-kind with warning (#34)', () => { + // Cycle is a config error. Both kinds degrade to per-kind + // counters and a warning is emitted. + const tree = u('root', [ + u('proof', { kind: 'theorem', identifier: 't1' }), + u('proof', { kind: 'lemma', identifier: 'l1' }), + u('proof', { kind: 'theorem', identifier: 't2' }), + ]); + const vfile = new VFile(); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + 'proof:theorem': { counter: 'lemma' }, + 'proof:lemma': { counter: 'theorem' }, + }, + }, + vfile, + }); + enumerateTargetsTransform(tree, { state }); + // Both kinds counted independently — theorem 1, 2; lemma 1. + expect(state.getTarget('t1')?.node.enumerator).toBe('1.1'); + expect(state.getTarget('l1')?.node.enumerator).toBe('1.1'); + expect(state.getTarget('t2')?.node.enumerator).toBe('1.2'); + expect(vfile.messages.some((m) => m.message.includes('cycle'))).toBe(true); + }); + + test('cross-family alias is ignored with warning (#34)', () => { + // `counter:` is restricted to the proof family. Aliasing a figure + // to the theorem slot is dropped + warned; figure continues with + // its own counter. + const tree = u('root', [ + u('container', { kind: 'figure', identifier: 'f1' }), + u('container', { kind: 'figure', identifier: 'f2' }), + ]); + const vfile = new VFile(); + const state = new ReferenceState('ch1.md', { + frontmatter: { + numbering: { + book: { enabled: true }, + all: { enabled: true }, + title: { enabled: true }, + heading_1: { enabled: true }, + figure: { counter: 'proof:theorem' }, + }, + }, + vfile, + }); + enumerateTargetsTransform(tree, { state }); + expect(state.getTarget('f1')?.node.enumerator).toBe('1.1'); + expect(state.getTarget('f2')?.node.enumerator).toBe('1.2'); + expect( + vfile.messages.some( + (m) => m.message.includes('proof-family') && m.message.includes('figure'), + ), + ).toBe(true); + }); + test('subfigures inherit the chapter-prefixed parent enumerator', () => { const tree = u('root', [ u('container', { kind: 'figure', identifier: 'fig-p' }, [ diff --git a/packages/myst-transforms/src/enumerate.ts b/packages/myst-transforms/src/enumerate.ts index 7ed242b70..75a73ed4a 100644 --- a/packages/myst-transforms/src/enumerate.ts +++ b/packages/myst-transforms/src/enumerate.ts @@ -78,6 +78,94 @@ function isProofFamilyKind(kind: string): boolean { return kind === 'proof' || kind.startsWith('proof:') || kind.startsWith('prf:'); } +/** + * Build the resolved-kind alias map for `counter:` sharing (#34, LaTeX + * `\newtheorem{name}[other]{Heading}` parity). + * + * Reads every `numbering[kind].counter` value, normalizes it to the + * fully-qualified form (already done by the frontmatter validator but + * tolerated here for defensiveness), and computes the *transitive* + * resolution so chains like `a→b→c` flatten to `a→c`, `b→c`. Cycles are + * detected and broken: the offending edges are dropped (no alias) and a + * warning is emitted, so output remains deterministic rather than + * oscillating between the cycle members. + * + * Constraints enforced here, not in the validator: + * - **Family check**: aliasing is only honored within the proof family + * (`proof:*` / `prf:*`). Cross-family targets are dropped + warned. + * The validator can't easily distinguish runtime directive families + * from generic kind names, but this engine already has + * `isProofFamilyKind` colocated with the rest of the family logic. + * - **Cycle break**: `a→b→a` drops both edges (returning self-resolution + * for both kinds), rather than picking an arbitrary member as the + * slot owner. + * + * Kinds without an alias are absent from the returned map; callers + * default to the kind itself (see `effectiveCounterKind`). The map is + * built once per `ReferenceState` and reused across every + * `incrementCount` call — there is no recursive lookup in the hot path. + */ +function buildResolvedCounterMap(numbering: Numbering, vfile?: VFile): Record { + const rawAliases: Record = {}; + for (const [kind, item] of Object.entries(numbering)) { + if (!item?.counter) continue; + if (!isProofFamilyKind(kind)) { + if (vfile) { + fileWarn( + vfile, + `numbering.${kind}.counter is only supported for proof-family kinds (proof:*, prf:*); ignoring`, + { source: TRANSFORM_NAME, ruleId: RuleId.validPageFrontmatter }, + ); + } + continue; + } + const target = item.counter.includes(':') ? item.counter : `proof:${item.counter}`; + if (!isProofFamilyKind(target)) { + if (vfile) { + fileWarn( + vfile, + `numbering.${kind}.counter target "${item.counter}" must be a proof-family kind; ignoring`, + { source: TRANSFORM_NAME, ruleId: RuleId.validPageFrontmatter }, + ); + } + continue; + } + rawAliases[kind] = target; + } + const resolved: Record = {}; + for (const start of Object.keys(rawAliases)) { + const path: string[] = [start]; + let cur: string = rawAliases[start]; + let cycled = false; + while (rawAliases[cur]) { + if (path.includes(cur)) { + cycled = true; + if (vfile) { + fileWarn( + vfile, + `numbering.counter cycle detected: ${[...path, cur].join(' → ')}; aliasing ignored on this chain`, + { source: TRANSFORM_NAME, ruleId: RuleId.validPageFrontmatter }, + ); + } + break; + } + path.push(cur); + cur = rawAliases[cur]; + } + if (cycled) continue; // self-resolution (no alias) for cycle members + if (cur !== start) resolved[start] = cur; + } + return resolved; +} + +/** + * Look up the counter slot that `kind` actually steps. Defaults to the + * kind itself when no alias is configured — the common case. + */ +function effectiveCounterKind(resolved: Record, kind: string): string { + return resolved[kind] ?? kind; +} + /** * Resolve a kind's effective auto-prefix scope depth (#27). * @@ -510,8 +598,19 @@ export class ReferenceState implements IReferenceStateResolver { * next increment of the same kind sees a different prefix, the main * counter resets. Empty on fresh pages — counter reset across pages * is governed by `targetCounts` carry-over, not this map. + * + * Keyed on the *resolved* counter kind (#34), so aliased kinds share + * the slot owner's scope-key entry. Otherwise an alias chain would + * trigger double resets at scope boundaries. */ lastScopeKeyByKind: Record; + /** + * Resolved `counter:` alias map (#34). Built once at construction + * from the page's numbering frontmatter. Lookups go through + * `effectiveCounterKind(this.resolvedCounters, kind)`. Kinds without + * an alias are absent from the map and default to themselves. + */ + resolvedCounters: Record; constructor( filePath: string, @@ -552,6 +651,7 @@ export class ReferenceState implements IReferenceStateResolver { this.dataUrl = opts?.dataUrl; this.title = opts?.frontmatter?.title; this.lastScopeKeyByKind = {}; + this.resolvedCounters = buildResolvedCounterMap(this.numbering, this.vfile); } addTarget(node: TargetNodes, hidden?: boolean) { @@ -617,16 +717,25 @@ export class ReferenceState implements IReferenceStateResolver { return enumerator; } const countKind = kind === TargetKind.subequation ? TargetKind.equation : kind; - // Ensure target kind is instantiated - this.targetCounts[countKind] ??= { main: 0, sub: 0 }; - const kindFormat = this.numbering[countKind]?.format; + // #34: when `numbering[countKind].counter` is set (proof-family + // only, e.g. `proof:lemma → proof:theorem`), every counter mechanic + // — target slot, scope-key tracking, format lookup, continue, + // scope depth — reads from the slot owner. Rendering (the + // enumerator wrap template) stays per-kind so an aliased lemma + // still renders "Lemma %s" while sharing the theorem counter slot. + // For non-aliased kinds and the subequation→equation mapping, + // `slotKind` equals `countKind` and behavior is unchanged. + const slotKind = effectiveCounterKind(this.resolvedCounters, countKind); + // Ensure target slot is instantiated + this.targetCounts[slotKind] ??= { main: 0, sub: 0 }; + const kindFormat = this.numbering[slotKind]?.format; // §3.2(e) auto-prefix: in book mode, prepend the active chapter or // appendix enumerator (this.enumerator — the page's H1 number / letter) // so figures render "3.1", "A.2", etc. Pages in front/back matter have // no this.enumerator, so the flat global counter is used automatically. // Per-kind `continue: true` (§3.4(6)) opts out and keeps the counter // flat across pages. - const continueKind = this.numbering[countKind]?.continue || this.numbering.all?.continue; + const continueKind = this.numbering[slotKind]?.continue || this.numbering.all?.continue; let autoPrefix = ''; if ( this.enumerator && @@ -637,9 +746,9 @@ export class ReferenceState implements IReferenceStateResolver { // #27: scope > 1 means "go deeper than the chapter prefix" — e.g. // `Theorem 5.1.2` from chapter 5, section 1, second theorem. The // counter must also reset on each scope boundary (e.g. new heading_2), - // so we track the last-seen scope key per kind and reset main/sub + // so we track the last-seen scope key per slot and reset main/sub // when it changes. Scope == 1 keeps today's behaviour exactly. - const scopeDepth = effectiveScopeDepth(this.numbering, countKind); + const scopeDepth = effectiveScopeDepth(this.numbering, slotKind); if (scopeDepth > 1) { const scopePrefix = formatHeadingPrefix( this.targetCounts.heading, @@ -648,42 +757,46 @@ export class ReferenceState implements IReferenceStateResolver { ); autoPrefix = scopePrefix ? `${scopePrefix}.` : ''; // Reset only on a real scope *change* — i.e. we've already seen - // this kind at a different scope key. Resetting on *first* - // encounter would clobber `numbering[kind].start` seeded by + // this slot at a different scope key. Resetting on *first* + // encounter would clobber `numbering[slotKind].start` seeded by // `initializeTargetCounts`, so e.g. `figure: { start: 5, scope: // section }` would silently render `5.1.1` instead of `5.1.5`. - const prevScopeKey = this.lastScopeKeyByKind[countKind]; + // #34: keying on `slotKind` (not `countKind`) means aliased + // kinds share the slot owner's scope-key entry; otherwise a + // chain like `lemma→theorem` would trigger double resets on + // each scope crossing. + const prevScopeKey = this.lastScopeKeyByKind[slotKind]; if (prevScopeKey !== undefined && prevScopeKey !== scopePrefix) { - this.targetCounts[countKind] = { main: 0, sub: 0 }; + this.targetCounts[slotKind] = { main: 0, sub: 0 }; } - this.lastScopeKeyByKind[countKind] = scopePrefix; + this.lastScopeKeyByKind[slotKind] = scopePrefix; } else { autoPrefix = `${this.enumerator}.`; } } if (node.subcontainer || kind === TargetKind.subequation) { - this.targetCounts[countKind].sub += 1; + this.targetCounts[slotKind].sub += 1; // Will restart counting if there are more than 26 subequations/figures const letter = String.fromCharCode( - ((this.targetCounts[countKind].sub - 1) % 26) + 'a'.charCodeAt(0), + ((this.targetCounts[slotKind].sub - 1) % 26) + 'a'.charCodeAt(0), ); if (node.subcontainer) { node.parentEnumerator = this.resolveEnumerator( - autoPrefix + formatCounter(this.targetCounts[countKind].main, kindFormat), + autoPrefix + formatCounter(this.targetCounts[slotKind].main, kindFormat), this.numbering[countKind]?.enumerator, ); enumerator = letter; } else { enumerator = this.resolveEnumerator( - autoPrefix + formatCounter(this.targetCounts[countKind].main, kindFormat) + letter, + autoPrefix + formatCounter(this.targetCounts[slotKind].main, kindFormat) + letter, this.numbering[countKind]?.enumerator, ); } } else { - this.targetCounts[kind].main += 1; - this.targetCounts[kind].sub = 0; + this.targetCounts[slotKind].main += 1; + this.targetCounts[slotKind].sub = 0; enumerator = this.resolveEnumerator( - autoPrefix + formatCounter(this.targetCounts[kind].main, kindFormat), + autoPrefix + formatCounter(this.targetCounts[slotKind].main, kindFormat), this.numbering[kind]?.enumerator, ); }