@amritk/lint 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -1
- package/dist/parsers/edit-model.js +7 -17
- package/dist/parsers/json.js +21 -2
- package/dist/parsers/lines.d.ts +5 -0
- package/dist/parsers/lines.js +15 -3
- package/dist/parsers/yaml-locator.d.ts +56 -0
- package/dist/parsers/yaml-locator.js +122 -0
- package/dist/parsers/yaml-merge-key.d.ts +58 -0
- package/dist/parsers/yaml-merge-key.js +104 -0
- package/dist/parsers/yaml-non-finite.d.ts +22 -0
- package/dist/parsers/yaml-non-finite.js +77 -0
- package/dist/parsers/yaml.js +26 -109
- package/package.json +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -140,8 +140,9 @@ export type IFixResult = {
|
|
|
140
140
|
};
|
|
141
141
|
/**
|
|
142
142
|
* Lints a document and applies the supplied `fixers` repeatedly until the
|
|
143
|
-
* document stops changing (or {@link MAX_FIX_PASSES} is reached)
|
|
144
|
-
*
|
|
143
|
+
* document stops changing (or {@link MAX_FIX_PASSES} is reached). `remaining`
|
|
144
|
+
* reflects the fixed document: the converging pass already linted it, so only
|
|
145
|
+
* a run that hit the cap lints once more. A one-call convenience over
|
|
145
146
|
* {@link lintDocumentWithResult} + `createFixPlugin`. With no `fixers` this is a
|
|
146
147
|
* no-op that just returns the findings.
|
|
147
148
|
*/
|
package/dist/index.js
CHANGED
|
@@ -83,10 +83,12 @@ const fixDocument = async (input, options = {}) => {
|
|
|
83
83
|
let converged = false;
|
|
84
84
|
const applied = [];
|
|
85
85
|
const seen = /* @__PURE__ */ new Set();
|
|
86
|
+
let convergedDiagnostics;
|
|
86
87
|
for (let pass = 0; pass < MAX_FIX_PASSES; pass++) {
|
|
87
88
|
const result = await runLint(current, ruleset, { ...lintOptions, plugins: [plugin] });
|
|
88
89
|
if (result.output === void 0 || result.output === current) {
|
|
89
90
|
converged = true;
|
|
91
|
+
convergedDiagnostics = result.diagnostics;
|
|
90
92
|
break;
|
|
91
93
|
}
|
|
92
94
|
current = result.output;
|
|
@@ -102,7 +104,7 @@ const fixDocument = async (input, options = {}) => {
|
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
}
|
|
105
|
-
const remaining = (await runLint(current, ruleset, lintOptions)).diagnostics;
|
|
107
|
+
const remaining = convergedDiagnostics ?? (await runLint(current, ruleset, lintOptions)).diagnostics;
|
|
106
108
|
return { output: current, fixed: current !== input, remaining, applied, converged, passes };
|
|
107
109
|
};
|
|
108
110
|
export {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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) =>
|
|
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[
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) {
|
package/dist/parsers/json.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getNodeValue, parseTree, printParseErrorCode } from "jsonc-parser";
|
|
2
2
|
import { findExcessiveNesting, MAX_NESTING_DEPTH } from "./depth.js";
|
|
3
3
|
import { createLineMap } from "./lines.js";
|
|
4
4
|
import { DiagnosticSeverity } from "./types.js";
|
|
@@ -35,7 +35,7 @@ const parseJson = (source) => {
|
|
|
35
35
|
return void 0;
|
|
36
36
|
const p = path.slice();
|
|
37
37
|
while (true) {
|
|
38
|
-
const node =
|
|
38
|
+
const node = findNode(root, p);
|
|
39
39
|
if (node) {
|
|
40
40
|
return {
|
|
41
41
|
range: {
|
|
@@ -51,6 +51,25 @@ const parseJson = (source) => {
|
|
|
51
51
|
};
|
|
52
52
|
return { data, diagnostics, getLocationForJsonPath };
|
|
53
53
|
};
|
|
54
|
+
const ARRAY_INDEX = /^(?:0|[1-9]\d*)$/;
|
|
55
|
+
const findNode = (root, path) => {
|
|
56
|
+
let node = root;
|
|
57
|
+
for (const segment of path) {
|
|
58
|
+
if (node === void 0)
|
|
59
|
+
return void 0;
|
|
60
|
+
if (node.type === "object") {
|
|
61
|
+
const key = String(segment);
|
|
62
|
+
const property = node.children?.find((child) => child.children?.length === 2 && child.children[0]?.value === key);
|
|
63
|
+
node = property?.children?.[1];
|
|
64
|
+
} else if (node.type === "array") {
|
|
65
|
+
const text = String(segment);
|
|
66
|
+
node = ARRAY_INDEX.test(text) ? node.children?.[Number(text)] : void 0;
|
|
67
|
+
} else {
|
|
68
|
+
return void 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return node;
|
|
72
|
+
};
|
|
54
73
|
export {
|
|
55
74
|
parseJson
|
|
56
75
|
};
|
package/dist/parsers/lines.d.ts
CHANGED
|
@@ -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;
|
package/dist/parsers/lines.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
const createLineMap = (source) => {
|
|
2
2
|
const length = source.length;
|
|
3
3
|
const lineStarts = [0];
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
+
};
|
package/dist/parsers/yaml.js
CHANGED
|
@@ -1,35 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseAllDocuments } from "@amritk/yaml";
|
|
2
2
|
import { createLineMap } from "./lines.js";
|
|
3
3
|
import { DiagnosticSeverity } from "./types.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
if (isSeq(node)) {
|
|
7
|
-
return node.items.length === 0 ? "[]" : `[ ${node.items.map(flowText).join(", ")} ]`;
|
|
8
|
-
}
|
|
9
|
-
if (isMap(node)) {
|
|
10
|
-
const pairs = node.items.filter(isPair).map((pair) => `${flowText(pair.key)}: ${pair.value ? flowText(pair.value) : "null"}`);
|
|
11
|
-
return pairs.length === 0 ? "{}" : `{ ${pairs.join(", ")} }`;
|
|
12
|
-
}
|
|
13
|
-
return flowText(node);
|
|
14
|
-
};
|
|
15
|
-
const flowText = (node) => {
|
|
16
|
-
if (isScalar(node)) {
|
|
17
|
-
const v = node.value;
|
|
18
|
-
return typeof v === "string" ? v : v === null ? "null" : String(v);
|
|
19
|
-
}
|
|
20
|
-
if (isAlias(node))
|
|
21
|
-
return node.target ? flowText(node.target) : `*${node.source}`;
|
|
22
|
-
return serializeComplexKey(node);
|
|
23
|
-
};
|
|
24
|
-
const keyToString = (key) => {
|
|
25
|
-
if (isScalar(key)) {
|
|
26
|
-
const v = key.value;
|
|
27
|
-
return typeof v === "string" ? v : v === null ? "" : String(v);
|
|
28
|
-
}
|
|
29
|
-
if (isAlias(key))
|
|
30
|
-
return key.target ? keyToString(key.target) : `*${key.source}`;
|
|
31
|
-
return serializeComplexKey(key);
|
|
32
|
-
};
|
|
4
|
+
import { createYamlLocator } from "./yaml-locator.js";
|
|
5
|
+
import { reportNonFinite } from "./yaml-non-finite.js";
|
|
33
6
|
const parseYaml = (source, options = {}) => {
|
|
34
7
|
const lineMap = createLineMap(source);
|
|
35
8
|
const duplicateKeys = options.duplicateKeys;
|
|
@@ -38,7 +11,6 @@ const parseYaml = (source, options = {}) => {
|
|
|
38
11
|
const incompatibleValues = options.incompatibleValues;
|
|
39
12
|
const incompatSeverity = typeof incompatibleValues === "number" ? incompatibleValues : void 0;
|
|
40
13
|
const docs = parseAllDocuments(source, { uniqueKeys: !dedupe });
|
|
41
|
-
const index = /* @__PURE__ */ new Map();
|
|
42
14
|
const diagnostics = [];
|
|
43
15
|
const pushError = (severity, message, start, end, code) => {
|
|
44
16
|
diagnostics.push({
|
|
@@ -48,105 +20,50 @@ const parseYaml = (source, options = {}) => {
|
|
|
48
20
|
range: { start: lineMap.positionAt(start), end: lineMap.positionAt(end) }
|
|
49
21
|
});
|
|
50
22
|
};
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
end: lineMap.positionAt(node.end)
|
|
54
|
-
});
|
|
55
|
-
let budget = Math.max(1e5, source.length * 100);
|
|
56
|
-
const isMergePair = (pair) => isScalar(pair.key) && pair.key.source === "<<";
|
|
57
|
-
const walkMerge = (node, path) => {
|
|
58
|
-
const target = node != null && isAlias(node) ? node.target : node;
|
|
59
|
-
if (target == null)
|
|
60
|
-
return;
|
|
61
|
-
if (isSeq(target)) {
|
|
62
|
-
for (const item of target.items)
|
|
63
|
-
walkMerge(item, path);
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
if (!isMap(target))
|
|
67
|
-
return;
|
|
68
|
-
for (const item of target.items) {
|
|
69
|
-
if (!isPair(item))
|
|
70
|
-
continue;
|
|
71
|
-
if (isMergePair(item)) {
|
|
72
|
-
walkMerge(item.value, path);
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
const childPath = [...path, keyToString(item.key)];
|
|
76
|
-
if (!index.has(pathKey(childPath)))
|
|
77
|
-
walk(item.value, childPath);
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
const walk = (node, path) => {
|
|
81
|
-
if (node == null || budget-- <= 0)
|
|
82
|
-
return;
|
|
83
|
-
index.set(pathKey(path), rangeOf(node));
|
|
84
|
-
if (isScalar(node)) {
|
|
85
|
-
const value = node.value;
|
|
86
|
-
if (incompatSeverity !== void 0 && typeof value === "number" && !Number.isFinite(value)) {
|
|
87
|
-
pushError(incompatSeverity, `Value ${String(value)} cannot be represented in JSON and will serialize to null.`, node.start, node.end, "INCOMPATIBLE_VALUE");
|
|
88
|
-
}
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
const target = isAlias(node) ? node.target : node;
|
|
92
|
-
if (target == null)
|
|
23
|
+
const checkIncompatible = (doc) => {
|
|
24
|
+
if (incompatSeverity === void 0)
|
|
93
25
|
return;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (!isPair(item))
|
|
98
|
-
continue;
|
|
99
|
-
if (isMergePair(item)) {
|
|
100
|
-
merges.push(item.value);
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
walk(item.value, [...path, keyToString(item.key)]);
|
|
104
|
-
}
|
|
105
|
-
for (const merge of merges)
|
|
106
|
-
walkMerge(merge, path);
|
|
107
|
-
} else if (isSeq(target)) {
|
|
108
|
-
target.items.forEach((item, i) => {
|
|
109
|
-
walk(item, [...path, i]);
|
|
110
|
-
});
|
|
111
|
-
}
|
|
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
|
+
});
|
|
112
29
|
};
|
|
113
30
|
const collectProblems = (doc) => {
|
|
114
31
|
for (const err of doc.errors) {
|
|
115
32
|
const severity = err.code === "DUPLICATE_KEY" ? dupSeverity : DiagnosticSeverity.Error;
|
|
116
|
-
pushError(severity, err.message, err.start, err.end);
|
|
33
|
+
pushError(severity, err.message, err.start, err.end, err.code);
|
|
117
34
|
}
|
|
118
35
|
for (const warn of doc.warnings) {
|
|
119
|
-
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;
|
|
120
47
|
}
|
|
121
48
|
};
|
|
122
49
|
let data;
|
|
123
50
|
if (docs.length > 1) {
|
|
124
|
-
data = docs.map((doc
|
|
125
|
-
|
|
51
|
+
data = docs.map((doc) => {
|
|
52
|
+
checkIncompatible(doc);
|
|
126
53
|
collectProblems(doc);
|
|
127
|
-
return doc
|
|
54
|
+
return project(doc);
|
|
128
55
|
});
|
|
129
56
|
} else {
|
|
130
57
|
const doc = docs[0];
|
|
131
58
|
if (doc) {
|
|
132
|
-
|
|
59
|
+
checkIncompatible(doc);
|
|
133
60
|
collectProblems(doc);
|
|
134
|
-
data = doc
|
|
61
|
+
data = project(doc);
|
|
135
62
|
} else {
|
|
136
63
|
data = null;
|
|
137
64
|
}
|
|
138
65
|
}
|
|
139
|
-
const getLocationForJsonPath = (
|
|
140
|
-
const p = path.slice();
|
|
141
|
-
while (true) {
|
|
142
|
-
const range = index.get(pathKey(p));
|
|
143
|
-
if (range)
|
|
144
|
-
return { range };
|
|
145
|
-
if (!closest || p.length === 0)
|
|
146
|
-
return void 0;
|
|
147
|
-
p.pop();
|
|
148
|
-
}
|
|
149
|
-
};
|
|
66
|
+
const getLocationForJsonPath = createYamlLocator(docs, lineMap);
|
|
150
67
|
return { data, diagnostics, getLocationForJsonPath };
|
|
151
68
|
};
|
|
152
69
|
export {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/lint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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.
|
|
73
|
-
"@amritk/yaml": "^0.
|
|
72
|
+
"@amritk/runtime-validators": "^0.15.1",
|
|
73
|
+
"@amritk/yaml": "^0.8.0",
|
|
74
74
|
"jsonc-parser": "^3.3.1"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@amritk/resolve-refs": "^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"
|