@cldmv/eslint-plugin-jsonv 1.0.11 → 1.0.13

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/dist/index.mjs CHANGED
@@ -12,7 +12,10 @@
12
12
  * - Reports parse errors with accurate source locations
13
13
  * - Supports all ES2011-2025 features (JSON5, binary/octal literals, BigInt, numeric separators, etc.)
14
14
  * - Detects internal reference errors (circular refs, undefined refs)
15
- * - Configurable year-based feature detection
15
+ * - Configurable language options: target ES year, parse mode, strict BigInt and octal checks,
16
+ * and internal reference resolution
17
+ * - Exposes a positioned AST (objects, arrays, properties, literals, references, templates),
18
+ * comments and tokens, so rules can select nodes and inline `eslint-disable` comments work
16
19
  *
17
20
  * @example
18
21
  * // In eslint.config.mjs
@@ -28,14 +31,262 @@
28
31
  * ];
29
32
  */
30
33
 
31
- // Use proper package import - package is copied to node_modules during build
32
- import { JsonvSyntaxError, parseWithOptions } from "@cldmv/jsonv/parser";
34
+ import { JsonvReferenceError, JsonvSyntaxError, parseToAst, parseWithOptions } from "@cldmv/jsonv/parser";
35
+ import { ConfigCommentParser, Directive, TextSourceCodeBase, VisitNodeStep } from "@eslint/plugin-kit";
33
36
 
34
37
  import { existsSync } from "node:fs";
35
38
  import { createRequire } from "node:module";
36
39
  import { dirname, join } from "node:path";
37
40
  import { fileURLToPath } from "node:url";
38
41
 
