@amritk/lint 0.6.2 → 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/README.md CHANGED
@@ -129,7 +129,7 @@ One thing `restrictTo` does not cover: a regular expression a ruleset writes —
129
129
 
130
130
  ### Auto-fix
131
131
 
132
- `fixDocument` runs the linter and applies a `FixerRegistry` — fixers keyed by rule `code` that map a finding to a formatting-preserving text edit — to a fixpoint, then re-lints:
132
+ `fixDocument` runs the linter and applies a `FixerRegistry` — fixers keyed by rule `code` that map a finding to a formatting-preserving text edit — to a fixpoint, then reports the findings that remain:
133
133
 
134
134
  ```ts
135
135
  import { fixDocument, type FixerRegistry } from '@amritk/lint'
@@ -161,7 +161,7 @@ const { output, applied } = await fixDocument(source, {
161
161
  })
162
162
  ```
163
163
 
164
- Fixing runs to a fixpoint, capped at 10 passes. The result reports how that ended: **`converged`** is `false` when the cap was hit while the document was still changing (usually two fixers undoing each other), and **`passes`** counts the passes that changed something. `applied` is de-duplicated by rule code and path, so a report can safely say "fixed N problems".
164
+ Fixing runs to a fixpoint, capped at 10 passes. `remaining` comes from the pass that found nothing left to fix, so a run that converges does not lint the fixed document a second time; only one that hits the cap does. The result reports how that ended: **`converged`** is `false` when the cap was hit while the document was still changing (usually two fixers undoing each other), and **`passes`** counts the passes that changed something. `applied` is de-duplicated by rule code and path, so a report can safely say "fixed N problems".
165
165
 
166
166
  ### Rendering findings
167
167
 
@@ -199,6 +199,7 @@ A ruleset is a plain object (authored as YAML, JSON, or a JS module):
199
199
  | `functions` / `functionsDir` | Custom functions to load by name (default dir `functions/`). |
200
200
  | `overrides` | Per-file-glob rule tweaks. |
201
201
  | `aliases` | Reusable `given` fragments referenced as `#alias`. |
202
+ | `parserOptions` | YAML parser strictness, each set to a severity. `duplicateKeys` reports a repeated key (default `error`; `off` allows it, and the last value wins). `incompatibleValues` is off by default and reports `.nan` / `.inf` / `-.inf`, which `JSON.stringify` silently turns into `null`. Parser findings carry the code `parser`. |
202
203
 
203
204
  Built-in functions: `alphabetical`, `casing`, `defined`, `enumeration`, `falsy`, `length`, `or`, `pattern`, `schema`, `truthy`, `undefined`, `unreferencedReusableObject`, `xor`, `typedEnum`.
204
205
 
@@ -1,4 +1,4 @@
1
- import { isAlias, isMap, isScalar, isSeq, parseDocument } from "@amritk/yaml";
1
+ import { isMap, isScalar, isSeq, keyText, parseDocument } from "@amritk/yaml";
2
2
  import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser";
3
3
  const splice = (text, start, end, replacement) => text.slice(0, start) + replacement + text.slice(end);
