@limaformat/lima 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,43 @@
1
1
  /** Reference tokens recorded while the annotated Core tree is being built. */
2
- import type { StringSourceSpan } from './builder.js';
3
2
  export type ReferenceToken2 = {
4
3
  token: string;
4
+ /**
5
+ * Index of the token within the decoded scalar value — the splice point
6
+ * for interpolation. A UTF-16 offset (JS `String.slice` units); stays
7
+ * implementation-local, unlike `line`/`offset`.
8
+ */
5
9
  index: number;
6
- offset: number;
10
+ /** Physical 1-based source line of the token (References 2.0 §2.4). */
7
11
  line: number;
12
+ /**
13
+ * 0-based **codepoint** offset of the token within its source line — the
14
+ * `column - 1` a diagnostic reports, and the tie-break key for §5 error
15
+ * ordering. Identical across implementations.
16
+ */
17
+ offset: number;
8
18
  documentPath?: string;
9
19
  partialPath?: string;
10
20
  };
21
+ /**
22
+ * Where a scalar begins in the source: physical 1-based `line` and 0-based
23
+ * codepoint column `col` of its first character. A token's position is this
24
+ * anchor plus its codepoint offset within the value.
25
+ *
26
+ * `raw` is the value text a token's position is *read from*, before comment
27
+ * stripping and `\#` collapse (so `x: a\#b ${t}` reports `${t}` at its real
28
+ * column) — for a block scalar it is the original body lines joined with
29
+ * `\n` (§2.4 forbids reconstructing a token's line from the `^^`-merged
30
+ * string). Omitted only when it equals the decoded value.
31
+ *
32
+ * `tabAdjust[i]` is the column count Core §3 added to physical line
33
+ * `line + i` by leading-tab expansion; it is subtracted so the reported
34
+ * column is the *original*-source column (§2.4).
35
+ */
36
+ export type ReferenceSource = {
37
+ raw?: string;
38
+ line: number;
39
+ col: number;
40
+ tabAdjust?: number[];
41
+ };
11
42
  export declare const PURE_REFERENCE_2: RegExp;
12
- export declare const scanReferenceTokens2: (value: string, firstLine: number, sourceSpans?: StringSourceSpan[]) => ReferenceToken2[];
43
+ export declare const scanReferenceTokens2: (value: string, source: ReferenceSource | undefined) => ReferenceToken2[];
@@ -1,3 +1,4 @@
1
+ /** Reference tokens recorded while the annotated Core tree is being built. */
1
2
  const SEGMENT = '[a-zA-Z0-9_][a-zA-Z0-9_:-]*';
2
3
  const DOC_PATH = `${SEGMENT}(?:\\.${SEGMENT})*`;
3
4
  const PARTIAL_NAME = '[a-zA-Z0-9_][a-zA-Z0-9_:/-]*';
@@ -5,33 +6,65 @@ const PARTIAL_PATH = `${PARTIAL_NAME}(?:\\.${SEGMENT})*`;
5
6
  export const PURE_REFERENCE_2 = new RegExp(`^(?:\\$\\{(${DOC_PATH})\\}|\\$\\((${PARTIAL_PATH})\\))$`);
6
7
  const REFERENCE_2 = new RegExp(`\\$\\{(${DOC_PATH})\\}|\\$\\((${PARTIAL_PATH})\\)`, 'g');
7
8
  const NO_TOKENS = [];