42
+ //-----------------------------------------------------------------------------
43
+ // Types
44
+ //
45
+ // The language, its SourceCode and the plugin are typed against @eslint/core's
46
+ // generics (`Language`, `TextSourceCode`, `OkParseResult`, `Plugin`, ...), the same
47
+ // way @eslint/json types its JSON language. `tsc -p .configs/tsconfig.types.json`
48
+ // type-checks this JSDoc (`checkJs`) and emits it as `dist/index.d.mts`.
49
+ //-----------------------------------------------------------------------------
50
+
51
+ /**
52
+ * @import { ConfigObject, File, FileError, FileProblem, Language, OkParseResult, ParseResult, Plugin, RulesConfig, RuleVisitor, SourceLocation, SourceRange } from "@eslint/core";
53
+ * @import { CustomRuleDefinitionType, CustomRuleTypeDefinitions, CustomRuleVisitorWithExit, DirectiveType } from "@eslint/plugin-kit";
54
+ * @import { AstResult, SourceLocation as JsonvParserLocation, TokenType } from "@cldmv/jsonv/parser";
55
+ */
56
+
57
+ /**
58
+ * The ES years the `year` language option accepts, matching @cldmv/jsonv's `ParseOptions["year"]`.
59
+ *
60
+ * @public
61
+ * @typedef {2011 | 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025} JsonvYear
62
+ */
63
+
64
+ /**
65
+ * The parse modes the `mode` language option accepts, matching @cldmv/jsonv's `ParseOptions["mode"]`:
66
+ * `"jsonv"` (every jsonv feature of the selected year), `"json5"` (JSON5 only) or `"json"` (strict JSON).
67
+ *
68
+ * @public
69
+ * @typedef {"jsonv" | "json5" | "json"} JsonvMode
70
+ */
71
+
72
+ /**
73
+ * Language options for `language: "jsonv/jsonv"`. Each one is forwarded to @cldmv/jsonv's
74
+ * `parseWithOptions()` and `parseToAst()`. Any other key is rejected by `validateLanguageOptions`,
75
+ * so the type is closed.
76
+ *
77
+ * @public
78
+ * @typedef {object} JsonvLanguageOptions
79
+ * @property {JsonvYear} [year] The ES year whose jsonv features are allowed. Defaults to `2025`.
80
+ * @property {JsonvMode} [mode] The parse mode. Defaults to `"jsonv"`.
81
+ * @property {boolean} [strictBigInt] When `true`, an integer outside the safe range without an `n` suffix is an error instead of
82
+ * being converted to a BigInt. Defaults to `false`.
83
+ * @property {boolean} [strictOctal] When `true`, a legacy octal literal such as `0755` is an error (`0o755` is still allowed).
84
+ * Defaults to `false`.
85
+ * @property {boolean} [allowInternalReferences] When `false`, internal references are left unresolved, so an undefined or
86
+ * circular reference is no longer reported. Defaults to `true`.
87
+ */
88
+
89
+ /**
90
+ * A `Literal`: string, number, BigInt, boolean, `null`, `Infinity` or `NaN`, with its source text in `raw`.
91
+ *
92
+ * @public
93
+ * @typedef {object} JsonvLiteral
94
+ * @property {"Literal"} type The node type.
95
+ * @property {string | number | bigint | boolean | null} value The evaluated value.
96
+ * @property {string} raw The source text.
97
+ * @property {string} [bigint] The BigInt digits, for a BigInt literal.
98
+ * @property {SourceLocation} loc The location (1-based lines and columns).
99
+ * @property {SourceRange} range The `[start, end)` source offsets.
100
+ */
101
+
102
+ /**
103
+ * An `Identifier`: an unquoted key, or an internal reference such as `backup: port`.
104
+ *
105
+ * @public
106
+ * @typedef {object} JsonvIdentifier
107
+ * @property {"Identifier"} type The node type.
108
+ * @property {string} name The identifier text.
109
+ * @property {SourceLocation} loc The location (1-based lines and columns).
110
+ * @property {SourceRange} range The `[start, end)` source offsets.
111
+ */
112
+
113
+ /**
114
+ * An `ObjectExpression`: `{ ... }`.
115
+ *
116
+ * @public
117
+ * @typedef {object} JsonvObjectExpression
118
+ * @property {"ObjectExpression"} type The node type.
119
+ * @property {JsonvProperty[]} properties The properties, in source order.
120
+ * @property {SourceLocation} loc The location (1-based lines and columns).
121
+ * @property {SourceRange} range The `[start, end)` source offsets.
122
+ */
123
+
124
+ /**
125
+ * A `Property`: `key: value`. The key is a `Literal` (quoted or numeric key) or an `Identifier` (unquoted key).
126
+ *
127
+ * @public
128
+ * @typedef {object} JsonvProperty
129
+ * @property {"Property"} type The node type.
130
+ * @property {JsonvLiteral | JsonvIdentifier} key The key.
131
+ * @property {JsonvExpression} value The value.
132
+ * @property {boolean} computed Always `false` in jsonv.
133
+ * @property {SourceLocation} loc The location (1-based lines and columns).
134
+ * @property {SourceRange} range The `[start, end)` source offsets.
135
+ */
136
+
137
+ /**
138
+ * An `ArrayExpression`: `[ ... ]`.
139
+ *
140
+ * @public
141
+ * @typedef {object} JsonvArrayExpression
142
+ * @property {"ArrayExpression"} type The node type.
143
+ * @property {JsonvExpression[]} elements The elements, in source order.
144
+ * @property {SourceLocation} loc The location (1-based lines and columns).
145
+ * @property {SourceRange} range The `[start, end)` source offsets.
146
+ */
147
+
148
+ /**
149
+ * A `MemberExpression`: a dotted internal reference such as `server.port`.
150
+ *
151
+ * @public
152
+ * @typedef {object} JsonvMemberExpression
153
+ * @property {"MemberExpression"} type The node type.
154
+ * @property {JsonvExpression} object The object being accessed.
155
+ * @property {JsonvIdentifier} property The accessed property.
156
+ * @property {false} computed Always `false`.
157
+ * @property {SourceLocation} loc The location (1-based lines and columns).
158
+ * @property {SourceRange} range The `[start, end)` source offsets.
159
+ */
160
+
161
+ /**
162
+ * A `TemplateElement`: one string segment of a template literal.
163
+ *
164
+ * @public
165
+ * @typedef {object} JsonvTemplateElement
166
+ * @property {"TemplateElement"} type The node type.
167
+ * @property {{raw: string, cooked: string}} value The raw and cooked segment text.
168
+ * @property {boolean} tail Whether this is the last segment.
169
+ * @property {SourceLocation} loc The location (1-based lines and columns).
170
+ * @property {SourceRange} range The `[start, end)` source offsets.
171
+ */
172
+
173
+ /**
174
+ * A `TemplateLiteral`: a backtick string and its `${...}` interpolations.
175
+ *
176
+ * @public
177
+ * @typedef {object} JsonvTemplateLiteral
178
+ * @property {"TemplateLiteral"} type The node type.
179
+ * @property {JsonvTemplateElement[]} quasis The string segments.
180
+ * @property {JsonvExpression[]} expressions The interpolated expressions.
181
+ * @property {SourceLocation} loc The location (1-based lines and columns).
182
+ * @property {SourceRange} range The `[start, end)` source offsets.
183
+ */
184
+
185
+ /**
186
+ * Any value node.
187
+ *
188
+ * @public
189
+ * @typedef {JsonvLiteral | JsonvObjectExpression | JsonvArrayExpression | JsonvIdentifier | JsonvTemplateLiteral | JsonvMemberExpression} JsonvExpression
190
+ */
191
+
192
+ /**
193
+ * A `Line` (`// ...`) or `Block` (`/* ... *\/`) comment. `value` excludes the delimiters; `loc` and `range` include them.
194
+ *
195
+ * @public
196
+ * @typedef {object} JsonvComment
197
+ * @property {"Line" | "Block"} type The comment kind.
198
+ * @property {string} value The comment text without its delimiters.
199
+ * @property {SourceLocation} loc The location (1-based lines and columns).
200
+ * @property {SourceRange} range The `[start, end)` source offsets.
201
+ */
202
+
203
+ /**
204
+ * A token (comments and the end-of-file token are not included).
205
+ *
206
+ * @public
207
+ * @typedef {object} JsonvToken
208
+ * @property {`${TokenType}`} type The token type, e.g. `"{"`, `"String"`, `"Identifier"`, `"TemplateHead"`.
209
+ * @property {string | number | bigint | boolean | null} value The token's value.
210
+ * @property {string} raw The source text.
211
+ * @property {SourceLocation} loc The location (1-based lines and columns).
212
+ * @property {SourceRange} range The `[start, end)` source offsets.
213
+ */
214
+
215
+ /**
216
+ * The `Program` root: `body` is the root value; `comments` and `tokens` are in source order.
217
+ *
218
+ * @public
219
+ * @typedef {object} JsonvProgram
220
+ * @property {"Program"} type The node type.
221
+ * @property {JsonvExpression} body The root value.
222
+ * @property {JsonvComment[]} comments The comments.
223
+ * @property {JsonvToken[]} tokens The tokens, excluding comments.
224
+ * @property {SourceLocation} loc The location of the whole document (1-based lines and columns).
225
+ * @property {SourceRange} range The `[start, end)` source offsets of the whole document.
226
+ */
227
+
228
+ /**
229
+ * Any AST node.
230
+ *
231
+ * @public
232
+ * @typedef {JsonvProgram | JsonvExpression | JsonvProperty | JsonvTemplateElement} JsonvNode
233
+ */
234
+
235
+ /**
236
+ * Anything with a location: a node, a token or a comment.
237
+ *
238
+ * @public
239
+ * @typedef {JsonvNode | JsonvToken | JsonvComment} JsonvSyntaxElement
240
+ */
241
+
242
+ /**
243
+ * The jsonv language, typed with @eslint/core's `Language` generics.
244
+ *
245
+ * @public
246
+ * @typedef {Language<{LangOptions: JsonvLanguageOptions, Code: JsonvSourceCode, RootNode: JsonvProgram, Node: JsonvNode}>} JsonvLanguage
247
+ */
248
+
249
+ /**
250
+ * A successful `parse()` result. Besides `ast` it carries `jsonvValue`, the evaluated document
251
+ * (references resolved).
252
+ *
253
+ * @public
254
+ * @typedef {OkParseResult<JsonvProgram>} JsonvOkParseResult
255
+ */
256
+
257
+ /**
258
+ * A `parse()` result: {@link JsonvOkParseResult}, or a failure carrying `errors`.
259
+ *
260
+ * @public
261
+ * @typedef {ParseResult<JsonvProgram>} JsonvParseResult
262
+ */
263
+
264
+ /**
265
+ * The visitor a jsonv rule returns: a handler per node type (plus `:exit` variants), each called
266
+ * with the node and its parent.
267
+ *
268
+ * @public
269
+ * @typedef {RuleVisitor & CustomRuleVisitorWithExit<{
270
+ * Program?: (node: JsonvProgram) => void,
271
+ * ObjectExpression?: (node: JsonvObjectExpression, parent?: JsonvNode) => void,
272
+ * Property?: (node: JsonvProperty, parent?: JsonvObjectExpression) => void,
273
+ * ArrayExpression?: (node: JsonvArrayExpression, parent?: JsonvNode) => void,
274
+ * Literal?: (node: JsonvLiteral, parent?: JsonvNode) => void,
275
+ * Identifier?: (node: JsonvIdentifier, parent?: JsonvNode) => void,
276
+ * MemberExpression?: (node: JsonvMemberExpression, parent?: JsonvNode) => void,
277
+ * TemplateLiteral?: (node: JsonvTemplateLiteral, parent?: JsonvNode) => void,
278
+ * TemplateElement?: (node: JsonvTemplateElement, parent?: JsonvTemplateLiteral) => void
279
+ * }>} JsonvRuleVisitor
280
+ */
281
+
282
+ /**
283
+ * A rule for jsonv files. `Options` sets `RuleOptions`, `MessageIds` and `ExtRuleDocs`.
284
+ *
285
+ * @public
286
+ * @template {Partial<CustomRuleTypeDefinitions>} [Options={}]
287
+ * @typedef {CustomRuleDefinitionType<{LangOptions: JsonvLanguageOptions, Code: JsonvSourceCode, Visitor: JsonvRuleVisitor, Node: JsonvNode}, Options>} JsonvRuleDefinition
288
+ */
289
+
39
290
  /**
40
291
  * Reads this package's own `name` and `version` from its `package.json`.
41
292
  *
@@ -59,14 +310,43 @@ function readPackageMeta() {
59
310
  }
60
311
 
61
312
  /**
62
- * Language options `jsonvLanguage#parse` reads from `context.languageOptions` and
63
- * forwards to `@cldmv/jsonv`'s `parseWithOptions`. Any other key is rejected by
64
- * `validateLanguageOptions` since it would silently do nothing.
313
+ * The default of every language option `jsonvLanguage#parse` reads from `context.languageOptions`
314
+ * and forwards to @cldmv/jsonv's `parseWithOptions()` and `parseToAst()`. The defaults are
315
+ * @cldmv/jsonv's own (`ParseOptions` in its `src/api-types.mts`), except `year`, which jsonv
316
+ * derives from the current date and the plugin pins to the latest supported year.
317
+ *
318
+ * @public
319
+ * @type {Readonly<Required<JsonvLanguageOptions>>}
320
+ */
321
+ const DEFAULT_LANGUAGE_OPTIONS = Object.freeze({
322
+ year: 2025,
323
+ mode: "jsonv",
324
+ strictBigInt: false,
325
+ strictOctal: false,
326
+ allowInternalReferences: true
327
+ });
328
+
329
+ /**
330
+ * The language options the plugin accepts: exactly the keys of {@link DEFAULT_LANGUAGE_OPTIONS}.
331
+ * Any other key is rejected by `validateLanguageOptions`, since it would silently do nothing.
65
332
  *
66
333
  * @public
67
334
  * @type {Set<string>}
68
335
  */