4
4
  const lineStart = (text, offset) => {
@@ -20,17 +20,7 @@ const detectEol = (text) => {
20
20
  const i = text.indexOf("\n");
21
21
  return i > 0 && text.charCodeAt(i - 1) === 13 ? "\r\n" : "\n";
22
22
  };
23
- const keyName = (key) => {
24
- if (isScalar(key)) {
25
- const v = key.value;
26
- return typeof v === "string" ? v : v === null ? "null" : String(v);
27
- }
28
- if (isAlias(key))
29
- return `*${key.source}`;
30
- if (isMap(key) || isSeq(key))
31
- return "";
32
- return String(key);
33
- };
23
+ const seqIndex = (segment) => typeof segment === "number" ? segment : /^(?:0|[1-9]\d*)$/.test(segment) ? Number(segment) : -1;
34
24
  const yamlNodeAt = (root, path) => {
35
25
  let current = root ?? void 0;
36
26
  for (const segment of path) {
@@ -38,10 +28,10 @@ const yamlNodeAt = (root, path) => {
38
28
  return void 0;
39
29
  if (isMap(current)) {
40
30
  const target = String(segment);
41
- const pair = current.items.findLast((item) => keyName(item.key) === target);
31
+ const pair = current.items.findLast((item) => keyText(item.key) === target);
42
32
  current = pair?.value ?? void 0;
43
33
  } else if (isSeq(current)) {
44
- current = current.items[Number(segment)];
34
+ current = current.items[seqIndex(segment)];
45
35
  } else {
46
36
  return void 0;
47
37
  }
@@ -53,7 +43,7 @@ const yamlPairAt = (root, path) => {
53
43
  if (!parent || !isMap(parent))
54
44
  return {};
55
45
  const last = String(path[path.length - 1]);
56
- const pair = parent.items.findLast((item) => keyName(item.key) === last);
46
+ const pair = parent.items.findLast((item) => keyText(item.key) === last);
57
47
  if (!pair)
58
48
  return {};
59
49
  return { key: pair.key, ...pair.value ? { value: pair.value } : {} };
@@ -133,7 +123,7 @@ const applyYamlOp = (text, op) => {
133
123
  if (!parent || !isMap(parent))
134
124
  return text;
135
125
  const last = String(op.path[op.path.length - 1]);
136
- const index = parent.items.findLastIndex((item) => keyName(item.key) === last);
126
+ const index = parent.items.findLastIndex((item) => keyText(item.key) === last);
137
127
  if (index === -1)
138
128
  return text;
139
129
  const pair = parent.items[index];
@@ -190,7 +180,7 @@ const applyYamlOp = (text, op) => {
190
180
  }
191
181
  if (!isMap(parent))
192
182
  return text;
193
- if (parent.items.some((item) => keyName(item.key) === op.key))
183
+ if (parent.items.some((item) => keyText(item.key) === op.key))
194
184
  return text;
195
185
  const pair = `${yamlInsertKey(op.key)}: ${yamlInsertValue(op.value)}`;
196
186
  if (text.charCodeAt(parent.start) === 123) {
@@ -9,5 +9,10 @@ export type LineMap = {
9
9
  /**
10
10
  * Builds a {@link LineMap} for `source`. The line-start offsets are precomputed
11
11
  * once so each `positionAt` lookup is a binary search rather than a re-scan.
12
+ *
13
+ * A line ends at CR LF, a lone CR, or a lone LF — YAML 1.2 §5.4's
14
+ * `b-break ::= CR LF | CR | LF`, which is also where JSON's scanner breaks lines.
15
+ * Counting LF alone put every position in a CR-only file on line 0 while the
16
+ * parser (and `@amritk/yaml`'s `lineCounter`) put it on its real line.
12
17
  */
13
18
  export declare const createLineMap: (source: string) => LineMap;
@@ -1,9 +1,21 @@
1
1
  const createLineMap = (source) => {
2
2
  const length = source.length;
3
3
  const lineStarts = [0];
4
- for (let i = 0; i < source.length; i++) {
5
- if (source.charCodeAt(i) === 10)
6
- lineStarts.push(i + 1);
4
+ let nl = source.indexOf("\n");
5
+ let cr = source.indexOf("\r");
6
+ while (nl !== -1 || cr !== -1) {
7
+ if (cr !== -1 && (nl === -1 || cr < nl)) {
8
+ if (source.charCodeAt(cr + 1) === 10) {
9
+ lineStarts.push(cr + 2);
10
+ nl = source.indexOf("\n", cr + 2);
11
+ } else {
12
+ lineStarts.push(cr + 1);
13
+ }
14
+ cr = source.indexOf("\r", cr + 1);
15
+ } else {
16
+ lineStarts.push(nl + 1);
17
+ nl = source.indexOf("\n", nl + 1);
18
+ }
7
19
  }
8
20
  const positionAt = (offset) => {
9
21
  const clamped = Math.max(0, Math.min(offset, length));
@@ -0,0 +1,56 @@
1
+ import { type YamlDocument } from '@amritk/yaml';
2
+ import type { LineMap } from './lines.js';
3
+ import type { ILocation, JsonPath } from './types.js';
4
+ /**
5
+ * Knobs for tests. `maxVisits` replaces {@link MAX_LOOKUP_VISITS} so the budget
6
+ * fallback can be reached with a small document, and `stats.visits` accumulates
7
+ * the work every lookup charged, so a test can bound a lookup's cost without
8
+ * timing it.
9
+ */
10
+ export type YamlLocatorOptions = {
11
+ maxVisits?: number;
12
+ stats?: {
13
+ visits: number;
14
+ };
15
+ };
16
+ /**
17
+ * Builds the path-to-position lookup for a parsed YAML stream, resolving each
18
+ * path on demand instead of indexing every node up front.
19
+ *
20
+ * Indexing eagerly meant copying a path array, building a string key, and
21
+ * running two line-map searches for every node of the document — about two
22
+ * thirds of lint's parse time on a large spec — while a lint run only ever asks
23
+ * for the handful of paths that carry findings. A lookup now walks the tree from
24
+ * the root along the path, which is O(depth).
25
+ *
26
+ * The answers are the eager index's, including its quirks, because rulesets and
27
+ * fixers were written against them:
28
+ *
29
+ * - Segments match by text: `0` and `'0'` both name item 0 of a sequence and a
30
+ * `"0"` key of a map, the way the index's string keys did.
31
+ * - An alias keeps its own range, and a path beneath it descends into the
32
+ * anchored node.
33
+ * - A duplicated key resolves to the last occurrence — the index's last write.
34
+ * Because the index was keyed by path text, *every* occurrence of the key
35
+ * contributed its subtree, so a path that exists only under an earlier
36
+ * duplicate still resolves there. That is why a lookup tracks a list of
37
+ * candidate nodes per segment rather than a single node: the answer is the
38
+ * last candidate at the full path.
39
+ * - A key brought in by `<<` resolves to the node `toJS` takes it from (see
40
+ * {@link createMergeResolver}): explicit keys win over merged ones, an earlier
41
+ * merge (or an earlier item of a merge list) wins over a later one, and a
42
+ * merge source contributes the keys of its own projection — its explicit keys
43
+ * ahead of anything *it* merges. A merged key only lands on a path nothing
44
+ * else claimed. The eager index differed here: it walked a merge source's
45
+ * pairs in order, so a nested `<<` written first claimed keys the source
46
+ * itself overrides, and a finding landed on a value the data does not hold.
47
+ * - A key written with no value (`key:`) has no position of its own, so the key
48
+ * is absent and `closest` falls back past it.
49
+ * - In a multi-document stream every path starts with the document index, and
50
+ * the stream itself (the empty path) has no position.
51
+ *
52
+ * `closest` returns the deepest prefix that resolves, which is what popping
53
+ * segments off the path until the index had it found: every prefix of an indexed
54
+ * path was itself indexed.
55
+ */
56
+ export declare const createYamlLocator: (docs: readonly YamlDocument[], lineMap: LineMap, options?: YamlLocatorOptions) => ((path: JsonPath, closest?: boolean) => ILocation | undefined);
@@ -0,0 +1,122 @@
1
+ import { isAlias, isMap, isSeq } from "@amritk/yaml";
2
+ import { createMergeResolver } from "./yaml-merge-key.js";
3
+ const MAX_LOOKUP_VISITS = 1e5;
4
+ const seqIndex = (segment, length) => {
5
+ const index = Number(segment);
6
+ return Number.isInteger(index) && index >= 0 && index < length && String(index) === segment ? index : -1;
7
+ };
8
+ const identityOf = (node) => isAlias(node) ? node.target ?? node : node;
9
+ const dedupeCandidates = (nodes) => {
10
+ const last = /* @__PURE__ */ new Map();
11
+ for (let i = 0; i < nodes.length; i++)
12
+ last.set(identityOf(nodes[i]), i);
13
+ if (last.size === nodes.length)
14
+ return nodes;
15
+ const kept = [];
16
+ const seen = /* @__PURE__ */ new Set();
17
+ for (let i = 0; i < nodes.length; i++) {
18
+ const node = nodes[i];
19
+ const identity = identityOf(node);
20
+ if (!seen.has(identity)) {
21
+ seen.add(identity);
22
+ kept.push(node);
23
+ } else if (last.get(identity) === i) {
24
+ kept.push(node);
25
+ }
26
+ }
27
+ return kept;
28
+ };
29
+ const createYamlLocator = (docs, lineMap, options = {}) => {
30
+ const multiDocument = docs.length > 1;
31
+ const maxVisits = options.maxVisits ?? MAX_LOOKUP_VISITS;
32
+ const budget = { left: maxVisits };
33
+ const merges = createMergeResolver(budget);
34
+ const childrenOf = (visits, segment) => {
35
+ const children = [];
36
+ for (const visit of visits) {
37
+ if (budget.left-- <= 0)
38
+ return void 0;
39
+ const node = isAlias(visit) ? visit.target : visit;
40
+ if (node == null)
41
+ continue;
42
+ if (isMap(node)) {
43
+ const explicit = merges.tableOf(node).explicit.get(segment);
44
+ if (explicit) {
45
+ budget.left -= explicit.length;
46
+ for (const value of explicit)
47
+ children.push(value);
48
+ } else if (children.length === 0) {
49
+ const merged = merges.mergedValueOf(node, segment);
50
+ if (merged)
51
+ children.push(merged);
52
+ }
53
+ } else if (isSeq(node)) {
54
+ const index = seqIndex(segment, node.items.length);
55
+ const item = index === -1 ? void 0 : node.items[index];
56
+ if (item !== void 0)
57
+ children.push(item);
58
+ }
59
+ }
60
+ if (budget.left <= 0)
61
+ return void 0;
62
+ return children.length > 1 ? dedupeCandidates(children) : children;
63
+ };
64
+ const descend = (root, path, depth, closest) => {
65
+ let node = root;
66
+ for (; depth < path.length; depth++) {
67
+ const target = isAlias(node) ? node.target : node;
68
+ const segment = String(path[depth]);
69
+ let child;
70
+ if (target != null && isMap(target))
71
+ child = merges.projectedValueOf(target, segment);
72
+ else if (target != null && isSeq(target))
73
+ child = target.items[seqIndex(segment, target.items.length)];
74
+ if (child == null)
75
+ return closest ? node : void 0;
76
+ node = child;
77
+ }
78
+ return node;
79
+ };
80
+ const locate = (path, closest) => {
81
+ let root;
82
+ let depth = 0;
83
+ if (multiDocument) {
84
+ const first = path[0];
85
+ if (first === void 0)
86
+ return void 0;
87
+ const index = seqIndex(String(first), docs.length);
88
+ root = index === -1 ? null : docs[index]?.contents ?? null;
89
+ depth = 1;
90
+ } else {
91
+ root = docs[0]?.contents ?? null;
92
+ }
93
+ if (root == null)
94
+ return void 0;
95
+ const start = depth;
96
+ let visits = [root];
97
+ for (; depth < path.length; depth++) {
98
+ const children = childrenOf(visits, String(path[depth]));
99
+ if (children === void 0)
100
+ return descend(root, path, start, closest);
101
+ if (children.length === 0) {
102
+ if (!closest)
103
+ return void 0;
104
+ break;
105
+ }
106
+ visits = children;
107
+ }
108
+ return visits[visits.length - 1];
109
+ };
110
+ return (path, closest = false) => {
111
+ budget.left = maxVisits;
112
+ const node = locate(path, closest);
113
+ if (options.stats)
114
+ options.stats.visits += maxVisits - budget.left;
115
+ if (node === void 0)
116
+ return void 0;
117
+ return { range: { start: lineMap.positionAt(node.start), end: lineMap.positionAt(node.end) } };
118
+ };
119
+ };
120
+ export {
121
+ createYamlLocator
122
+ };
@@ -0,0 +1,58 @@
1
+ import { type YamlMap, type YamlNode, type YamlPair } from '@amritk/yaml';
2
+ /**
3
+ * True when a pair is a `<<` merge key, whose value folds into the parent map
4
+ * instead of becoming a key of its own. The parser runs with `merge: true`, so
5
+ * `toJS` never projects a `<<` key — which is why both the position lookup and
6
+ * the non-finite scan route these pairs to their merge handling rather than
7
+ * treating `<<` as a path segment.
8
+ */
9
+ export declare const isMergePair: (pair: YamlPair) => boolean;
10
+ /**
11
+ * What one map holds, gathered the first time anything asks about it: the
12
+ * explicit pairs' values grouped by key text (in source order, so duplicates
13
+ * keep their order), the `<<` values in source order, and the answers already
14
+ * worked out for merged keys and for the full key list.
15
+ */
16
+ export type KeyTable = {
17
+ explicit: Map<string, YamlNode[]>;
18
+ merges: (YamlNode | null)[];
19
+ merged: Map<string, YamlNode | null>;
20
+ keys: string[] | undefined;
21
+ };
22
+ /**
23
+ * Answers "which node does `toJS` project this key of this map from?" for maps
24
+ * that use `<<`, shared by the position lookup and the non-finite scan so both
25
+ * agree with the data.
26
+ *
27
+ * The rule is `toJsValue`/`applyMerge` in `@amritk/yaml`: a map's object is
28
+ * built pair by pair, an explicit key overwrites whatever is there (so the last
29
+ * duplicate wins, and it beats a merged key wherever it is written), and a `<<`
30
+ * pair copies in every key of its source's *finished* object that the map does
31
+ * not hold yet (so an earlier merge, or an earlier item of a merge list, wins).
32
+ * The source's object was itself built by the same rule first, which is the part
33
+ * a walk over the source's pairs in order gets wrong: in `{<<: *b, a: 1}` the
34
+ * nested merge is written first, but the source's own `a` is what it projects.
35
+ * A merge source that is not a map or a list of maps (a scalar, a `!!set`, an
36
+ * `!!omap`) projects to something with no own keys and contributes nothing.
37
+ *
38
+ * One quirk is kept from the eager position index this replaced: a key written
39
+ * with no value (`key:`) has no node to point at, so it is treated as absent —
40
+ * it neither answers nor shadows — rather than as the `null` `toJS` gives it.
41
+ *
42
+ * Answers are memoised per map and key. The same anchor merged into a thousand
43
+ * maps, or a merge chain reached by a thousand aliases, is then scanned once,
44
+ * not once per map per lookup. `meter.left` is decremented for every merge
45
+ * source examined on a cache miss so a caller with a work budget can charge the
46
+ * scanning to it; the scan always runs to completion, so a cached answer is
47
+ * never a partial one.
48
+ */
49
+ export declare const createMergeResolver: (meter?: {
50
+ left: number;
51
+ }) => {
52
+ tableOf: (map: YamlMap) => KeyTable;
53
+ projectedValueOf: (map: YamlMap, key: string) => YamlNode | null;
54
+ mergedValueOf: (map: YamlMap, key: string) => YamlNode | null;
55
+ keysOf: (map: YamlMap) => readonly string[];
56
+ };
57
+ /** The shared merge-key resolver {@link createMergeResolver} builds. */
58
+ export type MergeResolver = ReturnType<typeof createMergeResolver>;
@@ -0,0 +1,104 @@
1
+ import { isAlias, isMap, isPair, isScalar, isSeq, keyText } from "@amritk/yaml";
2
+ const isMergePair = (pair) => isScalar(pair.key) && pair.key.source === "<<";
3
+ const createMergeResolver = (meter = { left: Number.POSITIVE_INFINITY }) => {
4
+ const tables = /* @__PURE__ */ new Map();
5
+ const tableOf = (map) => {
6
+ const cached = tables.get(map);
7
+ if (cached)
8
+ return cached;
9
+ const table = { explicit: /* @__PURE__ */ new Map(), merges: [], merged: /* @__PURE__ */ new Map(), keys: void 0 };
10
+ for (const pair of map.items) {
11
+ if (!isPair(pair))
12
+ continue;
13
+ if (isMergePair(pair)) {
14
+ table.merges.push(pair.value);
15
+ continue;
16
+ }
17
+ if (pair.value == null)
18
+ continue;
19
+ const key = keyText(pair.key);
20
+ const values = table.explicit.get(key);
21
+ if (values)
22
+ values.push(pair.value);
23
+ else
24
+ table.explicit.set(key, [pair.value]);
25
+ }
26
+ tables.set(map, table);
27
+ return table;
28
+ };
29
+ const eachSource = (node, visit) => {
30
+ meter.left--;
31
+ const target = node != null && isAlias(node) ? node.target : node;
32
+ if (target == null)
33
+ return null;
34
+ if (isSeq(target)) {
35
+ if (target.tag === "omap")
36
+ return null;
37
+ for (const item of target.items) {
38
+ const found = eachSource(item, visit);
39
+ if (found !== null)
40
+ return found;
41
+ }
42
+ return null;
43
+ }
44
+ if (!isMap(target) || target.tag === "set")
45
+ return null;
46
+ return visit(target);
47
+ };
48
+ const mergedValueOf = (map, key) => {
49
+ const table = tableOf(map);
50
+ if (table.merges.length === 0)
51
+ return null;
52
+ const cached = table.merged.get(key);
53
+ if (cached !== void 0)
54
+ return cached;
55
+ table.merged.set(key, null);
56
+ let found = null;
57
+ for (const merge of table.merges) {
58
+ found = eachSource(merge, (source) => projectedValueOf(source, key));
59
+ if (found !== null)
60
+ break;
61
+ }
62
+ table.merged.set(key, found);
63
+ return found;
64
+ };
65
+ const projectedValueOf = (map, key) => {
66
+ const explicit = tableOf(map).explicit.get(key);
67
+ if (explicit)
68
+ return explicit[explicit.length - 1];
69
+ return mergedValueOf(map, key);
70
+ };
71
+ const keysOf = (map) => {
72
+ const table = tableOf(map);
73
+ if (table.keys)
74
+ return table.keys;
75
+ const keys = [];
76
+ table.keys = keys;
77
+ const seen = /* @__PURE__ */ new Set();
78
+ const add = (key) => {
79
+ if (seen.has(key))
80
+ return;
81
+ seen.add(key);
82
+ keys.push(key);
83
+ };
84
+ for (const pair of map.items) {
85
+ if (!isPair(pair))
86
+ continue;
87
+ if (isMergePair(pair)) {
88
+ eachSource(pair.value, (source) => {
89
+ for (const key of keysOf(source))
90
+ add(key);
91
+ return null;
92
+ });
93
+ } else if (pair.value != null) {
94
+ add(keyText(pair.key));
95
+ }
96
+ }
97
+ return keys;
98
+ };
99
+ return { tableOf, projectedValueOf, mergedValueOf, keysOf };
100
+ };
101
+ export {
102
+ createMergeResolver,
103
+ isMergePair
104
+ };
@@ -0,0 +1,22 @@
1
+ import { type YamlNode, type YamlScalar } from '@amritk/yaml';
2
+ /**
3
+ * Calls `report` once for every scalar value in a document that projects to a
4
+ * non-finite number — `.nan`, `.inf`, `-.inf` under the core schema — in the
5
+ * order a depth-first walk of the data reaches them.
6
+ *
7
+ * `JSON.stringify` silently rewrites those values to `null`, which is what the
8
+ * `incompatibleValues` option exists to catch. This used to ride along with the
9
+ * eager position index; the index is on demand now, so the check gets its own
10
+ * walk, and only when the option is on.
11
+ *
12
+ * The walk covers what the projection can hold: map values, sequence items, the
13
+ * collection an alias names, and the keys a `<<` merge brings in — skipping a
14
+ * merged key an explicit key (or an earlier merge) already claimed, and taking
15
+ * each merged key from where the source's own projection takes it, since `toJS`
16
+ * drops every other value. Keys themselves are not checked; they project to
17
+ * strings. A collection is walked once however many aliases reach it and a
18
+ * value is reported once however many merges bring it in, so this is linear in
19
+ * the document even for the alias-heavy "billion laughs" shape, and a value
20
+ * written once is reported once.
21
+ */
22
+ export declare const reportNonFinite: (root: YamlNode | null, report: (node: YamlScalar) => void) => void;
@@ -0,0 +1,77 @@
1
+ import { isAlias, isMap, isPair, isScalar, isSeq, keyText } from "@amritk/yaml";
2
+ import { createMergeResolver, isMergePair } from "./yaml-merge-key.js";
3
+ const reportNonFinite = (root, report) => {
4
+ const seen = /* @__PURE__ */ new Set();
5
+ let merges;
6
+ const visitMerge = (node, claimed, sources) => {
7
+ const target = node != null && isAlias(node) ? node.target : node;
8
+ if (target == null || sources.has(target))
9
+ return;
10
+ sources.add(target);
11
+ if (isSeq(target)) {
12
+ if (target.tag === "omap")
13
+ return;
14
+ for (const item of target.items)
15
+ visitMerge(item, claimed, sources);
16
+ return;
17
+ }
18
+ if (!isMap(target) || target.tag === "set")
19
+ return;
20
+ merges ??= createMergeResolver();
21
+ for (const key of merges.keysOf(target)) {
22
+ if (claimed.has(key))
23
+ continue;
24
+ claimed.add(key);
25
+ visit(merges.projectedValueOf(target, key));
26
+ }
27
+ };
28
+ const visit = (node) => {
29
+ if (node == null)
30
+ return;
31
+ if (isScalar(node)) {
32
+ const value = node.value;
33
+ if (typeof value === "number" && !Number.isFinite(value) && !seen.has(node)) {
34
+ seen.add(node);
35
+ report(node);
36
+ }
37
+ return;
38
+ }
39
+ const target = isAlias(node) ? node.target : node;
40
+ if (target == null || seen.has(target))
41
+ return;
42
+ if (isSeq(target)) {
43
+ seen.add(target);
44
+ for (const item of target.items)
45
+ visit(item);
46
+ return;
47
+ }
48
+ if (!isMap(target))
49
+ return;
50
+ seen.add(target);
51
+ let hasMerge = false;
52
+ for (const pair of target.items) {
53
+ if (!isPair(pair))
54
+ continue;
55
+ if (isMergePair(pair))
56
+ hasMerge = true;
57
+ else
58
+ visit(pair.value);
59
+ }
60
+ if (!hasMerge)
61
+ return;
62
+ const claimed = /* @__PURE__ */ new Set();
63
+ for (const pair of target.items) {
64
+ if (isPair(pair) && !isMergePair(pair) && pair.value != null)
65
+ claimed.add(keyText(pair.key));
66
+ }
67
+ const sources = /* @__PURE__ */ new Set();
68
+ for (const pair of target.items) {
69
+ if (isPair(pair) && isMergePair(pair))
70
+ visitMerge(pair.value, claimed, sources);
71
+ }
72
+ };
73
+ visit(root);
74
+ };
75
+ export {
76
+ reportNonFinite
77
+ };
@@ -1,42 +1,8 @@
1
- import { isAlias, isMap, isPair, isScalar, isSeq, parseAllDocuments } from "@amritk/yaml";
1
+ import { parseAllDocuments } from "@amritk/yaml";
2
2
  import { createLineMap } from "./lines.js";
3
3
  import { DiagnosticSeverity } from "./types.js";
4
- const pathKey = (path) => {
5
- let key = "";
6
- for (const segment of path) {
7
- const text = String(segment);
8
- key += `${text.length}:${text}`;
9
- }
10
- return key;
11
- };
12
- const serializeComplexKey = (node) => {
13
- if (isSeq(node)) {
14
- return node.items.length === 0 ? "[]" : `[ ${node.items.map(flowText).join(", ")} ]`;
15
- }
16
- if (isMap(node)) {
17
- const pairs = node.items.filter(isPair).map((pair) => `${flowText(pair.key)}: ${pair.value ? flowText(pair.value) : "null"}`);
18
- return pairs.length === 0 ? "{}" : `{ ${pairs.join(", ")} }`;
19
- }
20
- return flowText(node);
21
- };
22
- const flowText = (node) => {
23
- if (isScalar(node)) {
24
- const v = node.value;
25
- return typeof v === "string" ? v : v === null ? "null" : String(v);
26
- }
27
- if (isAlias(node))
28
- return node.target ? flowText(node.target) : `*${node.source}`;
29
- return serializeComplexKey(node);
30
- };
31
- const keyToString = (key) => {
32
- if (isScalar(key)) {
33
- const v = key.value;
34
- return typeof v === "string" ? v : v === null ? "" : String(v);
35
- }
36
- if (isAlias(key))
37
- return key.target ? keyToString(key.target) : `*${key.source}`;
38
- return serializeComplexKey(key);
39
- };
4
+ import { createYamlLocator } from "./yaml-locator.js";
5
+ import { reportNonFinite } from "./yaml-non-finite.js";
40
6
  const parseYaml = (source, options = {}) => {
41
7
  const lineMap = createLineMap(source);
42
8
  const duplicateKeys = options.duplicateKeys;
@@ -45,7 +11,6 @@ const parseYaml = (source, options = {}) => {
45
11
  const incompatibleValues = options.incompatibleValues;
46
12
  const incompatSeverity = typeof incompatibleValues === "number" ? incompatibleValues : void 0;
47
13
  const docs = parseAllDocuments(source, { uniqueKeys: !dedupe });
48
- const index = /* @__PURE__ */ new Map();
49
14
  const diagnostics = [];
50
15
  const pushError = (severity, message, start, end, code) => {
51
16
  diagnostics.push({
@@ -55,105 +20,50 @@ const parseYaml = (source, options = {}) => {
55
20
  range: { start: lineMap.positionAt(start), end: lineMap.positionAt(end) }
56
21
  });
57
22
  };
58
- const rangeOf = (node) => ({
59
- start: lineMap.positionAt(node.start),
60
- end: lineMap.positionAt(node.end)
61
- });
62
- let budget = Math.max(1e5, source.length * 100);
63
- const isMergePair = (pair) => isScalar(pair.key) && pair.key.source === "<<";
64
- const walkMerge = (node, path) => {
65
- const target = node != null && isAlias(node) ? node.target : node;
66
- if (target == null)
67
- return;
68
- if (isSeq(target)) {
69
- for (const item of target.items)
70
- walkMerge(item, path);
71
- return;
72
- }
73
- if (!isMap(target))
74
- return;
75
- for (const item of target.items) {
76
- if (!isPair(item))
77
- continue;
78
- if (isMergePair(item)) {
79
- walkMerge(item.value, path);
80
- continue;
81
- }
82
- const childPath = [...path, keyToString(item.key)];
83
- if (!index.has(pathKey(childPath)))
84
- walk(item.value, childPath);
85
- }
86
- };
87
- const walk = (node, path) => {
88
- if (node == null || budget-- <= 0)
23
+ const checkIncompatible = (doc) => {
24
+ if (incompatSeverity === void 0)
89
25
  return;
90
- index.set(pathKey(path), rangeOf(node));
91
- if (isScalar(node)) {
92
- const value = node.value;
93
- if (incompatSeverity !== void 0 && typeof value === "number" && !Number.isFinite(value)) {
94
- pushError(incompatSeverity, `Value ${String(value)} cannot be represented in JSON and will serialize to null.`, node.start, node.end, "INCOMPATIBLE_VALUE");
95
- }
96
- return;
97
- }
98
- const target = isAlias(node) ? node.target : node;
99
- if (target == null)
100
- return;
101
- if (isMap(target)) {
102
- const merges = [];
103
- for (const item of target.items) {
104
- if (!isPair(item))
105
- continue;
106
- if (isMergePair(item)) {
107
- merges.push(item.value);
108
- continue;
109
- }
110
- walk(item.value, [...path, keyToString(item.key)]);
111
- }
112
- for (const merge of merges)
113
- walkMerge(merge, path);
114
- } else if (isSeq(target)) {
115
- target.items.forEach((item, i) => {
116
- walk(item, [...path, i]);
117
- });
118
- }
26
+ reportNonFinite(doc.contents, (node) => {
27
+ pushError(incompatSeverity, `Value ${String(node.value)} cannot be represented in JSON and will serialize to null.`, node.start, node.end, "INCOMPATIBLE_VALUE");
28
+ });
119
29
  };
120
30
  const collectProblems = (doc) => {
121
31
  for (const err of doc.errors) {
122
32
  const severity = err.code === "DUPLICATE_KEY" ? dupSeverity : DiagnosticSeverity.Error;
123
- pushError(severity, err.message, err.start, err.end);
33
+ pushError(severity, err.message, err.start, err.end, err.code);
124
34
  }
125
35
  for (const warn of doc.warnings) {
126
- pushError(DiagnosticSeverity.Warning, warn.message, warn.start, warn.end);
36
+ pushError(DiagnosticSeverity.Warning, warn.message, warn.start, warn.end, warn.code);
37
+ }
38
+ };
39
+ const project = (doc) => {
40
+ try {
41
+ return doc.toJS();
42
+ } catch (error) {
43
+ const at = doc.contents?.start ?? 0;
44
+ const message = error instanceof Error ? error.message : String(error);
45
+ pushError(DiagnosticSeverity.Error, message, at, at, "RESOURCE_EXHAUSTION");
46
+ return void 0;
127
47
  }
128
48
  };
129
49
  let data;
130
50
  if (docs.length > 1) {
131
- data = docs.map((doc, i) => {
132
- walk(doc.contents, [i]);
51
+ data = docs.map((doc) => {
52
+ checkIncompatible(doc);
133
53
  collectProblems(doc);
134
- return doc.toJS();
54
+ return project(doc);
135
55
  });
136
56
  } else {
137
57
  const doc = docs[0];
138
58
  if (doc) {
139
- walk(doc.contents, []);
59
+ checkIncompatible(doc);
140
60
  collectProblems(doc);
141
- data = doc.toJS();
61
+ data = project(doc);
142
62
  } else {
143
63
  data = null;
144
64
  }
145
65
  }
146
- const getLocationForJsonPath = (path, closest = false) => {
147
- const p = path.slice();
148
- while (true) {
149
- const range = index.get(pathKey(p));
150
- if (range)
151
- return { range };
152
- if (!closest || p.length === 0)
153
- return void 0;
154
- p.pop();
155
- }
156
- };
66
+ const getLocationForJsonPath = createYamlLocator(docs, lineMap);
157
67
  return { data, diagnostics, getLocationForJsonPath };
158
68
  };
159
69
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amritk/lint",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "description": "A fast, format-agnostic JSON/YAML style-guide linter with JSON Schema and custom rules.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -69,12 +69,12 @@
69
69
  }
70
70
  },
71
71
  "dependencies": {
72
- "@amritk/runtime-validators": "^0.15.1",
73
- "@amritk/yaml": "^0.7.3",
72
+ "@amritk/runtime-validators": "^0.15.2",
73
+ "@amritk/yaml": "^0.8.0",
74
74
  "jsonc-parser": "^3.3.1"
75
75
  },
76
76
  "devDependencies": {
77
- "@amritk/resolve-refs": "^0.8.0",
77
+ "@amritk/resolve-refs": "^0.9.0",
78
78
  "@stoplight/spectral-core": "^1.23.1",
79
79
  "@stoplight/spectral-parsers": "^1.0.5",
80
80
  "@stoplight/spectral-rulesets": "^1.22.7"