@limaformat/lima 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,27 @@ carry their own.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.7.1] — 2026-10-31
16
+
17
+ ### Security
18
+
19
+ - A few KB of references could copy millions of nodes before the limit on the result stopped them, and interpolation
20
+ kept growing a value past its limit. Resolution now counts what it copies and stops (References §4.1), with the
21
+ error of an oversized result.
22
+
23
+ ### Fixed
24
+
25
+ - A value or sequence item followed by whitespace other than spaces kept it at the top level: `draft: false` with a no-break space
26
+ after it is now the boolean, as in a block (Core §4).
27
+ - `.0` and `-.0` are zero, not an underflow.
28
+ - A line with an empty key in a block is an unrecognised line.
29
+ - Spaces before a flow value in a sequence item threw an error without a Lima code.
30
+ - Messages no longer quote control or direction characters of the source, and shorten a long line.
31
+
32
+ ### Added
33
+
34
+ - `LimaError` is exported.
35
+
15
36
  ## [0.7.0] — 2026-10-30
16
37
 
17
38
  Lima Core 1.2 and References 2.1: an unquoted key may be written in any
package/dist/block.js CHANGED
@@ -13,7 +13,7 @@ import { stripKeyQuotes, stripComment, stripCommentKeepEscapes, parseQuotedOrTyp
13
13
  import { KEY_GRAMMAR_HINT, specificKeyHint } from './key-hints.js';
14
14
  import { parseFlowOrScalarValue, parseFlowSequence } from './flow.js';
15
15
  import { buildBlockScalar } from './block-scalar.js';
16
- import { LimaError } from './errors.js';
16
+ import { LimaError, printableLine } from './errors.js';
17
17
  import { isTrimWhitespace } from './chars.js';
18
18
  import { BlockCursor } from './block-cursor.js';
19
19
  const DASH_PREFIX_RE = /^-\s+/;
@@ -167,7 +167,7 @@ const cursorAfterDash = (cursor) => {
167
167
  /** Core 1.1 §10.1: a line in a block whose key the key grammar (§5.1) rejects, in strict mode. */
168
168
  const unrecognizedKeyLine = (line, trimmed) => new LimaError({
169
169
  code: 'UNRECOGNIZED_LINE', line,
170
- message: `Lima: unrecognised line at line ${line}: "${trimmed}"${specificKeyHint(trimmed) ?? KEY_GRAMMAR_HINT}`,
170
+ message: `Lima: unrecognised line at line ${line}: "${printableLine(trimmed)}"${specificKeyHint(trimmed) ?? KEY_GRAMMAR_HINT}`,
171
171
  });
172
172
  /** Shared block grammar consuming one mutable physical-line cursor. */
173
173
  const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder, compact = false) => {
@@ -208,7 +208,7 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder, compact =
208
208
  const keyRaw = colonPos !== -1
209
209
  ? trimEndSlice(trimmed, 0, colonPos)
210
210
  : bareKey ? trimEndSlice(trimmed, 0, trimmed.length - 1) : '';
211
- if (keyRaw !== '' && !isValidKey(keyRaw)) {
211
+ if ((keyRaw !== '' || colonPos !== -1) && !isValidKey(keyRaw)) {
212
212
  // §5.1: not a usable key — an unrecognised line, skipped in non-strict mode and an error in strict
213
213
  // mode, as at the top level (Core 1.1 §10.1)
214
214
  if (ctx.strict)
@@ -268,9 +268,7 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder, compact =
268
268
  if (items === null)
269
269
  items = [];
270
270
  const afterDashRaw = cursorAfterDash(cursor);
271
- let afterDash = afterDashRaw;
272
- if (afterDash.includes('#'))
273
- afterDash = stripComment(afterDash);
271
+ const afterDash = afterDashRaw.includes('#') ? stripComment(afterDashRaw) : afterDashRaw.trimEnd();
274
272
  const afterDashCol = wantSource ? cursor.indent + dashPrefixLen(cursor) : 0;
275
273
  const afterDashTab = wantSource && ctx.tabAdjust ? [ctx.tabAdjust[line - 1] ?? 0] : undefined;
276
274
  const dashSource = wantSource ? ({
@@ -331,7 +329,7 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder, compact =
331
329
  // The key sits after `- `, two columns past the dash.
332
330
  const valueCol = wantSource ? afterDashCol + valueStart + leadingWsLen(afterDash.slice(valueStart)) : 0;
333
331
  pendingColumn = cursor.indent + dashPrefixLen(cursor);
334
- pendingItem = builder.createMappingWith(key, inlineOrBlockScalar(raw, trimSlice(afterDashRaw, valueStart, afterDashRaw.length) === '|', pendingColumn, line, valueCol, wantSource ? afterDashRaw.slice(valueStart) : raw, cursor, ctx, builder));
332
+ pendingItem = builder.createMappingWith(key, inlineOrBlockScalar(raw, trimSlice(afterDashRaw, valueStart, afterDashRaw.length) === '|', pendingColumn, line, valueCol, wantSource ? trimSlice(afterDashRaw, valueStart, afterDashRaw.length) : raw, cursor, ctx, builder));
335
333
  // the item's later keys at its key column; any other line is the block loop's (§7.2)
336
334
  while (cursor.valid && cursor.indent === pendingColumn) {
337
335
  const continuationLine = baseLine + cursor.lineIndex;
@@ -399,7 +397,7 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder, compact =
399
397
  const keyRaw = colonPos !== -1
400
398
  ? trimEndSlice(trimmed, 0, colonPos)
401
399
  : bareKey ? trimEndSlice(trimmed, 0, trimmed.length - 1) : '';
402
- if (keyRaw !== '' && !isValidKey(keyRaw)) {
400
+ if ((keyRaw !== '' || colonPos !== -1) && !isValidKey(keyRaw)) {
403
401
  // §5.1: not a usable key — an unrecognised line, skipped in non-strict mode and an error in strict
404
402
  // mode, as at the top level (Core 1.1 §10.1)
405
403
  if (ctx.strict)
package/dist/core.js CHANGED
@@ -221,7 +221,7 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
221
221
  }
222
222
  const val = frontMatter.slice(rawStart, valueEnd);
223
223
  const hasHash = val.includes('#');
224
- const uncommented = hasHash ? stripComment(val) : val;
224
+ const uncommented = hasHash ? stripComment(val) : val.trimEnd();
225
225
  // The physical anchor keeps `\#` but drops a real comment, so a
226
226
  // `${…}`-shaped token inside a trailing comment is not scanned (§2.4).
227
227
  const source = wantSource ? {
package/dist/errors.d.ts CHANGED
@@ -19,6 +19,8 @@ export interface LimaDiagnostic {
19
19
  partial?: string;
20
20
  path?: string;
21
21
  }
22
+ /** A line of the source for a message: shortened, and no control or direction character reaches a terminal. */
23
+ export declare const printableLine: (text: string) => string;
22
24
  export declare class LimaError extends Error {
23
25
  readonly code: LimaDiagnosticCode;
24
26
  readonly line?: number;
package/dist/errors.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * module directly rather than duplicating it) inspect *why* a parse failed
9
9
  * without re-parsing the message string.
10
10
  */
11
+ /** A line of the source for a message: shortened, and no control or direction character reaches a terminal. */
12
+ export const printableLine = (text) => (text.length > 120 ? `${text.slice(0, 120)}…` : text).replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
11
13
  export class LimaError extends Error {
12
14
  code;
13
15
  line;
package/dist/index.d.ts CHANGED
@@ -12,5 +12,6 @@
12
12
  * retained as its deprecated compatibility alias.
13
13
  */
14
14
  export { parseCore, type CoreOptions, type Diagnostic } from './core.js';
15
+ export { LimaError, type LimaDiagnostic, type LimaDiagnosticCode } from './errors.js';
15
16
  export { parseReferences, parse, type ParseMode, type ParseOptions } from './references2.js';
16
17
  export type ReferencesOptions = import('./references2.js').ParseOptions;
package/dist/index.js CHANGED
@@ -12,4 +12,5 @@
12
12
  * retained as its deprecated compatibility alias.
13
13
  */
14
14
  export { parseCore } from './core.js';
15
+ export { LimaError } from './errors.js';
15
16
  export { parseReferences, parse } from './references2.js';
@@ -1,5 +1,5 @@
1
1
  /** Lima References 2.1 resolver. References 1.0 remains isolated in references.ts. */
2
- import { canonicalString, codepointLength, countNodes, ingestPartialValue, PARTIAL_COUNT_LIMIT, PARTIAL_NAME_LENGTH_LIMIT, PARTIAL_NODE_LIMIT, RESULT_NODE_LIMIT, SCALAR_LENGTH_LIMIT, } from './value.js';
2
+ import { canonicalString, codepointLength, countNodes, ingestPartialValue, PARTIAL_COUNT_LIMIT, PARTIAL_NAME_LENGTH_LIMIT, PARTIAL_NODE_LIMIT, RESOLUTION_COPY_LIMIT, RESULT_NODE_LIMIT, SCALAR_LENGTH_LIMIT, } from './value.js';
3
3
  import { isValidUnquotedKey } from './key-grammar.js';
4
4
  import { validateDocument } from './document.js';
5
5
  import { NESTING_DEPTH_LIMIT, parseCoreValidated, parseCoreWithPositions, toPlainValue, } from './core.js';
@@ -13,13 +13,44 @@ const TARGET_EDGE_BUDGET = MAX_EDGES - 1;
13
13
  const referenceResourceStatuses = new WeakMap();
14
14
  /** Copies a pure-reference target and stamps the new insertion without
15
15
  * discarding provenance already attached to the copied root (R-112). */
16
- const copyWithInsertion = (target, insertedAt) => {
16
+ const copyWithInsertion = (target, insertedAt, ctx) => {
17
+ const earliest = ctx.earliestInsertion;
18
+ ctx.earliestInsertion = earliest === null ? insertedAt : earliestParticipant([earliest, insertedAt]);
19
+ if (!ctx.overflowed) {
20
+ ctx.copied += countPositioned(target);
21
+ ctx.overflowed = ctx.copied > RESOLUTION_COPY_LIMIT;
22
+ }
23
+ // the result is lost by now: resolution only goes on to see the earliest insertion
24
+ if (ctx.overflowed)
25
+ return { kind: 'null', line: 0 };
17
26
  const copy = deepCopyPositioned(target);
18
27
  const priorInsertions = copy.insertedAt === undefined
19
28
  ? copy.priorInsertions
20
29
  : [...(copy.priorInsertions ?? []), copy.insertedAt];
21
30
  return { ...copy, insertedAt, priorInsertions };
22
31
  };
32
+ const countPositioned = (v) => {
33
+ let count = 1;
34
+ if (v.kind === 'array')
35
+ for (const item of v.items)
36
+ count += countPositioned(item);
37
+ if (v.kind === 'mapping')
38
+ for (const child of v.entries.values())
39
+ count += countPositioned(child);
40
+ return count;
41
+ };
42
+ const newContext = (cache) => ({
43
+ diagnostics: [], copied: 0, overflowed: false, earliestInsertion: null, ...(cache ? { cache } : {}),
44
+ });
45
+ /** The error of a document whose references would copy past RESOLUTION_COPY_LIMIT: that of an oversized result. */
46
+ const overflowError = (ctx) => {
47
+ const winner = ctx.earliestInsertion;
48
+ return new LimaError({
49
+ code: 'RESOURCE_LIMIT', line: winner?.line ?? 1, token: winner?.token,
50
+ ...(winner?.offset !== undefined ? { column: winner.offset + 1 } : {}),
51
+ message: `Lima: result exceeds maximum size of ${RESULT_NODE_LIMIT} total nodes at line ${winner?.line ?? 1}`,
52
+ });
53
+ };
23
54
  const addDiagnostic = (ctx, diagnostic, offset = 0) => {
24
55
  ctx.diagnostics.push({ ...diagnostic, offset });
25
56
  };
@@ -58,10 +89,10 @@ export const resolveDocumentReferenceTarget = (parsed, path) => {
58
89
  return { status: resourceStatus };
59
90
  if (resourceStatus === 'invalid')
60
91
  return { status: 'unresolved' };
61
- const ctx = { diagnostics: [], cache: new WeakMap() };
92
+ const ctx = newContext(new WeakMap());
62
93
  const stack = new Set([target.root]);
63
94
  const resolved = resolveNode(target.root, document, new Map(), ctx, TARGET_EDGE_BUDGET, stack);
64
- if (!resolved.complete || ctx.diagnostics.length > 0)
95
+ if (!resolved.complete || ctx.overflowed || ctx.diagnostics.length > 0)
65
96
  return { status: 'unresolved' };
66
97
  const selected = lookupPath(resolved.value, target.tail);
67
98
  return selected === undefined
@@ -116,9 +147,9 @@ const documentReferenceResourceStatus = (parsed) => {
116
147
  const cached = referenceResourceStatuses.get(document);
117
148
  if (cached !== undefined)
118
149
  return cached;
119
- const ctx = { diagnostics: [], cache: new WeakMap() };
150
+ const ctx = newContext(new WeakMap());
120
151
  const resolved = resolveDocument(document, new Map(), ctx);
121
- let valid = !ctx.diagnostics.some((diagnostic) => diagnostic.code === 'RESOURCE_LIMIT');
152
+ let valid = !ctx.overflowed && !ctx.diagnostics.some((diagnostic) => diagnostic.code === 'RESOURCE_LIMIT');
122
153
  if (valid) {
123
154
  try {
124
155
  assertResolvedDocumentResourceLimits(resolved);
@@ -220,7 +251,7 @@ const resolveNodeUncached = (node, document, partials, ctx, remainingEdges, stac
220
251
  // `partialToPositioned` never annotates partial targets: partials are
221
252
  // inert and have no source token before this document insertion.
222
253
  return {
223
- value: copyWithInsertion(target, { line: pure.line, offset: pure.offset, token: pure.token }),
254
+ value: copyWithInsertion(target, { line: pure.line, offset: pure.offset, token: pure.token }, ctx),
224
255
  complete: true,
225
256
  };
226
257
  }
@@ -235,7 +266,7 @@ const resolveNodeUncached = (node, document, partials, ctx, remainingEdges, stac
235
266
  return {
236
267
  // `selected` may itself be the root inserted by an earlier reference;
237
268
  // both it and this outer copy participate in final-structure errors.
238
- value: copyWithInsertion(selected, { line: pure.line, offset: pure.offset, token: pure.token }),
269
+ value: copyWithInsertion(selected, { line: pure.line, offset: pure.offset, token: pure.token }, ctx),
239
270
  complete: true,
240
271
  };
241
272
  }
@@ -280,6 +311,8 @@ const resolveNodeUncached = (node, document, partials, ctx, remainingEdges, stac
280
311
  code: 'RESOURCE_LIMIT', line: first.line, token: first.token,
281
312
  message: `Lima: scalar exceeds maximum length of ${SCALAR_LENGTH_LIMIT} code points at line ${first.line}`,
282
313
  }, first.offset);
314
+ // the document has failed; a value that holds this one still exceeds the limit, and none grows further
315
+ output = output.slice(0, 2 * (SCALAR_LENGTH_LIMIT + 1));
283
316
  }
284
317
  return { value: { ...node, value: output, references2: unresolvedTokens }, complete };
285
318
  };
@@ -373,8 +406,10 @@ const parseInternal = (frontMatter, options, useResolveCache) => {
373
406
  return parseCoreValidated(frontMatter, options);
374
407
  }
375
408
  const document = parseCoreWithPositions(frontMatter, { strict: options?.strict ?? false, onWarning: options?.onWarning });
376
- const ctx = { diagnostics: [], ...(useResolveCache ? { cache: new WeakMap() } : {}) };
409
+ const ctx = newContext(useResolveCache ? new WeakMap() : undefined);
377
410
  const resolved = resolveDocument(document, partials, ctx);
411
+ if (ctx.overflowed)
412
+ throw overflowError(ctx);
378
413
  if (options?.strict)
379
414
  for (const value of resolved.values())
380
415
  scanUnresolved(value, ctx);
package/dist/scalars.js CHANGED
@@ -187,7 +187,7 @@ const parseDateUTC = (str, strict = false, line = 0) => {
187
187
  const NUMBER_RE = /^-?(?:(?:0|[1-9][0-9]*)(?:\.[0-9]+)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/;
188
188
  const DATE_PRE_RE = /\d[\d\-:.\/a-zA-Z]{4,}/;
189
189
  const isFloatForm = (str) => str.includes('.') || str.includes('e') || str.includes('E');
190
- const isZeroLiteral = (str) => /^0+(\.0+)?$/.test(str.replace(/^-/, '').split(/[eE]/)[0]);
190
+ const isZeroLiteral = (str) => /^(?:0+(?:\.0+)?|\.0+)$/.test(str.replace(/^-/, '').split(/[eE]/)[0]);
191
191
  export const NO_SPAN_VALUE = Symbol('NO_SPAN_VALUE');
192
192
  /**
193
193
  * Parses allocation-free scalar forms whose complete grammar can be proven
package/dist/structure.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * these lines, as Core 1.0 did in both modes.
16
16
  */
17
17
  import { isTrimWhitespace } from './chars.js';
18
- import { LimaError } from './errors.js';
18
+ import { LimaError, printableLine } from './errors.js';
19
19
  import { isValidUnquotedKey } from './key-grammar.js';
20
20
  import { KEY_GRAMMAR_HINT, specificKeyHint } from './key-hints.js';
21
21
  /**
@@ -59,7 +59,7 @@ export function checkRangeLines(frontMatter, start, end, line, owner, valueStart
59
59
  const text = frontMatter.slice(pos, stop);
60
60
  throw new LimaError({
61
61
  code: 'UNRECOGNIZED_LINE', line: current,
62
- message: `Lima: unrecognised line at line ${current}: "${text}"${hint(text)}`,
62
+ message: `Lima: unrecognised line at line ${current}: "${printableLine(text)}"${hint(text)}`,
63
63
  });
64
64
  }
65
65
  if (owner === 'mapping' && base === -1)
package/dist/value.d.ts CHANGED
@@ -60,6 +60,12 @@ export declare const PARTIAL_COUNT_LIMIT = 128;
60
60
  export declare const PARTIAL_NAME_LENGTH_LIMIT = 128;
61
61
  export declare const PARTIAL_NODE_LIMIT = 4096;
62
62
  export declare const RESULT_NODE_LIMIT = 65536;
63
+ /**
64
+ * Nodes a resolution may copy (References §4.1: limits are validated during expansion). Each value is resolved
65
+ * for at most four edge budgets, so a document within RESULT_NODE_LIMIT stays far below this. Past it the result
66
+ * cannot fit, and copying on would only fill the memory: a few KB of references can reach millions of nodes.
67
+ */
68
+ export declare const RESOLUTION_COPY_LIMIT: number;
63
69
  export declare const SCALAR_LENGTH_LIMIT = 16384;
64
70
  /**
65
71
  * Counts Unicode code points (what every length-limit check in this
package/dist/value.js CHANGED
@@ -63,6 +63,12 @@ export const PARTIAL_COUNT_LIMIT = 128;
63
63
  export const PARTIAL_NAME_LENGTH_LIMIT = 128;
64
64
  export const PARTIAL_NODE_LIMIT = 4096;
65
65
  export const RESULT_NODE_LIMIT = 65536;
66
+ /**
67
+ * Nodes a resolution may copy (References §4.1: limits are validated during expansion). Each value is resolved
68
+ * for at most four edge budgets, so a document within RESULT_NODE_LIMIT stays far below this. Past it the result
69
+ * cannot fit, and copying on would only fill the memory: a few KB of references can reach millions of nodes.
70
+ */
71
+ export const RESOLUTION_COPY_LIMIT = 16 * RESULT_NODE_LIMIT;
66
72
  export const SCALAR_LENGTH_LIMIT = 16384;
67
73
  /**
68
74
  * Counts Unicode code points (what every length-limit check in this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limaformat/lima",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Lima frontmatter parser — LIMA Is Metadata Annotation",
5
5
  "keywords": ["frontmatter", "parser", "yaml", "markdown", "metadata"],
6
6
  "homepage": "https://limaformat.dev",