69
- const SUPPORTED_LANGUAGE_OPTIONS = new Set(["year", "strictBigInt"]);
336
+ const SUPPORTED_LANGUAGE_OPTIONS = new Set(Object.keys(DEFAULT_LANGUAGE_OPTIONS));
337
+
338
+ /**
339
+ * @cldmv/jsonv parse options the plugin deliberately does not accept, each with the reason
340
+ * `validateLanguageOptions` gives when a config sets it.
341
+ *
342
+ * @public
343
+ * @type {Readonly<Record<string, string>>}
344
+ */
345
+ const UNSUPPORTED_JSONV_OPTIONS = Object.freeze({
346
+ reviver: "a reviver only transforms the evaluated value, which linting does not use",
347
+ preserveComments: "the AST always carries the comments",
348
+ tolerant: "the plugin controls how parse errors are collected"
349
+ });
70
350
 
71
351
  /**
72
352
  * Valid `year` language option values, matching @cldmv/jsonv's `ParseOptions["year"]`
@@ -77,71 +357,461 @@ const SUPPORTED_LANGUAGE_OPTIONS = new Set(["year", "strictBigInt"]);
77
357
  */
78
358
  const VALID_YEARS = new Set([2011, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025]);
79
359
 
360
+ /**
361
+ * Valid `mode` language option values, matching @cldmv/jsonv's `ParseOptions["mode"]` union.
362
+ *
363
+ * @public
364
+ * @type {Set<string>}
365
+ */
366
+ const VALID_MODES = new Set(["jsonv", "json5", "json"]);
367
+
368
+ /**
369
+ * The language options that take a boolean.
370
+ *
371
+ * @public
372
+ * @type {ReadonlyArray<"strictBigInt" | "strictOctal" | "allowInternalReferences">}
373
+ */
374
+ const BOOLEAN_LANGUAGE_OPTIONS = Object.freeze(["strictBigInt", "strictOctal", "allowInternalReferences"]);
375
+
376
+ /**
377
+ * Child keys of every node type in the AST `jsonvLanguage#parse` produces, in source order
378
+ * where that is defined. The node types and their shapes come from @cldmv/jsonv's own AST
379
+ * (`@cldmv/jsonv/parser`'s `Program`/`Expression` types); the plugin only re-positions them.
380
+ *
381
+ * - `Program` — the document; `body` is the single root value.
382
+ * - `ObjectExpression` — `{ ... }`, with `properties` (`Property` nodes).
383
+ * - `Property` — `key: value`; `key` is a `Literal` (quoted or numeric key) or an `Identifier` (unquoted key).
384
+ * - `ArrayExpression` — `[ ... ]`, with `elements`.
385
+ * - `Literal` — string, number, BigInt, boolean, null, `Infinity`, `NaN` (`raw` holds the source text).
386
+ * - `Identifier` — an unquoted key, or an internal reference such as `backup: port`.
387
+ * - `MemberExpression` — a dotted internal reference such as `server.port`.
388
+ * - `TemplateLiteral` / `TemplateElement` — backtick strings and their `${...}` interpolations.
389
+ *
390
+ * @public
391
+ * @type {Readonly<Record<string, ReadonlyArray<string>>>}
392
+ */
393
+ const VISITOR_KEYS = Object.freeze({
394
+ Program: Object.freeze(["body"]),
395
+ ObjectExpression: Object.freeze(["properties"]),
396
+ Property: Object.freeze(["key", "value"]),
397
+ ArrayExpression: Object.freeze(["elements"]),
398
+ Literal: Object.freeze([]),
399
+ Identifier: Object.freeze([]),
400
+ MemberExpression: Object.freeze(["object", "property"]),
401
+ TemplateLiteral: Object.freeze(["quasis", "expressions"]),
402
+ TemplateElement: Object.freeze([])
403
+ });
404
+
405
+ /**
406
+ * Line terminators, matching how @cldmv/jsonv advances its line counter (`\r\n` as one
407
+ * break, plus lone `\r`, `\n`, U+2028 and U+2029), so `sourceCode.lines` and
408
+ * `getLocFromIndex()` agree with the node positions.
409
+ *
410
+ * @public
411
+ * @type {RegExp}
412
+ */
413
+ const LINE_ENDING_PATTERN = /\r\n|[\r\n\u2028\u2029]/u;
414
+
415
+ /**
416
+ * Matches the comment bodies ESLint treats as inline configuration (same pattern as @eslint/json).
417
+ *
418
+ * @public
419
+ * @type {RegExp}
420
+ */
421
+ const INLINE_CONFIG = /^\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|$)/u;
422
+
423
+ /**
424
+ * Shared parser for `eslint-*` directive comments.
425
+ *
426
+ * @public
427
+ * @type {ConfigCommentParser}
428
+ */
429
+ const commentParser = new ConfigCommentParser();
430
+
431
+ /**
432
+ * Tests whether a value is a positioned @cldmv/jsonv AST node.
433
+ * @public
434
+ * @param {unknown} value - Any property value of a node.
435
+ * @returns {value is {type: string, [key: string]: unknown}} `true` for an object with a string `type` and a `loc`.
436
+ * @example
437
+ * isJsonvNode({ type: "Literal", loc: { start: {}, end: {} } }); // true
438
+ */
439
+ function isJsonvNode(value) {
440
+ return (
441
+ value !== null &&
442
+ typeof value === "object" &&
443
+ "type" in value &&
444
+ typeof value.type === "string" &&
445
+ "loc" in value &&
446
+ typeof value.loc === "object"
447
+ );
448
+ }
449
+
450
+ /**
451
+ * Returns the child keys to visit for a node: its entry in {@link VISITOR_KEYS}, or — for a
452
+ * node type @cldmv/jsonv may add later — every key holding a node or an array of nodes.
453
+ * @public
454
+ * @param {{type: string, [key: string]: unknown}} node - An AST node.
455
+ * @returns {ReadonlyArray<string>} Child property names.
456
+ * @example
457
+ * getChildKeys({ type: "Property", key, value }); // ["key", "value"]
458
+ */
459
+ function getChildKeys(node) {
460
+ return (
461
+ VISITOR_KEYS[node.type] ??
462
+ Object.keys(node).filter((key) => {
463
+ const value = node[key];
464
+ return Array.isArray(value) ? value.some(isJsonvNode) : isJsonvNode(value);
465
+ })
466
+ );
467
+ }
468
+
469
+ /**
470
+ * Converts a @cldmv/jsonv `loc` (1-based lines, 0-based columns, 0-based `offset`s) into the
471
+ * ESLint `loc`/`range` pair for this language. The language declares `lineStart: 1` and
472
+ * `columnStart: 1`, so columns are shifted to 1-based; `range` comes from the offsets.
473
+ * @public
474
+ * @param {JsonvParserLocation} loc - A jsonv location.
475
+ * @returns {{loc: SourceLocation, range: SourceRange}} The ESLint location and range.
476
+ * @example
477
+ * toEslintPosition({ start: { line: 1, column: 0, offset: 0 }, end: { line: 1, column: 3, offset: 3 } });
478
+ * // { loc: { start: { line: 1, column: 1 }, end: { line: 1, column: 4 } }, range: [0, 3] }
479
+ */
480
+ function toEslintPosition(loc) {
481
+ return {
482
+ loc: {
483
+ start: { line: loc.start.line, column: loc.start.column + 1 },
484
+ end: { line: loc.end.line, column: loc.end.column + 1 }
485
+ },
486
+ range: [loc.start.offset, loc.end.offset]
487
+ };
488
+ }
489
+
490
+ /**
491
+ * Copies a jsonv comment or token with ESLint positions (a 1-based `loc` and a `range`).
492
+ * Tokens are never walked for children: some token types share a name with a node type
493
+ * (`Identifier`, `TemplateLiteral`) but carry no child nodes.
494
+ * @public
495
+ * @template {{loc: JsonvParserLocation}} T
496
+ * @param {T} element - A jsonv comment or token.
497
+ * @returns {{[K in keyof T]: K extends "loc" ? SourceLocation : T[K]} & {range: SourceRange}} The converted copy.
498
+ * @example
499
+ * convertLeaf(parseToAst("// note\n1").comments[0]);
500
+ */
501
+ function convertLeaf(element) {
502
+ // TypeScript types a spread of a generic as an intersection (`T & {loc, range}`), which keeps
503
+ // the jsonv `loc` type alongside the ESLint one; the cast states the actual replacement.
504
+ return /** @type {{[K in keyof T]: K extends "loc" ? SourceLocation : T[K]} & {range: SourceRange}} */ ({
505
+ ...element,
506
+ ...toEslintPosition(element.loc)
507
+ });
508
+ }
509
+
510
+ /**
511
+ * Deep-copies a jsonv AST node with ESLint positions: every node gets a 1-based `loc` and a
512
+ * `range`, and child nodes are converted recursively.
513
+ * @public
514
+ * @param {{type: string, loc: JsonvParserLocation, [key: string]: unknown}} node - A jsonv AST node.
515
+ * @returns {{type: string, [key: string]: unknown}} The converted copy.
516
+ * @example
517
+ * convertNode(parseToAst("{ a: 1 }").program.body);
518
+ */
519
+ function convertNode(node) {
520
+ /** @type {{type: string, [key: string]: unknown}} */
521
+ const converted = convertLeaf(node);
522
+ for (const key of getChildKeys(node)) {
523
+ // getChildKeys() only returns keys that hold a node or an array of nodes.
524
+ const child = /** @type {Parameters<typeof convertNode>[0] | Parameters<typeof convertNode>[0][]} */ (node[key]);
525
+ converted[key] = Array.isArray(child) ? child.map(convertNode) : convertNode(child);
526
+ }
527
+ return converted;
528
+ }
529
+
530
+ /**
531
+ * Builds the ESLint AST for a document from @cldmv/jsonv's `parseToAst()` result: the
532
+ * `Program` root (whose `body` is the root value) with positioned `comments` and `tokens`.
533
+ * @public
534
+ * @param {AstResult} astResult - The `parseToAst()` result.
535
+ * @returns {JsonvProgram} The `Program` node.
536
+ * @example
537
+ * buildProgram(parseToAst("// note\n{ a: 1 }"));
538
+ */
539
+ function buildProgram({ program, comments, tokens }) {
540
+ // @cldmv/jsonv positions every node it produces (`ASTNode#loc` is only optional in its type), and
541
+ // convertNode() keeps each node's shape while swapping in ESLint positions, so the copy is a
542
+ // JsonvProgram. The generic, runtime-keyed walk cannot express that per node type, hence the casts.
543
+ const rawProgram = /** @type {Parameters<typeof convertNode>[0]} */ (/** @type {unknown} */ (program));
544
+ const root = /** @type {Omit<JsonvProgram, "comments" | "tokens">} */ (convertNode(rawProgram));
545
+ return {
546
+ ...root,
547
+ comments: comments.map(convertLeaf),
548
+ tokens: tokens.map(convertLeaf)
549
+ };
550
+ }
551
+
552
+ /**
553
+ * SourceCode for a parsed `.jsonv` file, modelled on @eslint/json's `JSONSourceCode`:
554
+ * positions, lines and text helpers come from `TextSourceCodeBase`; this class adds AST
555
+ * traversal, parent lookup and inline configuration (`eslint-disable*` / `eslint-enable`
556
+ * directives and `/* eslint rule: ... *\/` config comments).
557
+ *
558
+ * @public
559
+ * @extends {TextSourceCodeBase<{LangOptions: JsonvLanguageOptions, RootNode: JsonvProgram, SyntaxElementWithLoc: JsonvSyntaxElement, ConfigNode: JsonvComment}>}
560
+ */
561
+ class JsonvSourceCode extends TextSourceCodeBase {
562
+ /**
563
+ * Cached traversal steps.
564
+ * @type {VisitNodeStep[]|undefined}
565
+ */
566
+ #steps;
567
+
568
+ /**
569
+ * Parent of every non-root node.
570
+ * @type {WeakMap<object, JsonvNode>}
571
+ */
572
+ #parents = new WeakMap();
573
+
574
+ /**
575
+ * Cached inline configuration comments.
576
+ * @type {JsonvComment[]|undefined}
577
+ */
578
+ #inlineConfigComments;
579
+
580
+ /**
581
+ * Creates a SourceCode object.
582
+ * @param {Object} options - Options.
583
+ * @param {string} options.text - The source text.
584
+ * @param {JsonvProgram} options.ast - The `Program` node built by `jsonvLanguage#parse`.
585
+ */
586
+ constructor({ text, ast }) {
587
+ super({ text, ast, lineEndingPattern: LINE_ENDING_PATTERN });
588
+ /** @type {JsonvComment[]} Comments in source order. */
589
+ this.comments = ast.comments;
590
+ /** @type {JsonvToken[]} Tokens in source order, excluding comments. */
591
+ this.tokens = ast.tokens;
592
+ /** @type {boolean} Whether the text starts with a byte order mark. */
593
+ this.hasBOM = text.charCodeAt(0) === 0xfeff;
594
+ }
595
+
596
+ /**
597
+ * Returns the source text split into lines (a fresh copy of `lines`).
598
+ * @public
599
+ * @returns {string[]} The lines.
600
+ */
601
+ getLines() {
602
+ return [...this.lines];
603
+ }
604
+
605
+ /**
606
+ * Walks the AST once, recording enter/exit steps and each node's parent.
607
+ * @returns {VisitNodeStep[]} The traversal steps.
608
+ */
609
+ #ensureTraversal() {
610
+ if (!this.#steps) {
611
+ /** @type {VisitNodeStep[]} */
612
+ const steps = [];
613
+ /**
614
+ * @param {JsonvNode} node - The node to visit.
615
+ * @param {JsonvNode|undefined} parent - Its parent (`undefined` for the root).
616
+ */
617
+ const visit = (node, parent) => {
618
+ if (parent) this.#parents.set(node, parent);
619
+ steps.push(new VisitNodeStep({ target: node, phase: 1, args: [node, parent] }));
620
+ for (const key of getChildKeys(node)) {
621
+ // getChildKeys() only returns keys that hold a node or an array of nodes.
622
+ const child = /** @type {JsonvNode | JsonvNode[]} */ (/** @type {{type: string, [key: string]: unknown}} */ (node)[key]);
623
+ for (const item of Array.isArray(child) ? child : [child]) visit(item, node);
624
+ }
625
+ steps.push(new VisitNodeStep({ target: node, phase: 2, args: [node, parent] }));
626
+ };
627
+ visit(this.ast, undefined);
628
+ this.#steps = steps;
629
+ }
630
+ return this.#steps;
631
+ }
632
+
633
+ /**
634
+ * Returns the enter/exit visit steps for every node, depth first in source order.
635
+ * @public
636
+ * @returns {IterableIterator<VisitNodeStep>} The traversal steps.
637
+ */
638
+ traverse() {
639
+ return this.#ensureTraversal().values();
640
+ }
641
+
642
+ /**
643
+ * Returns the parent of a node, or `undefined` for the `Program` root.
644
+ * @public
645
+ * @param {JsonvSyntaxElement} node - An AST node.
646
+ * @returns {JsonvNode|undefined} The parent node.
647
+ */
648
+ getParent(node) {
649
+ this.#ensureTraversal();
650
+ return this.#parents.get(node);
651
+ }
652
+
653
+ /**
654
+ * Returns the comments that hold inline configuration (`eslint`, `eslint-enable`,
655
+ * `eslint-disable`, `eslint-disable-line`, `eslint-disable-next-line`).
656
+ * @public
657
+ * @returns {JsonvComment[]} The inline configuration comments.
658
+ */
659
+ getInlineConfigNodes() {
660
+ if (!this.#inlineConfigComments) {
661
+ this.#inlineConfigComments = this.comments.filter((comment) => INLINE_CONFIG.test(comment.value));
662
+ }
663
+ return this.#inlineConfigComments;
664
+ }
665
+
666
+ /**
667
+ * Returns the `eslint-disable*` / `eslint-enable` directives, plus problems for malformed
668
+ * ones (an `eslint-disable-line` spanning several lines).
669
+ * @public
670
+ * @returns {{problems: FileProblem[], directives: Directive[]}} Directives and problems.
671
+ */
672
+ getDisableDirectives() {
673
+ /** @type {FileProblem[]} */
674
+ const problems = [];
675
+ /** @type {Directive[]} */
676
+ const directives = [];
677
+
678
+ for (const comment of this.getInlineConfigNodes()) {
679
+ // parseDirective() returns undefined only for text that doesn't start with a directive label;
680
+ // every comment here matched INLINE_CONFIG, which requires one.
681
+ const { label, value, justification } = /** @type {NonNullable<ReturnType<ConfigCommentParser["parseDirective"]>>} */ (
682
+ commentParser.parseDirective(comment.value)
683
+ );
684
+
685
+ if (label === "eslint-disable-line" && comment.loc.start.line !== comment.loc.end.line) {
686
+ problems.push({ ruleId: null, message: `${label} comment should not span multiple lines.`, loc: comment.loc });
687
+ continue;
688
+ }
689
+
690
+ if (label !== "eslint") {
691
+ // The comment matched INLINE_CONFIG, so any label other than "eslint" is an
692
+ // `eslint-disable*` / `eslint-enable` label, i.e. a DirectiveType once unprefixed.
693
+ const type = /** @type {DirectiveType} */ (label.slice("eslint-".length));
694
+ directives.push(new Directive({ type, node: comment, value, justification }));
695
+ }
696
+ }
697
+
698
+ return { problems, directives };
699
+ }
700
+
701
+ /**
702
+ * Returns the rule configurations from `/* eslint rule: ... *\/` comments, plus problems for
703
+ * ones that fail to parse.
704
+ * @public
705
+ * @returns {{problems: FileProblem[], configs: Array<{config: {rules: RulesConfig}, loc: SourceLocation}>}} Configs and problems.
706
+ */
707
+ applyInlineConfig() {
708
+ /** @type {FileProblem[]} */
709
+ const problems = [];
710
+ /** @type {Array<{config: {rules: RulesConfig}, loc: SourceLocation}>} */
711
+ const configs = [];
712
+
713
+ for (const comment of this.getInlineConfigNodes()) {
714
+ // Defined for the same reason as in getDisableDirectives().
715
+ const { label, value } = /** @type {NonNullable<ReturnType<ConfigCommentParser["parseDirective"]>>} */ (
716
+ commentParser.parseDirective(comment.value)
717
+ );
718
+ if (label !== "eslint") continue;
719
+
720
+ const parseResult = commentParser.parseJSONLikeConfig(value);
721
+ if (parseResult.ok) {
722
+ configs.push({ config: { rules: parseResult.config }, loc: comment.loc });
723
+ } else {
724
+ problems.push({ ruleId: null, message: parseResult.error.message, loc: comment.loc });
725
+ }
726
+ }
727
+
728
+ return { problems, configs };
729
+ }
730
+ }
731
+
80
732
  /**
81
733
  * Language definition for jsonv files
82
734
  *
83
735
  * @public
84
- * @type {Object}
736
+ * @type {JsonvLanguage}
85
737
  */