8
- export const scanReferenceTokens2 = (value, firstLine, sourceSpans) => {
9
- if (!value.includes('${') && !value.includes('$('))
10
- return NO_TOKENS;
9
+ /** Codepoint length of the UTF-16 slice `[a, b)` of `s`. */
10
+ const codepointLen = (s, a, b) => {
11
+ let n = 0;
12
+ for (let i = a; i < b;) {
13
+ i += s.codePointAt(i) > 0xffff ? 2 : 1;
14
+ n++;
15
+ }
16
+ return n;
17
+ };
18
+ /** Splice points (decoded value order): token text, UTF-16 index, path. */
19
+ const scanDecoded = (value) => {
11
20
  REFERENCE_2.lastIndex = 0;
12
- const result = [];
13
- let line = firstLine;
14
- let lineStart = 0;
21
+ const out = [];
22
+ for (const m of value.matchAll(REFERENCE_2)) {
23
+ out.push({
24
+ token: m[0], index: m.index ?? 0,
25
+ ...(m[1] !== undefined ? { documentPath: m[1] } : { partialPath: m[2] }),
26
+ });
27
+ }
28
+ return out;
29
+ };
30
+ /** Physical `(line, offset)` of each token, in source order, from the raw text. */
31
+ const scanPhysical = (raw, firstLine, firstCol, tabAdjust) => {
32
+ REFERENCE_2.lastIndex = 0;
33
+ const out = [];
15
34
  let scanned = 0;
16
- let spanIndex = 0;
17
- for (const match of value.matchAll(REFERENCE_2)) {
18
- const index = match.index ?? 0;
19
- while (scanned < index) {
20
- if (value.charCodeAt(scanned) === 10) {
35
+ let line = firstLine;
36
+ let col = firstCol;
37
+ let lineIndex = 0;
38
+ for (const m of raw.matchAll(REFERENCE_2)) {
39
+ const idx = m.index ?? 0;
40
+ while (scanned < idx) {
41
+ const cp = raw.codePointAt(scanned);
42
+ if (cp === 10) {
21
43
  line++;
22
- lineStart = scanned + 1;
44
+ col = 0;
45
+ lineIndex++;
23
46
  }
24
- scanned++;
47
+ else
48
+ col++;
49
+ scanned += cp > 0xffff ? 2 : 1;
25
50
  }
26
- while (sourceSpans && spanIndex + 1 < sourceSpans.length && sourceSpans[spanIndex + 1].start <= index)
27
- spanIndex++;
28
- const source = sourceSpans?.[spanIndex];
29
- result.push({
30
- token: match[0], index,
31
- line: source?.line ?? line,
32
- offset: source ? source.sourceOffset + index - source.start : index - lineStart,
33
- ...(match[1] !== undefined ? { documentPath: match[1] } : { partialPath: match[2] }),
34
- });
51
+ out.push({ line, offset: col - (tabAdjust?.[lineIndex] ?? 0) });
52
+ }
53
+ return out;
54
+ };
55
+ export const scanReferenceTokens2 = (value, source) => {
56
+ if (!value.includes('${') && !value.includes('$('))
57
+ return NO_TOKENS;
58
+ const decoded = scanDecoded(value);
59
+ if (decoded.length === 0)
60
+ return NO_TOKENS;
61
+ // The raw and decoded scans see the same tokens in the same order —
62
+ // `\#` collapse and `^^` merge never add or remove a `${…}` / `$(…)`.
63
+ const physical = source === undefined
64
+ ? scanPhysical(value, 1, 0)
65
+ : scanPhysical(source.raw ?? value, source.line, source.col, source.tabAdjust);
66
+ if (physical.length !== decoded.length) {
67
+ throw new Error(`Lima internal: reference token count mismatch (raw ${physical.length}, decoded ${decoded.length})`);
35
68
  }
36
- return result;
69
+ return decoded.map((d, i) => ({ ...d, line: physical[i].line, offset: physical[i].offset }));
37
70
  };
@@ -20,71 +20,8 @@
20
20
  * There is no module-level mutable state: diagnostics are collected in a
21
21
  * `ResolutionContext` created fresh per call and threaded explicitly.
22
22
  */
23
- import { type LimaValue } from './value.js';
24
- import { type PositionedValue, type Diagnostic, type InsertedAt, type NativeValue } from './core.js';
23
+ import { type Diagnostic } from './core.js';
25
24
  type Meta = Record<string, unknown>;
26
- /**
27
- * Structural deep copy for document-derived targets — preserves each leaf's
28
- * own line/quoted (§4.2/R-104: still eligible for further resolution) and
29
- * any existing `insertedAt` provenance from an earlier, nested resolution
30
- * (R-112: a reference copied wholesale into a new position can itself
31
- * already contain the result of an inner reference that resolved first —
32
- * both insertion sites remain identifiable participants after the copy).
33
- */
34
- export declare const deepCopyPositioned: (v: PositionedValue) => PositionedValue;
35
- /**
36
- * Wraps a partial's ingested value into the annotated representation, with
37
- * every string leaf marked permanently inert (§3.8: "no traversal into
38
- * partial values" — the resolution phases must never rediscover a
39
- * reference-like substring inside partial content). Called once per partial
40
- * name at ingestion (see the call site), producing the one canonical tree
41
- * every pure reference to that partial retrieves — deep-copying it on every
42
- * such reference (§3.1) is the resolveTree call site's job, not this one.
43
- */
44
- export declare const partialToPositioned: (v: LimaValue, line: number) => PositionedValue;
45
- /**
46
- * References §5/R-112 (nesting depth), §6.2 (node count), and the public
47
- * result shape (native conversion) computed together in one recursive pass
48
- * over the final, post-substitution tree — instead of three independent
49
- * full-tree walks (a depth-only pass, a count-only pass, then
50
- * `toNativeFromPositioned`), each of which would otherwise revisit every
51
- * node of what can be a large, reference-expanded result. Depth attribution
52
- * needs more than a depth number: it needs to know which reference
53
- * insertions lie on the actual deepest path, so the earliest of those (by
54
- * line) can be blamed if the limit is exceeded — ties for "deepest child"
55
- * all count, since a violation can be reached via more than one
56
- * maximal-depth branch. `native` is still built unconditionally even
57
- * though a depth/node-count violation elsewhere in the document means it
58
- * will be discarded and never returned — cheaper than adding a second
59
- * conditional branch to every call site, and the discarded case is the
60
- * rare (error) path, not the one this pass exists to speed up.
61
- *
62
- * Each array/mapping branch tracks a running max depth and its participant
63
- * list in a single loop over its children, instead of first materializing a
64
- * full array of child results and then doing separate `.map()`/`Math.max`/
65
- * `.filter().flatMap()` passes over it — for a wide sibling group (many
66
- * children at the same depth, the common shape for flat documents with many
67
- * top-level references) that would mean an extra full pass per level on top
68
- * of the traversal itself. A running accumulator only ever copies-on-write:
69
- * `.slice()` when a strictly greater depth replaces the running set, plain
70
- * `.push()` onto that fresh copy for a tie at the current max — never a
71
- * mutation of a child's own returned array. Reusing a child's array in
72
- * place would currently be safe (each node is visited exactly once, no
73
- * aliasing left after the pure-reference deep-copy fix), but relying on
74
- * that invariant here has no upside worth the risk in a file that has
75
- * already had one real aliasing bug.
76
- */
77
- export type FinalizedValue = {
78
- native: NativeValue;
79
- nodeCount: number;
80
- depth: number;
81
- deepestParticipants: InsertedAt[];
82
- };
83
- export declare const finalizePositioned: (v: PositionedValue) => FinalizedValue;
84
- /** Earliest (lowest-line) participant, or null when none exist — R-113's "line 1" fallback applies then. */
85
- export declare const earliestParticipant: (participants: InsertedAt[]) => InsertedAt | null;
86
- /** Node-count attribution for the RESOURCE_LIMIT error path: every reference insertion anywhere in the tree contributes to the total. */
87
- export declare const collectAllParticipants: (v: PositionedValue, acc: InsertedAt[]) => void;
88
25
  export type ReferencesOptions = {
89
26
  /** Named values available via `(%key)` references. */
90
27
  partials?: Meta;
@@ -21,9 +21,9 @@
21
21
  * `ResolutionContext` created fresh per call and threaded explicitly.
22
22
  */
23
23
  import { countNodes, canonicalString, codepointLength, ingestPartialValue, PARTIAL_COUNT_LIMIT, PARTIAL_NAME_LENGTH_LIMIT, PARTIAL_NODE_LIMIT, RESULT_NODE_LIMIT, SCALAR_LENGTH_LIMIT, } from './value.js';
24
- import { parseCoreWithPositions, toPlainValue, toNativeFromPositioned, NESTING_DEPTH_LIMIT, } from './core.js';
24
+ import { parseCoreWithPositions, toPlainValue, NESTING_DEPTH_LIMIT, } from './core.js';
25
25
  import { LimaError } from './errors.js';
26
- const emptyMapping = () => Object.create(null);
26
+ import { collectAllParticipants, deepCopyPositioned, earliestParticipant, emptyMapping, finalizePositioned, partialToPositioned, } from './positioned-tree.js';
27
27
  // References §2.1/§2.2/Appendix B grammar, per sigil.
28
28
  const DOC_SEGMENT = '[a-zA-Z0-9_][a-zA-Z0-9_:-]*';
29
29
  const DOC_PATH = `${DOC_SEGMENT}(?:\\.${DOC_SEGMENT})*`;
@@ -102,53 +102,6 @@ const isReferenceFreeP = (v) => {
102
102
  referenceFreeCache.set(v, result);
103
103
  return result;
104
104
  };
105
- /**
106
- * Structural deep copy for document-derived targets — preserves each leaf's
107
- * own line/quoted (§4.2/R-104: still eligible for further resolution) and
108
- * any existing `insertedAt` provenance from an earlier, nested resolution
109
- * (R-112: a reference copied wholesale into a new position can itself
110
- * already contain the result of an inner reference that resolved first —
111
- * both insertion sites remain identifiable participants after the copy).
112
- */
113
- export const deepCopyPositioned = (v) => {
114
- switch (v.kind) {
115
- case 'array': return { kind: 'array', items: v.items.map(deepCopyPositioned), line: v.line, references2Active: v.references2Active, insertedAt: v.insertedAt };
116
- case 'mapping': {
117
- const entries = new Map();
118
- for (const [k, c] of v.entries)
119
- entries.set(k, deepCopyPositioned(c));
120
- return { kind: 'mapping', entries, line: v.line, references2Active: v.references2Active, insertedAt: v.insertedAt };
121
- }
122
- case 'instant': return { kind: 'instant', value: new Date(v.value.getTime()), line: v.line, insertedAt: v.insertedAt };
123
- default: return v;
124
- }
125
- };
126
- /**
127
- * Wraps a partial's ingested value into the annotated representation, with
128
- * every string leaf marked permanently inert (§3.8: "no traversal into
129
- * partial values" — the resolution phases must never rediscover a
130
- * reference-like substring inside partial content). Called once per partial
131
- * name at ingestion (see the call site), producing the one canonical tree
132
- * every pure reference to that partial retrieves — deep-copying it on every
133
- * such reference (§3.1) is the resolveTree call site's job, not this one.
134
- */
135
- export const partialToPositioned = (v, line) => {
136
- switch (v.kind) {
137
- case 'null': return { kind: 'null', line };
138
- case 'bool': return { kind: 'bool', value: v.value, line };
139
- case 'int': return { kind: 'int', value: v.value, line };
140
- case 'float': return { kind: 'float', value: v.value, line };
141
- case 'string': return { kind: 'string', value: v.value, line, quoted: true };
142
- case 'instant': return { kind: 'instant', value: v.value, line };
143
- case 'array': return { kind: 'array', items: v.items.map((i) => partialToPositioned(i, line)), line };
144
- case 'mapping': {
145
- const entries = new Map();
146
- for (const [k, c] of v.entries)
147
- entries.set(k, partialToPositioned(c, line));
148
- return { kind: 'mapping', entries, line };
149
- }
150
- }
151
- };
152
105
  /**
153
106
  * Recursively resolves reference-shaped string leaves in `node` against
154
107
  * `lookup` — the single function both resolution phases share (§4.1's live
@@ -282,70 +235,6 @@ const resolveTree = (node, lookup, partials, ctx) => {
282
235
  }
283
236
  return node; // null/bool/int/float/instant — nothing to resolve
284
237
  };
285
- export const finalizePositioned = (v) => {
286
- const own = v.insertedAt ? [v.insertedAt] : [];
287
- if (v.kind === 'array') {
288
- if (v.items.length === 0)
289
- return { native: [], nodeCount: 1, depth: 1, deepestParticipants: own };
290
- const native = new Array(v.items.length);
291
- let nodeCount = 1;
292
- let maxDepth = -1;
293
- let childParticipants = [];
294
- for (let i = 0; i < v.items.length; i++) {
295
- const r = finalizePositioned(v.items[i]);
296
- native[i] = r.native;
297
- nodeCount += r.nodeCount;
298
- if (r.depth > maxDepth) {
299
- maxDepth = r.depth;
300
- childParticipants = r.deepestParticipants.slice();
301
- }
302
- else if (r.depth === maxDepth) {
303
- for (const p of r.deepestParticipants)
304
- childParticipants.push(p);
305
- }
306
- }
307
- return { native, nodeCount, depth: 1 + maxDepth, deepestParticipants: own.length ? own.concat(childParticipants) : childParticipants };
308
- }
309
- if (v.kind === 'mapping') {
310
- if (v.entries.size === 0)
311
- return { native: emptyMapping(), nodeCount: 1, depth: 1, deepestParticipants: own };
312
- const native = emptyMapping();
313
- let nodeCount = 1;
314
- let maxDepth = -1;
315
- let childParticipants = [];
316
- for (const [k, c] of v.entries) {
317
- const r = finalizePositioned(c);
318
- native[k] = r.native;
319
- nodeCount += r.nodeCount;
320
- if (r.depth > maxDepth) {
321
- maxDepth = r.depth;
322
- childParticipants = r.deepestParticipants.slice();
323
- }
324
- else if (r.depth === maxDepth) {
325
- for (const p of r.deepestParticipants)
326
- childParticipants.push(p);
327
- }
328
- }
329
- return { native: native, nodeCount, depth: 1 + maxDepth, deepestParticipants: own.length ? own.concat(childParticipants) : childParticipants };
330
- }
331
- // Scalar leaf: null/bool/int/float/string/instant. No further recursion,
332
- // so plain toNativeFromPositioned is exactly the single-node conversion
333
- // needed here — reused rather than duplicated.
334
- return { native: toNativeFromPositioned(v), nodeCount: 1, depth: 0, deepestParticipants: own };
335
- };
336
- /** Earliest (lowest-line) participant, or null when none exist — R-113's "line 1" fallback applies then. */
337
- export const earliestParticipant = (participants) => participants.length === 0 ? null : participants.reduce((a, b) => (b.line < a.line ? b : a));
338
- /** Node-count attribution for the RESOURCE_LIMIT error path: every reference insertion anywhere in the tree contributes to the total. */
339
- export const collectAllParticipants = (v, acc) => {
340
- if (v.insertedAt)
341
- acc.push(v.insertedAt);
342
- if (v.kind === 'array')
343
- for (const item of v.items)
344
- collectAllParticipants(item, acc);
345
- if (v.kind === 'mapping')
346
- for (const c of v.entries.values())
347
- collectAllParticipants(c, acc);
348
- };
349
238
  export const parseReferences = (frontMatter, options) => {
350
239
  const strict = options?.strict ?? false;
351
240
  const rawPartials = options?.partials ?? {};
@@ -8,6 +8,8 @@ export type ParseOptions = {
8
8
  onWarning?: (diagnostic: Diagnostic) => void;
9
9
  };
10
10
  export declare const parse: <T extends Record<string, unknown> = Meta>(frontMatter: string, options?: ParseOptions) => T;
11
+ /** Test oracle: exercises the resolver without its dependency-result cache. */
12
+ export declare const __parseWithoutResolveCacheForTest: <T extends Record<string, unknown> = Meta>(frontMatter: string, options?: ParseOptions) => T;
11
13
  /** @deprecated Use parse(). */
12
14
  export declare const parseReferences: typeof parse;
13
15
  export {};
@@ -3,11 +3,19 @@ import { canonicalString, codepointLength, countNodes, ingestPartialValue, PARTI
3
3
  import { NESTING_DEPTH_LIMIT, parseCore, parseCoreWithPositions, toPlainValue, } from './core.js';
4
4
  import { hasActiveReferences2 } from './scalars.js';
5
5
  import { LimaError } from './errors.js';
6
- import { collectAllParticipants, deepCopyPositioned, earliestParticipant, finalizePositioned, partialToPositioned, } from './references.js';
7
- const emptyMapping = () => Object.create(null);
6
+ import { collectAllParticipants, deepCopyPositioned, earliestParticipant, emptyMapping, finalizePositioned, partialToPositioned, } from './positioned-tree.js';
8
7
  const PARTIAL_NAME = '[a-zA-Z0-9_][a-zA-Z0-9_:/-]*';
9
8
  const PARTIAL_NAME_RE = new RegExp(`^${PARTIAL_NAME}$`);
10
9
  const MAX_EDGES = 3;
10
+ /** Copies a pure-reference target and stamps the new insertion without
11
+ * discarding provenance already attached to the copied root (R-112). */
12
+ const copyWithInsertion = (target, insertedAt) => {
13
+ const copy = deepCopyPositioned(target);
14
+ const priorInsertions = copy.insertedAt === undefined
15
+ ? copy.priorInsertions
16
+ : [...(copy.priorInsertions ?? []), copy.insertedAt];
17
+ return { ...copy, insertedAt, priorInsertions };
18
+ };
11
19
  const addDiagnostic = (ctx, diagnostic, offset = 0) => {
12
20
  ctx.diagnostics.push({ ...diagnostic, offset });
13
21
  };
@@ -23,8 +31,18 @@ const lookupPath = (root, segments) => {
23
31
  };
24
32
  const documentTarget = (root, path) => {
25
33
  const segments = path.split('.');
26
- const value = root.get(segments[0]);
27
- return value === undefined ? undefined : { root: value, tail: segments.slice(1) };
34
+ let value = root.get(segments[0]);
35
+ if (value === undefined)
36
+ return undefined;
37
+ let index = 1;
38
+ while (index < segments.length && value.kind === 'mapping') {
39
+ const child = value.entries.get(segments[index]);
40
+ if (child === undefined)
41
+ return undefined;
42
+ value = child;
43
+ index++;
44
+ }
45
+ return { root: value, tail: segments.slice(index) };
28
46
  };
29
47
  const lookupPartial = (partials, path) => {
30
48
  const dot = path.indexOf('.');
@@ -65,9 +83,9 @@ const resolveNodeUncached = (node, document, partials, ctx, remainingEdges, stac
65
83
  const match = tokenMatches(item)[0];
66
84
  const token = match?.token ?? item.value;
67
85
  addDiagnostic(ctx, {
68
- code: 'INVALID_REFERENCE_SHAPE', line: item.line, token,
69
- message: `Lima: reference "${token}" resolves to an array, which cannot be inserted as a sequence item at line ${item.line}`,
70
- });
86
+ code: 'INVALID_REFERENCE_SHAPE', line: match?.line ?? item.line, token,
87
+ message: `Lima: reference "${token}" resolves to an array, which cannot be inserted as a sequence item at line ${match?.line ?? item.line}`,
88
+ }, match?.offset ?? 0);
71
89
  return item;
72
90
  }
73
91
  return result.value;
@@ -110,8 +128,10 @@ const resolveNodeUncached = (node, document, partials, ctx, remainingEdges, stac
110
128
  if (target === undefined || stack.has(target))
111
129
  return { value: node, complete: false };
112
130
  if (pure.partialPath !== undefined) {
131
+ // `partialToPositioned` never annotates partial targets: partials are
132
+ // inert and have no source token before this document insertion.
113
133
  return {
114
- value: { ...deepCopyPositioned(target), insertedAt: { line: pure.line, token: pure.token } },
134
+ value: copyWithInsertion(target, { line: pure.line, offset: pure.offset, token: pure.token }),
115
135
  complete: true,
116
136
  };
117
137
  }
@@ -124,7 +144,9 @@ const resolveNodeUncached = (node, document, partials, ctx, remainingEdges, stac
124
144
  if (selected === undefined)
125
145
  return { value: node, complete: false };
126
146
  return {
127
- value: { ...deepCopyPositioned(selected), insertedAt: { line: pure.line, token: pure.token } },
147
+ // `selected` may itself be the root inserted by an earlier reference;
148
+ // both it and this outer copy participate in final-structure errors.
149
+ value: copyWithInsertion(selected, { line: pure.line, offset: pure.offset, token: pure.token }),
128
150
  complete: true,
129
151
  };
130
152
  }
@@ -172,13 +194,24 @@ const resolveNodeUncached = (node, document, partials, ctx, remainingEdges, stac
172
194
  }
173
195
  return { value: { ...node, value: output, references2: unresolvedTokens }, complete };
174
196
  };
175
- /** Successful and failed dependency results are immutable and budget-specific. */
197
+ /**
198
+ * The cache key is complete without `stack`: for a fixed node and edge
199
+ * budget, its outgoing targets and traversal order are fixed by the parsed
200
+ * document. If one of those targets is already on the current stack, that
201
+ * target is on a dependency cycle reachable from this node; entering from a
202
+ * different ancestor cannot make the cycle complete, and the monotonically
203
+ * decreasing edge budget fixes where an overlong path becomes unresolved.
204
+ * Thus `stack` only detects an already-inevitable incomplete dependency; it
205
+ * does not select a different successful value. Top-level calls are excluded
206
+ * (`stack.size > 1`) and cached Resolution trees are immutable, so sharing a
207
+ * successful or incomplete result at the same remaining budget is safe.
208
+ */
176
209
  const resolveNode = (node, document, partials, ctx, remainingEdges, stack) => {
177
- const cacheable = stack.size > 1 && (node.kind === 'array' || node.kind === 'mapping' ||
178
- (isActiveString(node) && tokenMatches(node).length > 0));
179
- if (!cacheable)
210
+ const cache = ctx.cache;
211
+ if (cache === undefined || stack.size <= 1 || !(node.kind === 'array' || node.kind === 'mapping' ||
212
+ (isActiveString(node) && tokenMatches(node).length > 0)))
180
213
  return resolveNodeUncached(node, document, partials, ctx, remainingEdges, stack);
181
- const byBudget = ctx.cache.get(node);
214
+ const byBudget = cache.get(node);
182
215
  const cached = byBudget?.get(remainingEdges);
183
216
  if (cached !== undefined)
184
217
  return cached;
@@ -186,7 +219,7 @@ const resolveNode = (node, document, partials, ctx, remainingEdges, stack) => {
186
219
  if (byBudget !== undefined)
187
220
  byBudget.set(remainingEdges, result);
188
221
  else
189
- ctx.cache.set(node, new Map([[remainingEdges, result]]));
222
+ cache.set(node, new Map([[remainingEdges, result]]));
190
223
  return result;
191
224
  };
192
225
  const validatePartials = (raw) => {
@@ -233,7 +266,7 @@ const scanUnresolved = (node, ctx) => {
233
266
  for (const child of node.entries.values())
234
267
  scanUnresolved(child, ctx);
235
268
  };
236
- export const parse = (frontMatter, options) => {
269
+ const parseInternal = (frontMatter, options, useResolveCache) => {
237
270
  if (options?.mode !== undefined && options.mode !== 'core' && options.mode !== 'references') {
238
271
  throw new TypeError(`Lima: invalid parse mode "${String(options.mode)}"`);
239
272
  }
@@ -250,7 +283,7 @@ export const parse = (frontMatter, options) => {
250
283
  return parseCore(frontMatter, options);
251
284
  }
252
285
  const document = parseCoreWithPositions(frontMatter, { strict: options?.strict ?? false, onWarning: options?.onWarning });
253
- const ctx = { diagnostics: [], cache: new WeakMap() };
286
+ const ctx = { diagnostics: [], ...(useResolveCache ? { cache: new WeakMap() } : {}) };
254
287
  const resolved = new Map();
255
288
  for (const [key, value] of document) {
256
289
  const stack = new Set([value]);
@@ -261,7 +294,10 @@ export const parse = (frontMatter, options) => {
261
294
  scanUnresolved(value, ctx);
262
295
  if (ctx.diagnostics.length > 0) {
263
296
  ctx.diagnostics.sort((a, b) => a.line - b.line || a.offset - b.offset);
264
- throw new LimaError(ctx.diagnostics[0]);
297
+ const winner = ctx.diagnostics[0];
298
+ // §2.4: expose the token's physical position (1-based) — Rust and Go
299
+ // already do; `offset` was internal-only here.
300
+ throw new LimaError({ ...winner, column: winner.offset + 1 });
265
301
  }
266
302
  const finalized = [...resolved].map(([key, value]) => [key, finalizePositioned(value)]);
267
303
  const depth = finalized.length === 0 ? 0 : Math.max(...finalized.map(([, value]) => value.depth));
@@ -270,6 +306,7 @@ export const parse = (frontMatter, options) => {
270
306
  const winner = earliestParticipant(participants);
271
307
  throw new LimaError({
272
308
  code: 'RESOURCE_LIMIT', line: winner?.line ?? 1, token: winner?.token,
309
+ ...(winner?.offset !== undefined ? { column: winner.offset + 1 } : {}),
273
310
  message: `Lima: nesting depth exceeds maximum of ${NESTING_DEPTH_LIMIT} at line ${winner?.line ?? 1}`,
274
311
  });
275
312
  }
@@ -283,6 +320,7 @@ export const parse = (frontMatter, options) => {
283
320
  const winner = earliestParticipant(participants);
284
321
  throw new LimaError({
285
322
  code: 'RESOURCE_LIMIT', line: winner?.line ?? 1, token: winner?.token,
323
+ ...(winner?.offset !== undefined ? { column: winner.offset + 1 } : {}),
286
324
  message: `Lima: result exceeds maximum size of ${RESULT_NODE_LIMIT} total nodes at line ${winner?.line ?? 1}`,
287
325
  });
288
326
  }
@@ -291,5 +329,8 @@ export const parse = (frontMatter, options) => {
291
329
  out[key] = value.native;
292
330
  return out;
293
331
  };
332
+ export const parse = (frontMatter, options) => parseInternal(frontMatter, options, true);
333
+ /** Test oracle: exercises the resolver without its dependency-result cache. */
334
+ export const __parseWithoutResolveCacheForTest = (frontMatter, options) => parseInternal(frontMatter, options, false);
294
335
  /** @deprecated Use parse(). */
295
336
  export const parseReferences = parse;