86
738
  const jsonvLanguage = {
87
739
  /** File type - must be "text" for text-based languages */
88
740
  fileType: "text",
89
741
  /** Line numbering starts at 1 */
90
742
  lineStart: 1,
91
- /** Column numbering starts at 1 */
743
+ /** Column numbering starts at 1 (node `loc` columns are 1-based) */
92
744
  columnStart: 1,
93
745
  /** Property name for node types */
94
746
  nodeTypeKey: "type",
747
+ /**
748
+ * Child keys of each AST node type, used for selector matching. `Language#visitorKeys` is typed
749
+ * with mutable arrays; ESLint only reads them, and VISITOR_KEYS stays frozen at runtime.
750
+ */
751
+ visitorKeys: /** @type {Record<string, string[]>} */ (VISITOR_KEYS),
95
752
 
96
753
  /**
97
- * Parse jsonv source code - ESLint 9 Language API
98
- * @param {Object} file - File object with body property containing source text
99
- * @param {Object} context - Context with languageOptions
100
- * @returns {Object} Parse result {ok, ast, jsonvValue} or {ok: false, errors}
754
+ * Parse jsonv source code - ESLint Language API.
755
+ *
756
+ * The file is first parsed and evaluated with `parseWithOptions` — that is what reports
757
+ * syntax errors, year-gated features, `mode`, `strictBigInt` and `strictOctal` violations
758
+ * and unresolved/circular internal references (unless `allowInternalReferences` is `false`),
759
+ * each with its source position. A file that passes is then parsed with `parseToAst` to
760
+ * build the positioned AST, comments and tokens ESLint traverses. Both calls receive every
761
+ * language option, with the defaults filled in.
762
+ *
763
+ * @param {File} file - File object with body property containing source text
764
+ * @param {{languageOptions: JsonvLanguageOptions}} context - Context with languageOptions
765
+ * @returns {JsonvParseResult} Parse result {ok, ast, jsonvValue} or {ok: false, errors}
101
766
  */
102
767
  parse(file, context) {
103
- const text = file.body;
768
+ // `fileType: "text"` means ESLint reads the file as text, so `body` is a string.
769
+ const text = /** @type {string} */ (file.body);
770
+ /** @type {JsonvLanguageOptions} */
104
771
  const options = context.languageOptions || {};
772
+ const parseOptions = {
773
+ // `||`, not `??`: a falsy year (e.g. `0` from a direct `parse()` call) has always meant the default.
774
+ year: options.year || DEFAULT_LANGUAGE_OPTIONS.year,
775
+ mode: options.mode ?? DEFAULT_LANGUAGE_OPTIONS.mode,
776
+ strictBigInt: options.strictBigInt ?? DEFAULT_LANGUAGE_OPTIONS.strictBigInt,
777
+ strictOctal: options.strictOctal ?? DEFAULT_LANGUAGE_OPTIONS.strictOctal,
778
+ allowInternalReferences: options.allowInternalReferences ?? DEFAULT_LANGUAGE_OPTIONS.allowInternalReferences
779
+ };
105
780
 
106
781
  try {
107
- // Parse with all features enabled (ES2025) and strict mode off by default
108
- const result = parseWithOptions(text, {
109
- year: options.year || 2025,
110
- strictBigInt: options.strictBigInt !== undefined ? options.strictBigInt : false,
111
- mode: "jsonv",
112
- preserveComments: true,
113
- tolerant: false
114
- });
115
-
116
- // Return parse result with minimal ESTree-compatible AST
782
+ const jsonvValue = parseWithOptions(text, { ...parseOptions, tolerant: false });
783
+ const ast = buildProgram(parseToAst(text, parseOptions));
784
+
117
785
  return {
118
786
  ok: true,
119
- ast: {
120
- type: "Program",
121
- body: [],
122
- sourceType: "module",
123
- comments: [],
124
- tokens: []
125
- },
126
- // Store the parsed jsonv value for potential use by rules
127
- jsonvValue: result
787
+ ast,
788
+ // The evaluated document (references resolved) for callers of parse()
789
+ jsonvValue
128
790
  };
129
791
  } catch (error) {
130
- // @cldmv/jsonv (>=1.0.10) throws a `JsonvSyntaxError` — or its `LexerError` subclass, which
131
- // extends it — for every parser-level and lexer-level failure, carrying the real
132
- // `line`/`column`/`loc` of the failure. Its `column` is 0-based (matching the column
133
- // embedded in its own messages), while this language's `columnStart: 1` means ESLint
134
- // expects 1-based columns, so the reported column is `error.column + 1`.
792
+ // @cldmv/jsonv (>=1.1.0) throws a `JsonvSyntaxError` (or its `LexerError` subclass) for
793
+ // every parser-level and lexer-level failure, and a `JsonvReferenceError` for an
794
+ // unresolved or circular internal reference; both carry the real `line`/`column`/`loc`.
795
+ // Their `column` is 0-based (matching the column embedded in jsonv's messages), while
796
+ // this language's `columnStart: 1` means ESLint expects 1-based columns, hence `+ 1`.
135
797
  //
136
- // Unresolved-reference errors (jsonv#32, in progress) are still a plain `Error` with no
137
- // position — fall back to line 1, column 1 for anything that isn't a `JsonvSyntaxError`.
138
- const hasPosition = error instanceof JsonvSyntaxError;
798
+ // Anything else (e.g. a RangeError from exhausting the call stack on pathologically deep
799
+ // nesting) has no position — fall back to line 1, column 1.
800
+ const hasPosition = error instanceof JsonvSyntaxError || error instanceof JsonvReferenceError;
139
801
 
802
+ // Everything the parser and buildProgram() throw is an Error instance (the RangeError
803
+ // above included), hence the cast in the fallback branch.
804
+ /** @type {FileError} */
140
805
  const errorInfo = hasPosition
141
- ? { message: error.message, line: error.line, column: error.column + 1, endLine: error.loc.end.line, endColumn: error.loc.end.column + 1 }
142
- : { message: error.message, line: 1, column: 1 };
806
+ ? {
807
+ message: error.message,
808
+ line: error.line,
809
+ column: error.column + 1,
810
+ endLine: error.loc.end.line,
811
+ endColumn: error.loc.end.column + 1
812
+ }
813
+ : { message: /** @type {Error} */ (error).message, line: 1, column: 1 };
143
814
 
144
- // Return error result
145
815
  return {
146
816
  ok: false,
147
817
  errors: [errorInfo]
@@ -150,67 +820,16 @@ const jsonvLanguage = {
150
820
  },
151
821
 
152
822
  /**
153
- * Create source code object
154
- * Required by ESLint 9 language API
823
+ * Create the SourceCode object ESLint hands to rules
155
824
  *
156
825
  * @public
157
- * @param {Object} file - The file object
158
- * @param {Object} parseResult - The result from parse()
159
- * @param {Object} context - Context object
160
- * @returns {Object} SourceCode object
826
+ * @param {File} file - The file object
827
+ * @param {JsonvOkParseResult} parseResult - The successful result from parse()
828
+ * @returns {JsonvSourceCode} SourceCode object
161
829
  */
162
- createSourceCode(file, parseResult, context) {
830
+ createSourceCode(file, parseResult) {
163
831
  const text = typeof file.body === "string" ? file.body : String(file.body || "");
164
- const ast = parseResult.ast;
165
-
166
- return {
167
- text,
168
- ast,
169
- lines: text.split(/\r\n|[\r\n\u2028\u2029]/g),
170
- hasBOM: text.charCodeAt(0) === 0xfeff,
171
-
172
- // Required methods
173
- getLoc(node) {
174
- return node.loc || { start: { line: 1, column: 0 }, end: { line: 1, column: 0 } };
175
- },
176
-
177
- getRange(node) {
178
- return node.range || [0, 0];
179
- },
180
-
181
- traverse() {
182
- // ESLint expects: { kind: 1 (VISIT), target: node, phase: 1|2, args: [] }
183
- // kind: 1 = STEP_KIND_VISIT
184
- // phase: 1 = enter, 2 = exit
185
- return [
186
- {
187
- kind: 1,
188
- target: ast,
189
- phase: 1,
190
- args: [ast]
191
- },
192
- {
193
- kind: 1,
194
- target: ast,
195
- phase: 2,
196
- args: [ast]
197
- }
198
- ].values();
199
- },
200
-
201
- // Optional helper methods
202
- getText(node, beforeCount, afterCount) {
203
- if (node && node.range) {
204
- const [start, end] = node.range;
205
- return text.slice(Math.max(0, start - (beforeCount || 0)), Math.min(text.length, end + (afterCount || 0)));
206
- }
207
- return text;
208
- },
209
-
210
- getLines() {
211
- return text.split(/\r\n|[\r\n\u2028\u2029]/g);
212
- }
213
- };
832
+ return new JsonvSourceCode({ text, ast: parseResult.ast });
214
833
  },
215
834
 
216
835
  /**
@@ -220,16 +839,21 @@ const jsonvLanguage = {
220
839
  * reflects the effective options that will reach `parse()`.
221
840
  *
222
841
  * @public
223
- * @param {Object} languageOptions - The (already-merged) language options to validate
842
+ * @param {JsonvLanguageOptions} languageOptions - The (already-merged) language options to validate
224
843
  * @returns {void}
225
844
  * @throws {TypeError} When an unsupported key is present, or a supported key holds an invalid value
226
845
  */
227
846
  validateLanguageOptions(languageOptions) {
228
847
  const options = languageOptions ?? {};
229
848
 
849
+ const supported = `Supported options are: ${[...SUPPORTED_LANGUAGE_OPTIONS].join(", ")}.`;
850
+
230
851
  for (const key of Object.keys(options)) {
852
+ if (Object.hasOwn(UNSUPPORTED_JSONV_OPTIONS, key)) {
853
+ throw new TypeError(`Language option "${key}" is not supported: ${UNSUPPORTED_JSONV_OPTIONS[key]}. ${supported}`);
854
+ }
231
855
  if (!SUPPORTED_LANGUAGE_OPTIONS.has(key)) {
232
- throw new TypeError(`Unknown language option "${key}". Supported options are: ${[...SUPPORTED_LANGUAGE_OPTIONS].join(", ")}.`);
856
+ throw new TypeError(`Unknown language option "${key}". ${supported}`);
233
857
  }
234
858
  }
235
859
 
@@ -239,8 +863,16 @@ const jsonvLanguage = {
239
863
  );
240
864
  }
241
865
 
242
- if (options.strictBigInt !== undefined && typeof options.strictBigInt !== "boolean") {
243
- throw new TypeError(`Invalid "strictBigInt" language option: expected a boolean, got ${typeof options.strictBigInt}.`);
866
+ if (options.mode !== undefined && !VALID_MODES.has(options.mode)) {
867
+ throw new TypeError(
868
+ `Invalid "mode" language option: ${JSON.stringify(options.mode)}. Supported modes are: ${[...VALID_MODES].join(", ")}.`
869
+ );
870
+ }
871
+
872
+ for (const key of BOOLEAN_LANGUAGE_OPTIONS) {
873
+ if (options[key] !== undefined && typeof options[key] !== "boolean") {
874
+ throw new TypeError(`Invalid "${key}" language option: expected a boolean, got ${typeof options[key]}.`);
875
+ }
244
876
  }
245
877
  },
246
878
 
@@ -251,12 +883,9 @@ const jsonvLanguage = {
251
883
  * calling `validateLanguageOptions` and `parse()`.
252
884
  *
253
885
  * @public
254
- * @type {Object}
886
+ * @type {JsonvLanguageOptions}
255
887
  */
256
- defaultLanguageOptions: {
257
- year: 2025,
258
- strictBigInt: false
259
- }
888
+ defaultLanguageOptions: { ...DEFAULT_LANGUAGE_OPTIONS }
260
889
  };
261
890
 
262
891
  /**
@@ -267,7 +896,7 @@ const jsonvLanguage = {
267
896
  * `language` such as `jsonv/jsonv` never consults it, so setting it here was inert.
268
897
  *
269
898
  * @public
270
- * @type {Object}
899
+ * @satisfies {ConfigObject}
271
900
  */
272
901
  const recommendedConfig = {
273
902
  rules: {
@@ -277,10 +906,11 @@ const recommendedConfig = {
277
906
  };
278
907
 
279
908
  /**
280
- * ESLint plugin export
909
+ * ESLint plugin export. `@satisfies` checks it against @eslint/core's `Plugin` (which ESLint's
910
+ * own `ESLint.Plugin` is) while keeping its literal keys (`languages.jsonv`, `configs.recommended`).
281
911
  *
282
912
  * @public
283
- * @type {Object}
913
+ * @satisfies {Plugin}
284
914
  */
285
915
  const plugin = {
286
916
  meta: readPackageMeta(),
@@ -301,3 +931,4 @@ const plugin = {
301
931
  };
302
932
 
303
933
  export default plugin;
934
+ export { JsonvSourceCode };